csv-parser: keep LLM-mapped tickers; don't pass them through T212 mapping

The route's resolve-slice loop is T212-specific — it looks tickers up
against the InstrumentMap, which only has T212's universe. For the LLM
path the ticker is already Yahoo-ready (e.g. VOD.L, ASML.AS), so
sending it through resolve_slice produced spurious "could not be
resolved" warnings and dropped the positions.

Fix: ParsedPie gains a ``tickers_resolved`` flag (default False for
T212 backward-compat); _apply_mapping in the LLM path sets it True
and also extracts currency from the LLM-mapped currency_col into a
new ``ParsedPosition.currency`` field. The route branches on the flag:
LLM-path positions are kept verbatim with a best-effort InstrumentMap
lookup for nicer name/currency overrides, never dropped.

Integration test tightened to assert all 5 IBKR fixture positions
round-trip with the right currencies (USD / GBP / EUR).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-05-27 12:48:27 +02:00
parent b8ebba9503
commit bc55ab7d26
4 changed files with 74 additions and 13 deletions

View file

@ -494,6 +494,20 @@ async def test_parse_portfolio_route_falls_through_to_llm(tmp_path, monkeypatch)
)
monkeypatch.setattr(market_mod, "fetch", _fake_fetch)
# ticker_universe.upsert_tickers uses MySQL ON DUPLICATE KEY UPDATE
# which SQLite doesn't compile. Mock the two universe-side effects;
# neither contributes to the JSON contract we're testing here.
from app.services import ticker_universe as tu_mod
async def _fake_upsert(session, tickers):
return len(list(tickers))
async def _fake_buffer(tickers):
return len(list(tickers))
monkeypatch.setattr(tu_mod, "upsert_tickers", _fake_upsert)
monkeypatch.setattr(tu_mod, "buffer_tickers", _fake_buffer)
raw = open("tests/fixtures/ibkr_sample.csv", "rb").read()
upload = UploadFile(filename="ibkr.csv", file=BytesIO(raw))
@ -502,12 +516,18 @@ async def test_parse_portfolio_route_falls_through_to_llm(tmp_path, monkeypatch)
result = await parse_portfolio(file=upload, session=session)
assert result["base_currency"] == "GBP"
# At least the AAPL/MSFT/NVDA rows should be present; resolve_slice may
# filter some if there's no InstrumentMap row, which is fine for this
# test — we just want to confirm the LLM fallback ran end-to-end.
assert isinstance(result["positions"], list)
# All 5 IBKR positions should round-trip — the LLM path trusts the
# Yahoo-ready tickers from the file and does NOT drop on a
# resolve_slice miss (that's the T212 path's behaviour).
tickers = {p["yahoo_ticker"] for p in result["positions"]}
assert tickers == {"AAPL", "MSFT", "NVDA", "VOD.L", "ASML.AS"}
# LLM was called exactly once (cache miss).
assert mod.call_llm.await_count == 1
# Currency comes from the LLM-mapped currency_col, falling back to
# USD only when neither InstrumentMap nor the file specified one.
by_t = {p["yahoo_ticker"]: p["currency"] for p in result["positions"]}
assert by_t["VOD.L"] == "GBP"
assert by_t["ASML.AS"] == "EUR"
def test_parse_portfolio_route_requires_paid():