fix(ucits): key NLV stray-check on IBKR-reported symbol (CBU0->CSBGU0)
Probe (probe-ucits-symbols, live 2026-07-20) confirmed only the IEF line diverges: config ticker CBU0 resolves to IBKR canonical symbol CSBGU0, which is what positions() reports the holding under. _expected_book_symbols keyed on the display ticker, so the legitimate 55-share IEF/Treasury book position (reported CSBGU0) was flagged 'outside the book' and dropped from NLV. Add UcitsListing.ibkr_symbol (defaults to ticker; set CSBGU0 for IEF) and include it in _expected_book_symbols so the stray-check matches what IBKR actually reports. A genuine stray (IBIT) is still flagged. Also fixed 4 lint items on the touched lines. 11 dashboard tests green (new CSBGU0-not-stray test + IBIT-still-stray + no regression). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -38,7 +38,13 @@ def _expected_book_symbols() -> frozenset[str]:
|
||||
from fxhnt.domain.strategies.multistrat import FUND_INSTRUMENTS
|
||||
syms = set(FUND_INSTRUMENTS)
|
||||
try:
|
||||
syms |= {listing.ticker for listing in get_settings().ucits.map.values()}
|
||||
# Include BOTH the cosmetic ticker AND the symbol IBKR actually REPORTS the holding under
|
||||
# (`ibkr_symbol`, which diverges for the IEF line: ticker CBU0 -> reported CSBGU0, probe 2026-07-20).
|
||||
# account_snapshot reads `p.contract.symbol`, so a legitimate book holding IBKR reports as CSBGU0 must
|
||||
# be in this set or it is wrongly flagged a stray and dropped from the book's NLV.
|
||||
for listing in get_settings().ucits.map.values():
|
||||
syms.add(listing.ticker)
|
||||
syms.add(listing.ibkr_symbol)
|
||||
except Exception: # noqa: BLE001 — config-less test path: the US instruments alone still guard the check
|
||||
pass
|
||||
return frozenset(syms)
|
||||
@@ -152,10 +158,11 @@ class DashboardService:
|
||||
b = backtests.get(reg.strategy_id) # most strategies have no backtest verdict -> None
|
||||
meta = STRATEGY_REGISTRY.get(reg.strategy_id, {}) # honest not-live flag lives in the Python registry
|
||||
if meta.get("archived"): # defense-in-depth: `registry()` already excludes archived rows (the
|
||||
continue # authoritative filter), so an archived sleeve (vrp/vrp_exec) never reaches here.
|
||||
continue # authoritative filter), so an archived sleeve never reaches here.
|
||||
min_days = _gate_min_days(reg.gate_spec) # warming = fewer forward days than the gate needs
|
||||
out.append(FleetRow(
|
||||
strategy_id=reg.strategy_id, display_name=display_name(reg.strategy_id, reg.display_name), sleeve=reg.sleeve,
|
||||
strategy_id=reg.strategy_id, sleeve=reg.sleeve,
|
||||
display_name=display_name(reg.strategy_id, reg.display_name),
|
||||
days=s.days if s else 0,
|
||||
total_return_pct=100.0 * s.total_return if s else 0.0,
|
||||
sharpe=s.sharpe if s else 0.0, maxdd=s.maxdd if s else 0.0,
|
||||
@@ -428,7 +435,8 @@ class DashboardService:
|
||||
periods = {p: aggregate(dates, rets, p) for p in PERIODS} # raw daily/weekly/monthly, no normalization
|
||||
pair = self._exec_pair(strategy_id, summaries)
|
||||
return StrategyDetail(
|
||||
strategy_id=strategy_id, display_name=display_name(strategy_id, reg.display_name), sleeve=reg.sleeve, venue=reg.venue,
|
||||
strategy_id=strategy_id, sleeve=reg.sleeve, venue=reg.venue,
|
||||
display_name=display_name(strategy_id, reg.display_name),
|
||||
days=s.days if s else 0, total_return_pct=100.0 * s.total_return if s else 0.0,
|
||||
sharpe=s.sharpe if s else 0.0, maxdd=s.maxdd if s else 0.0,
|
||||
gate_status=s.gate_status if s else "WAIT",
|
||||
|
||||
@@ -114,6 +114,12 @@ class UcitsListing(BaseModel):
|
||||
isin: str # unambiguous security id (e.g. "IE00B5BMR087") — the real resolution key
|
||||
exchange: str = "LSEETF" # the LSE ETF venue: LSEETF + ISIN + USD resolves the line (SMART+ISIN = 0)
|
||||
currency: str = "USD"
|
||||
# The symbol IBKR REPORTS a HOLDING of this line under (`contract.symbol` from `positions()`), which can
|
||||
# diverge from the cosmetic `ticker`. Probe-verified live 2026-07-20 (`probe-ucits-symbols`): only IEF's
|
||||
# `CBU0` diverges → IBKR reports it as `CSBGU0`; the other 5 lines report under their ticker. The cockpit's
|
||||
# NLV stray-check (`_expected_book_symbols`) keys on this, so a legitimate book position is not mislabeled
|
||||
# a stray + dropped from NLV. Defaults to `ticker` when they match (the common case).
|
||||
ibkr_symbol: str = ""
|
||||
# Yahoo LSE daily-volume ticker (ADV input for the UCITS-regime capacity/cost model). Defaults to
|
||||
# f"{ticker}.L" when left empty; set explicitly if Yahoo's symbol diverges from the display ticker.
|
||||
yahoo_ticker: str = ""
|
||||
@@ -122,9 +128,11 @@ class UcitsListing(BaseModel):
|
||||
half_spread_bps: float = 10.0
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _default_yahoo_ticker(self) -> "UcitsListing":
|
||||
def _default_derived_fields(self) -> UcitsListing:
|
||||
if not self.yahoo_ticker:
|
||||
self.yahoo_ticker = f"{self.ticker}.L"
|
||||
if not self.ibkr_symbol: # the common case: IBKR reports the holding under the ticker
|
||||
self.ibkr_symbol = self.ticker
|
||||
return self
|
||||
|
||||
|
||||
@@ -149,6 +157,7 @@ class UcitsSettings(BaseSettings):
|
||||
map: dict[str, UcitsListing] = Field(default_factory=lambda: {
|
||||
"SPY": UcitsListing(ticker="CSPX", isin="IE00B5BMR087", half_spread_bps=1.5),
|
||||
"IEF": UcitsListing(ticker="CBU0", isin="IE00B3VWN518", yahoo_ticker="CBU0.L", # Acc line, ~$12M ADV
|
||||
ibkr_symbol="CSBGU0", # IBKR reports the HOLDING under CSBGU0 (probe 2026-07-20)
|
||||
half_spread_bps=2.0),
|
||||
"GLD": UcitsListing(ticker="IGLN", isin="IE00B4ND3602", # USD line = IGLN (SGLN is the GBP line)
|
||||
half_spread_bps=2.0),
|
||||
|
||||
@@ -192,3 +192,24 @@ def test_clean_account_holdings_show_no_stray_warning() -> None:
|
||||
# SPY + IEF are both book instruments -> no "outside the book" warning renders.
|
||||
html = _client(_seed_forward(), _seed_ibkr()).get("/strategy/multistrat").text
|
||||
assert "outside the book" not in html
|
||||
|
||||
|
||||
def test_ibkr_reported_ucits_symbol_csbgu0_is_not_flagged_stray() -> None:
|
||||
# The IEF UCITS line's config ticker is CBU0, but IBKR REPORTS the holding under its canonical symbol
|
||||
# CSBGU0 (probe-verified 2026-07-20). A legitimate CSBGU0 book position must NOT be flagged "outside the
|
||||
# book" / dropped from NLV — the expected set keys on `ibkr_symbol`, not just the display ticker. A genuine
|
||||
# stray (IBIT) in the SAME account is still flagged.
|
||||
fwd = _seed_forward()
|
||||
ibkr = IbkrAccountRepo("sqlite://")
|
||||
ibkr.migrate()
|
||||
at = dt.datetime(2026, 7, 20, 15, 0)
|
||||
ibkr.replace_snapshot("2026-07-20", nlv=1_000_000.0, cash=900_000.0, gross=100_000.0,
|
||||
positions={"CSPX": 13.0, "CSBGU0": 55.0, "IBIT": 699.0}, at=at)
|
||||
ibkr.record_fills(strategy_id="multistrat", rebalance_id="r1",
|
||||
fills=[FillDTO(symbol="CSBGU0", side="BOT", qty=55.0, price=153.8, fee=1.0,
|
||||
status="Filled", shortfall_bps=0.0)], at=at)
|
||||
html = _client(fwd, ibkr).get("/strategy/multistrat").text
|
||||
banner = re.search(r"Position outside the book:[^<]*", html)
|
||||
assert banner is not None # IBIT is still a genuine stray
|
||||
assert "IBIT" in banner.group(0)
|
||||
assert "CSBGU0" not in banner.group(0) # the IEF book line is NOT flagged stray
|
||||
|
||||
Reference in New Issue
Block a user