"""T212 shortcode → Yahoo Finance ticker resolution. The CSV path gives us only T212's `Slice` (short name like "SGLN", "TTE"). To refresh prices via Yahoo we need the proper Yahoo symbol (`SGLN.L`, `TTE.PA`, …). The mapping comes from T212's own catalogue (/equity/metadata/instruments), synced into the `instrument_map` table via the admin's read-only API key. The resolver then picks the right listing per user using currency preference. This module has three responsibilities: 1. **Pure translation** — turn a T212 ticker like `SGLNl_EQ` into a Yahoo symbol like `SGLN.L` from suffix rules. No DB, no HTTP. 2. **Catalogue sync** — pull every T212 instrument and upsert into `instrument_map`. Hand-edited rows (`manual=True`) are never overwritten. 3. **Slice resolution** — given a CSV `Slice` like "SHEL", find the best matching `instrument_map` row using configurable currency preference. """ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta import httpx from sqlalchemy import select from sqlalchemy.dialects.mysql import insert as mysql_insert from sqlalchemy.ext.asyncio import AsyncSession from app.db import utcnow from app.models import InstrumentMap from app.services.trading212 import Trading212 # --- Pure translation: T212 ticker → Yahoo symbol --------------------------- # Single trailing letter before "_EQ" → exchange suffix. # These conventions come from observing T212's instrument catalogue. _T212_LETTER_TO_YAHOO = { "l": ".L", # London (LSE) "a": ".AS", # Amsterdam (Euronext) "p": ".PA", # Paris (Euronext) "d": ".DE", # Frankfurt (Xetra) "m": ".MI", # Milan (Borsa Italiana) "s": ".SW", # Swiss (SIX) "b": ".BR", # Brussels (Euronext) "i": ".IR", # Ireland (Euronext Dublin) "h": ".HE", # Helsinki (Nasdaq Nordic) "c": ".CO", # Copenhagen "o": ".OL", # Oslo } # Country-code suffix (e.g. _US_EQ, _CA_EQ) → Yahoo suffix. _T212_COUNTRY_TO_YAHOO = { "US": "", # NYSE/NASDAQ — Yahoo bare ticker "CA": ".TO", # Toronto "DE": ".DE", "FR": ".PA", "GB": ".L", "IT": ".MI", "ES": ".MC", "NL": ".AS", "BE": ".BR", "IE": ".IR", "FI": ".HE", "CH": ".SW", "NO": ".OL", "DK": ".CO", "SE": ".ST", } def t212_ticker_to_yahoo(t212_ticker: str, short_name: str) -> str | None: """Translate a T212 ticker like 'SGLNl_EQ' or 'EQNR_US_EQ' to its Yahoo Finance symbol. Returns None when the pattern isn't recognised. Rules, in order: 'XXXX__EQ' (country code) → short_name + country suffix e.g. EQNR_US_EQ → EQNR SHEL_US_EQ → SHEL 'XXXX_EQ' (single trailing lowercase letter) → short_name + suffix e.g. SGLNl_EQ → SGLN.L SHELLa_EQ → SHELL.AS FPp_EQ → FP.PA """ if not t212_ticker.endswith("_EQ"): return None body = t212_ticker[:-3] # strip "_EQ" # Country-code form: '..._XX' where XX is a 2-letter country code if len(body) >= 3 and body[-3] == "_" and body[-2:].isupper(): cc = body[-2:] suffix = _T212_COUNTRY_TO_YAHOO.get(cc) if suffix is None: return None return f"{short_name}{suffix}" # Single-letter form: '...x' where x is a recognised exchange letter if body and body[-1] in _T212_LETTER_TO_YAHOO: return f"{short_name}{_T212_LETTER_TO_YAHOO[body[-1]]}" return None # --- Catalogue sync --------------------------------------------------------- @dataclass class SyncResult: fetched: int upserted: int unmappable: int skipped_manual: int async def sync_from_t212( session: AsyncSession, client: httpx.AsyncClient, t212: Trading212 | None = None, ) -> SyncResult: """Pull every T212 instrument and upsert into instrument_map. Hand- edited rows (manual=True) are never overwritten. Runs idempotently.""" t212 = t212 or Trading212() instruments = await t212.instruments(client) or [] # Pre-fetch existing manual mappings so we know which t212_tickers # to skip on upsert. manual_tickers = { r.t212_ticker for r in (await session.execute( select(InstrumentMap).where(InstrumentMap.manual == True) )).scalars().all() } now = utcnow() rows = [] unmappable = 0 skipped_manual = 0 for inst in instruments: tkr = inst.get("ticker") sn = inst.get("shortName") or "" name = inst.get("name") or sn or tkr if not tkr: continue if tkr in manual_tickers: skipped_manual += 1 continue yahoo = t212_ticker_to_yahoo(tkr, sn) if yahoo is None: unmappable += 1 rows.append({ "t212_ticker": tkr, "t212_shortname": sn, "yahoo_ticker": yahoo, "name": (name or sn)[:128], "currency": inst.get("currencyCode"), "isin": inst.get("isin"), "instrument_type": inst.get("type"), "manual": False, "last_verified_at": now, }) if not rows: return SyncResult(fetched=len(instruments), upserted=0, unmappable=unmappable, skipped_manual=skipped_manual) # Bulk upsert. MySQL: ON DUPLICATE KEY UPDATE on t212_ticker unique key. # Chunk to avoid hitting MySQL's max_allowed_packet on 17k+ rows. chunk = 500 upserted = 0 for i in range(0, len(rows), chunk): stmt = mysql_insert(InstrumentMap).values(rows[i:i + chunk]) stmt = stmt.on_duplicate_key_update( yahoo_ticker=stmt.inserted.yahoo_ticker, t212_shortname=stmt.inserted.t212_shortname, name=stmt.inserted.name, currency=stmt.inserted.currency, isin=stmt.inserted.isin, instrument_type=stmt.inserted.instrument_type, last_verified_at=stmt.inserted.last_verified_at, ) await session.execute(stmt) upserted += len(rows[i:i + chunk]) await session.commit() return SyncResult( fetched=len(instruments), upserted=upserted, unmappable=unmappable, skipped_manual=skipped_manual, ) # --- Resolution: CSV Slice → preferred Yahoo ticker ------------------------- # Currency preference for users in a UK account. Listings denominated in the # user's account currency or its smaller-unit (GBX = pence) come first. EUR # ranks ABOVE USD because UK retail brokers (incl. T212) typically default # users to the London + Euronext listings; the NYSE dual-listing only wins # when no European listing exists (e.g. EQNR isn't on T212's Oslo book). _DEFAULT_CCY_PREFERENCE = ("GBX", "GBP", "EUR", "USD", "CHF", "JPY") @dataclass class ResolvedInstrument: t212_ticker: str yahoo_ticker: str | None name: str currency: str | None isin: str | None async def resolve_slice( session: AsyncSession, slice_code: str, currency_preference: tuple[str, ...] = _DEFAULT_CCY_PREFERENCE, ) -> ResolvedInstrument | None: """Find the best Yahoo ticker for a given CSV Slice. Picks the listing whose currency comes first in `currency_preference`. Manual mappings always win over auto-resolved ones.""" if not slice_code: return None rows = (await session.execute( select(InstrumentMap) .where(InstrumentMap.t212_shortname == slice_code) )).scalars().all() if not rows: return None def rank(row: InstrumentMap) -> tuple[int, int]: manual_rank = 0 if row.manual else 1 try: ccy_rank = currency_preference.index(row.currency or "") except ValueError: ccy_rank = len(currency_preference) return (manual_rank, ccy_rank) rows.sort(key=rank) chosen = rows[0] return ResolvedInstrument( t212_ticker=chosen.t212_ticker, yahoo_ticker=chosen.yahoo_ticker, name=chosen.name, currency=chosen.currency, isin=chosen.isin, ) def is_stale(row: InstrumentMap, max_age: timedelta = timedelta(days=14)) -> bool: """True if the row hasn't been refreshed from T212 recently.""" age = utcnow() - row.last_verified_at return age > max_age