phase B (1/4): CSV parser + InstrumentMap (T212 shortcode → Yahoo ticker)

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>
This commit is contained in:
Giorgio Gilestro 2026-05-16 10:53:08 +01:00
parent 6dac8a2c7f
commit 16e9f5f0cc
7 changed files with 840 additions and 0 deletions

View file

@ -0,0 +1,117 @@
"""Pure-function tests for app.services.instrument_map.t212_ticker_to_yahoo.
Catalogue sync and DB-backed resolution are covered by integration tests
against the live container, not here.
"""
from __future__ import annotations
import pytest
pytest.importorskip("httpx")
pytest.importorskip("sqlalchemy")
from app.services.instrument_map import t212_ticker_to_yahoo
# --- Single-letter exchange suffix (the common LSE case) -------------------
def test_lse_pence_listing():
# iShares Physical Gold on LSE, quoted in GBX.
assert t212_ticker_to_yahoo("SGLNl_EQ", "SGLN") == "SGLN.L"
assert t212_ticker_to_yahoo("BAl_EQ", "BA") == "BA.L"
assert t212_ticker_to_yahoo("BPl_EQ", "BP") == "BP.L"
assert t212_ticker_to_yahoo("VEURl_EQ", "VEUR") == "VEUR.L"
def test_amsterdam():
# Shell dual-listed on Amsterdam.
assert t212_ticker_to_yahoo("SHELLa_EQ", "SHELL") == "SHELL.AS"
def test_paris():
# TotalEnergies on Paris — T212 still uses the legacy FP shortcode in
# the ticker, but the resolver uses whatever shortName T212 passes.
assert t212_ticker_to_yahoo("FPp_EQ", "TTE") == "TTE.PA"
assert t212_ticker_to_yahoo("FPp_EQ", "FP") == "FP.PA"
def test_frankfurt():
assert t212_ticker_to_yahoo("SAPd_EQ", "SAP") == "SAP.DE"
def test_swiss():
assert t212_ticker_to_yahoo("VFEMs_EQ", "VFEM") == "VFEM.SW"
def test_milan():
assert t212_ticker_to_yahoo("ENIm_EQ", "ENI") == "ENI.MI"
# --- Country-code suffix (US + Canada + EU countries) ----------------------
def test_us_listing_bare():
# NYSE/NASDAQ — Yahoo uses the bare ticker.
assert t212_ticker_to_yahoo("EQNR_US_EQ", "EQNR") == "EQNR"
assert t212_ticker_to_yahoo("AAPL_US_EQ", "AAPL") == "AAPL"
assert t212_ticker_to_yahoo("SHEL_US_EQ", "SHEL") == "SHEL"
def test_country_code_with_uk():
assert t212_ticker_to_yahoo("ABC_GB_EQ", "ABC") == "ABC.L"
def test_country_code_with_germany():
assert t212_ticker_to_yahoo("ALV_DE_EQ", "ALV") == "ALV.DE"
def test_country_code_with_canada():
assert t212_ticker_to_yahoo("RY_CA_EQ", "RY") == "RY.TO"
# --- Edge cases ------------------------------------------------------------
def test_unknown_letter_returns_none():
assert t212_ticker_to_yahoo("FOOz_EQ", "FOO") is None
def test_unknown_country_returns_none():
assert t212_ticker_to_yahoo("FOO_ZZ_EQ", "FOO") is None
def test_no_eq_suffix_returns_none():
assert t212_ticker_to_yahoo("SGLN", "SGLN") is None
assert t212_ticker_to_yahoo("SGLNl", "SGLN") is None
def test_empty_strings():
assert t212_ticker_to_yahoo("", "") is None
assert t212_ticker_to_yahoo("_EQ", "") is None
# --- Full user-portfolio mapping check -------------------------------------
@pytest.mark.parametrize("t212_ticker,short,expected", [
# Mappings derived from probing T212's instruments endpoint earlier
# for the user's actual 13 holdings.
("SGLNl_EQ", "SGLN", "SGLN.L"),
("IGLSl_EQ", "IGLS", "IGLS.L"),
("VEURl_EQ", "VEUR", "VEUR.L"),
("HMCHl_EQ", "HMCH", "HMCH.L"),
("INRGl_EQ", "INRG", "INRG.L"),
("VFEMl_EQ", "VFEM", "VFEM.L"),
("VJPNl_EQ", "VJPN", "VJPN.L"),
("BAl_EQ", "BA", "BA.L"),
("NATPl_EQ", "NATP", "NATP.L"),
("BPl_EQ", "BP", "BP.L"),
# Paris-listed TotalEnergies
("FPp_EQ", "TTE", "TTE.PA"),
# US-listed Equinor + Shell
("EQNR_US_EQ", "EQNR", "EQNR"),
("SHEL_US_EQ", "SHEL", "SHEL"),
])
def test_user_portfolio_mappings(t212_ticker, short, expected):
assert t212_ticker_to_yahoo(t212_ticker, short) == expected