First two slices of the multi-user roadmap (Phase B). Validates the
core onboarding mechanic against the user's real T212 export before
paying any auth/tenancy tax.
CSV parser (app/services/csv_import.py):
- Header-name matched (survives T212 reordering columns between
exports), tolerant of UTF-8 BOM, dash/N/A/empty markers, thousand-
separator commas, blank rows, zero-quantity stubs, missing Total row.
- Returns ParsedPie(name, positions, invested, value, result) with
derived avg_price + current_price per share in account currency.
- 14 tests covering happy path on the real CSV + 13 edge cases.
InstrumentMap (migration 0006 + app/services/instrument_map.py):
- Catalogue table mapping T212 ticker → Yahoo ticker, populated by
sync_from_t212() against the dev's read-only API key. Manual rows
(manual=True) are protected from auto-overwrite.
- Pure t212_ticker_to_yahoo() handles both suffix forms: single
trailing exchange letter (l/a/p/d/m/s/...) and country code (US,
DE, FR, IT, CA, ...). All 13 of the user's holdings + 15 case-
coverage tests pass.
- Live sync against T212 ingests 17,050 instruments (~2.2% unmappable
on exotic exchanges; can extend the suffix map later).
- resolve_slice() picks the right listing per shortName using a
UK-friendly currency preference (GBX > GBP > EUR > USD). Resolved
correctly for all 13 of the user's positions, including TTE on
Paris vs the NYSE dual-listing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
49 lines
2 KiB
Python
49 lines
2 KiB
Python
"""instrument_map — T212 shortcode → Yahoo ticker mapping
|
|
|
|
Revision ID: 0006
|
|
Revises: 0005
|
|
Create Date: 2026-05-16
|
|
"""
|
|
from typing import Sequence, Union
|
|
|
|
import sqlalchemy as sa
|
|
from alembic import op
|
|
|
|
|
|
revision: str = "0006"
|
|
down_revision: Union[str, None] = "0005"
|
|
branch_labels: Union[str, Sequence[str], None] = None
|
|
depends_on: Union[str, Sequence[str], None] = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
op.create_table(
|
|
"instrument_map",
|
|
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
|
|
# T212's internal ticker (e.g. "SGLNl_EQ", "EQNR_US_EQ"). Unique
|
|
# globally because each T212 ticker is one listing.
|
|
sa.Column("t212_ticker", sa.String(64), nullable=False),
|
|
# T212's short name (e.g. "SGLN") — the value in the CSV's "Slice"
|
|
# column. Not unique: many tickers share a shortName across listings.
|
|
sa.Column("t212_shortname", sa.String(32), nullable=False),
|
|
# Computed Yahoo Finance symbol. Nullable so unmappable rows still
|
|
# land in the table (we may add manual mappings later).
|
|
sa.Column("yahoo_ticker", sa.String(32)),
|
|
sa.Column("name", sa.String(128), nullable=False),
|
|
# T212's currencyCode (GBP, GBX, USD, EUR, CHF, …).
|
|
sa.Column("currency", sa.String(8)),
|
|
sa.Column("isin", sa.String(16)),
|
|
sa.Column("instrument_type", sa.String(16)), # STOCK, ETF, …
|
|
# True when the row was hand-edited; auto-sync won't overwrite.
|
|
sa.Column("manual", sa.Boolean, nullable=False, server_default=sa.text("0")),
|
|
sa.Column("last_verified_at", sa.DateTime(timezone=True), nullable=False),
|
|
sa.UniqueConstraint("t212_ticker", name="uq_imap_t212_ticker"),
|
|
)
|
|
op.create_index("ix_imap_shortname", "instrument_map", ["t212_shortname"])
|
|
op.create_index("ix_imap_isin", "instrument_map", ["isin"])
|
|
|
|
|
|
def downgrade() -> None:
|
|
op.drop_index("ix_imap_isin", table_name="instrument_map")
|
|
op.drop_index("ix_imap_shortname", table_name="instrument_map")
|
|
op.drop_table("instrument_map")
|