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:
parent
6dac8a2c7f
commit
16e9f5f0cc
7 changed files with 840 additions and 0 deletions
15
tests/fixtures/t212_pie_export.csv
vendored
Normal file
15
tests/fixtures/t212_pie_export.csv
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
"Slice","Name","Invested value","Value","Result","Owned quantity","Dividends gained","Dividends cash","Dividends reinvested"
|
||||
"SGLN","iShares Physical Gold",2325,2324.3,-0.7,"35.12084592","N/A","N/A","N/A"
|
||||
"IGLS","iShares UK Gilts 0-5yr (Dist)",2092.5,2089.84,-2.66,"16.59397303","N/A","N/A","N/A"
|
||||
"VEUR","Vanguard FTSE Developed Europe (Dist)",1511.25,1507.96,-3.29,"36.59200968","N/A","N/A","N/A"
|
||||
"HMCH","HSBC MSCI China (Dist)",1162.5,1160.88,-1.62,"197.68022163","N/A","N/A","N/A"
|
||||
"INRG","iShares Global Clean Energy Transition (Dist)",1162.5,1170.95,8.45,"125.20193861","N/A","N/A","N/A"
|
||||
"VFEM","Vanguard FTSE Emerging Markets (Dist)",581.25,581.11,-0.14,"9.55610357","N/A","N/A","N/A"
|
||||
"VJPN","Vanguard FTSE Japan (Dist)",581.25,582.61,1.36,"15.48657829","N/A","N/A","N/A"
|
||||
"BA","BAE Systems",578.34,563.12,-15.22,"30.43093922","N/A","N/A","N/A"
|
||||
"TTE","TotalEnergies",346.83,349.06,2.23,"5.08932089","N/A","N/A","N/A"
|
||||
"NATP","Future of Defence UCITS ETF (Acc)",348.75,347.1,-1.65,"23.59926918","N/A","N/A","N/A"
|
||||
"EQNR","Equinor",348.23,358.52,10.29,"12.14586234","N/A","N/A","N/A"
|
||||
"SHEL","Shell",347.01,350.47,3.46,"10.97091368","N/A","N/A","N/A"
|
||||
"BP","BP",231.34,233.41,2.07,"42.26932212","N/A","N/A","N/A"
|
||||
"Total","Defensive Ex-US 2026",11616.75,11619.33,2.58,"-","0","0","0"
|
||||
|
182
tests/test_csv_import.py
Normal file
182
tests/test_csv_import.py
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
"""Tests for app.services.csv_import.
|
||||
|
||||
Uses the real T212 pie-export sample at tests/fixtures/t212_pie_export.csv
|
||||
for the happy path, then synthetic fixtures for edge cases.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.csv_import import (
|
||||
CSVImportError,
|
||||
ParsedPie,
|
||||
ParsedPosition,
|
||||
parse_t212_csv,
|
||||
)
|
||||
|
||||
|
||||
FIXTURE = Path(__file__).parent / "fixtures" / "t212_pie_export.csv"
|
||||
|
||||
|
||||
# --- Happy path: real T212 export ------------------------------------------
|
||||
|
||||
|
||||
def test_parses_real_t212_export():
|
||||
pie = parse_t212_csv(FIXTURE.read_bytes())
|
||||
assert isinstance(pie, ParsedPie)
|
||||
assert pie.name == "Defensive Ex-US 2026"
|
||||
assert pie.invested == pytest.approx(11616.75)
|
||||
assert pie.value == pytest.approx(11619.33)
|
||||
assert pie.result == pytest.approx(2.58)
|
||||
assert len(pie.positions) == 13
|
||||
|
||||
|
||||
def test_first_position_fields_resolved():
|
||||
pie = parse_t212_csv(FIXTURE.read_bytes())
|
||||
sgln = next(p for p in pie.positions if p.slice == "SGLN")
|
||||
assert sgln.name == "iShares Physical Gold"
|
||||
assert sgln.invested_value == pytest.approx(2325)
|
||||
assert sgln.current_value == pytest.approx(2324.3)
|
||||
assert sgln.result == pytest.approx(-0.7)
|
||||
assert sgln.quantity == pytest.approx(35.12084592)
|
||||
assert sgln.dividends_gained is None # 'N/A' in source
|
||||
assert sgln.average_price == pytest.approx(2325 / 35.12084592)
|
||||
assert sgln.current_price == pytest.approx(2324.3 / 35.12084592)
|
||||
|
||||
|
||||
def test_total_row_excluded_from_positions():
|
||||
pie = parse_t212_csv(FIXTURE.read_bytes())
|
||||
assert not any(p.slice.lower() == "total" for p in pie.positions)
|
||||
|
||||
|
||||
# --- Edge cases: defensive parsing -----------------------------------------
|
||||
|
||||
|
||||
def test_empty_file_raises():
|
||||
with pytest.raises(CSVImportError, match="Empty"):
|
||||
parse_t212_csv("")
|
||||
|
||||
|
||||
def test_missing_required_column_raises():
|
||||
# Only Name, no Slice or quantity
|
||||
csv = '"Name","Value"\n"iShares","100"\n'
|
||||
with pytest.raises(CSVImportError, match="missing required column"):
|
||||
parse_t212_csv(csv)
|
||||
|
||||
|
||||
def test_no_position_rows_raises():
|
||||
# Headers present but only the Total aggregate row.
|
||||
csv = (
|
||||
'"Slice","Name","Invested value","Value","Result","Owned quantity"\n'
|
||||
'"Total","Empty Pie",0,0,0,"-"\n'
|
||||
)
|
||||
with pytest.raises(CSVImportError, match="no parseable position"):
|
||||
parse_t212_csv(csv)
|
||||
|
||||
|
||||
def test_reordered_columns():
|
||||
"""T212 sometimes re-orders columns between exports. Header-name matching
|
||||
has to make that a non-issue."""
|
||||
csv = (
|
||||
'"Name","Owned quantity","Slice","Value","Invested value","Result"\n'
|
||||
'"Shell","10.5","SHEL","350","340","10"\n'
|
||||
'"Total","-","Total","350","340","10"\n'
|
||||
)
|
||||
pie = parse_t212_csv(csv)
|
||||
assert len(pie.positions) == 1
|
||||
p = pie.positions[0]
|
||||
assert p.slice == "SHEL"
|
||||
assert p.name == "Shell"
|
||||
assert p.quantity == pytest.approx(10.5)
|
||||
assert p.invested_value == pytest.approx(340)
|
||||
|
||||
|
||||
def test_unknown_columns_silently_ignored():
|
||||
csv = (
|
||||
'"Slice","Name","Owned quantity","Invested value",'
|
||||
'"Something T212 added later","Value"\n'
|
||||
'"SHEL","Shell","10","100","ignore me","105"\n'
|
||||
'"Total","-","-","100","-","105"\n'
|
||||
)
|
||||
pie = parse_t212_csv(csv)
|
||||
assert pie.positions[0].slice == "SHEL"
|
||||
assert pie.positions[0].current_value == pytest.approx(105)
|
||||
|
||||
|
||||
def test_handles_utf8_bom():
|
||||
"""Excel-saved CSVs often have a UTF-8 BOM. Should not break header
|
||||
matching for the first column."""
|
||||
csv = (
|
||||
""
|
||||
'"Slice","Name","Invested value","Value","Result","Owned quantity"\n'
|
||||
'"SHEL","Shell",100,105,5,"10"\n'
|
||||
'"Total","Test",100,105,5,"-"\n'
|
||||
).encode("utf-8")
|
||||
pie = parse_t212_csv(csv)
|
||||
assert pie.positions[0].slice == "SHEL"
|
||||
assert pie.name == "Test"
|
||||
|
||||
|
||||
def test_dash_and_na_become_none():
|
||||
csv = (
|
||||
'"Slice","Name","Invested value","Value","Result","Owned quantity",'
|
||||
'"Dividends gained","Dividends cash","Dividends reinvested"\n'
|
||||
'"SHEL","Shell",100,"-",,"10","N/A","—","n/a"\n'
|
||||
'"Total","P",100,100,0,"-","0","0","0"\n'
|
||||
)
|
||||
pie = parse_t212_csv(csv)
|
||||
p = pie.positions[0]
|
||||
assert p.current_value is None
|
||||
assert p.result is None
|
||||
assert p.dividends_gained is None
|
||||
assert p.dividends_cash is None
|
||||
assert p.dividends_reinvested is None
|
||||
|
||||
|
||||
def test_thousand_separator_commas_tolerated():
|
||||
csv = (
|
||||
'"Slice","Name","Invested value","Value","Result","Owned quantity"\n'
|
||||
'"BIG","Mega Holding","1,234,567.89","1,250,000",15432.11,"100"\n'
|
||||
'"Total","Big Pie","1,234,567.89","1,250,000",15432.11,"-"\n'
|
||||
)
|
||||
pie = parse_t212_csv(csv)
|
||||
assert pie.positions[0].invested_value == pytest.approx(1234567.89)
|
||||
assert pie.invested == pytest.approx(1234567.89)
|
||||
|
||||
|
||||
def test_blank_rows_skipped():
|
||||
csv = (
|
||||
'"Slice","Name","Invested value","Value","Result","Owned quantity"\n'
|
||||
'\n'
|
||||
'"SHEL","Shell",100,105,5,"10"\n'
|
||||
' \n'
|
||||
'"Total","P",100,105,5,"-"\n'
|
||||
)
|
||||
pie = parse_t212_csv(csv)
|
||||
assert len(pie.positions) == 1
|
||||
|
||||
|
||||
def test_zero_quantity_skipped():
|
||||
"""A position row with quantity=0 isn't a real holding — likely a stub
|
||||
left over from a fully-sold position. Don't fail; skip."""
|
||||
csv = (
|
||||
'"Slice","Name","Invested value","Value","Result","Owned quantity"\n'
|
||||
'"GONE","Sold Out",0,0,0,"0"\n'
|
||||
'"SHEL","Shell",100,105,5,"10"\n'
|
||||
'"Total","P",100,105,5,"-"\n'
|
||||
)
|
||||
pie = parse_t212_csv(csv)
|
||||
assert [p.slice for p in pie.positions] == ["SHEL"]
|
||||
|
||||
|
||||
def test_position_without_total_row_still_parses():
|
||||
csv = (
|
||||
'"Slice","Name","Invested value","Value","Result","Owned quantity"\n'
|
||||
'"SHEL","Shell",100,105,5,"10"\n'
|
||||
)
|
||||
pie = parse_t212_csv(csv)
|
||||
assert len(pie.positions) == 1
|
||||
assert pie.name is None
|
||||
assert pie.invested is None
|
||||
117
tests/test_instrument_map.py
Normal file
117
tests/test_instrument_map.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue