From 420cffa93c0eecbdfc573db964f2e671ed935854 Mon Sep 17 00:00:00 2001 From: Jeroen Grusewski Date: Mon, 20 Jul 2026 21:17:34 +0000 Subject: [PATCH 1/3] feat(ucits): read-only probe-ucits-symbols CLI to detect ticker vs IBKR-reported symbol divergence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The IEF UCITS line's config ticker CBU0 resolves to IBKR canonical symbol CSBGU0 (the Error-478 root). After Monday's conId fix, CBU0 fills and IBKR reports the holding as CSBGU0 — but _expected_book_symbols keys on the display ticker CBU0, so the legitimate IEF/Treasury book position is flagged 'stray' and dropped from NLV (live 2026-07-20: positions_json shows CSBGU0:55). This adds IbkrBroker.resolve_symbol_info (read-only qualify-by-ISIN -> symbol/ localSymbol/conId) + the probe-ucits-symbols CLI to enumerate every line's real reported symbol, so the config can carry an ibkr_symbol field and the NLV stray-check keys on the REPORTED symbol. Probe first (this commit), fix config next. Co-Authored-By: Claude Opus 4.8 --- src/fxhnt/adapters/broker/ibkr.py | 23 +++++++++++++++++++++++ src/fxhnt/cli.py | 25 +++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/src/fxhnt/adapters/broker/ibkr.py b/src/fxhnt/adapters/broker/ibkr.py index e31b8f1..59c5f7d 100644 --- a/src/fxhnt/adapters/broker/ibkr.py +++ b/src/fxhnt/adapters/broker/ibkr.py @@ -185,6 +185,29 @@ class IbkrBroker: ticker, isin, exc_info=True) return {} + def resolve_symbol_info(self, *, ticker: str, isin: str, exchange: str = "LSEETF", + currency: str = "USD") -> dict[str, str] | None: + """READ-ONLY: qualify a UCITS line by ISIN and report the identity IBKR actually assigns it — the + `symbol`, `localSymbol`, and `conId` that `positions()`/`accountSummary()` will report a HOLDING under. + Used by the `probe-ucits-symbols` CLI to detect where a config `ticker` (cosmetic) diverges from what + IBKR reports (e.g. IEF's `CBU0` -> canonical `CSBGU0`), so `_expected_book_symbols` can key the NLV + stray-check on the REPORTED symbol instead of the display ticker. Returns None on resolve failure + (logged), never raises — same discipline as `historical_daily_volume`.""" + try: + ib = self._ensure() + contract = self._resolve_contract(ib, ticker, exchange, currency, "ISIN", isin) + if contract is None: + log.error("IBKR contract did NOT resolve for symbol probe: ticker=%r isin=%r", ticker, isin) + return None + return {"ticker": ticker, "isin": isin, + "ibkr_symbol": str(getattr(contract, "symbol", "") or ""), + "local_symbol": str(getattr(contract, "localSymbol", "") or ""), + "con_id": str(getattr(contract, "conId", "") or "")} + except Exception: + log.error("IBKR symbol probe RAISED for ticker=%r (isin=%r) — returning None.", + ticker, isin, exc_info=True) + return None + def place_order(self, order: Order) -> str: from ib_async import MarketOrder diff --git a/src/fxhnt/cli.py b/src/fxhnt/cli.py index 87300b4..f02707a 100644 --- a/src/fxhnt/cli.py +++ b/src/fxhnt/cli.py @@ -695,6 +695,31 @@ def backfill_ucits_volume_ibkr() -> None: f"({type(e).__name__}: {str(e)[:120]}) — skipped") +@app.command("probe-ucits-symbols") +def probe_ucits_symbols() -> None: + """READ-ONLY: for every `settings.ucits.map` line, resolve it on IBKR by ISIN and print the SYMBOL IBKR + actually assigns it (`symbol` / `localSymbol` / `conId`) vs the config `ticker`. Detects where the cosmetic + display ticker diverges from what IBKR REPORTS a holding under — e.g. IEF's ticker `CBU0` resolves to the + canonical `CSBGU0`, which then flags a legitimate book position as a stray in the NLV view. Run it (via the + multistrat-rebalancer-labeled path that can reach ib-gateway:4004) to populate each line's `ibkr_symbol` + config field. Places NO orders; resolves + prints only.""" + s = get_settings() + broker = IbkrBroker(host=s.ibkr.host, port=s.ibkr.port, client_id=s.ibkr.client_id) + typer.echo(f"{'ticker':<8}{'ibkr_symbol':<14}{'local_symbol':<14}{'conId':<12}{'DIVERGES?'}") + typer.echo("-" * 62) + with broker as brk: + for listing in s.ucits.map.values(): + info = brk.resolve_symbol_info(ticker=listing.ticker, isin=listing.isin, + exchange=listing.exchange, currency=listing.currency) + if info is None: + typer.echo(f"{listing.ticker:<8}{'(unresolved)':<40}") + continue + reported = info["ibkr_symbol"] or info["local_symbol"] + diverges = "YES -> set ibkr_symbol" if reported and reported != listing.ticker else "no" + typer.echo(f"{listing.ticker:<8}{info['ibkr_symbol']:<14}{info['local_symbol']:<14}" + f"{info['con_id']:<12}{diverges}") + + @app.command("ingest-forward") def ingest_forward() -> None: """Evaluate + persist the forward gate for every track the engine has recorded in the cockpit DB.""" From 9baf1f81154ea4e9153590e06e920fb82cc86da1 Mon Sep 17 00:00:00 2001 From: Jeroen Grusewski Date: Mon, 20 Jul 2026 21:26:18 +0000 Subject: [PATCH 2/3] 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 --- src/fxhnt/application/dashboard_service.py | 16 ++++++++++---- src/fxhnt/config.py | 11 +++++++++- .../test_ibkr_account_detail_web.py | 21 +++++++++++++++++++ 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/fxhnt/application/dashboard_service.py b/src/fxhnt/application/dashboard_service.py index 081305b..df74f42 100644 --- a/src/fxhnt/application/dashboard_service.py +++ b/src/fxhnt/application/dashboard_service.py @@ -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", diff --git a/src/fxhnt/config.py b/src/fxhnt/config.py index 7fbba3f..e5d9d71 100644 --- a/src/fxhnt/config.py +++ b/src/fxhnt/config.py @@ -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), diff --git a/tests/integration/test_ibkr_account_detail_web.py b/tests/integration/test_ibkr_account_detail_web.py index f266fcc..bcccd34 100644 --- a/tests/integration/test_ibkr_account_detail_web.py +++ b/tests/integration/test_ibkr_account_detail_web.py @@ -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 From a16b341d908b77380296644082ff99645fef5392 Mon Sep 17 00:00:00 2001 From: Jeroen Grusewski Date: Mon, 20 Jul 2026 21:30:01 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix(ib-gateway):=20zero-pad=20AUTO=5FRESTAR?= =?UTF-8?q?T=5FTIME=20(8:00->08:00=20AM)=20=E2=80=94=20stops=20~16=20resta?= =?UTF-8?q?rts/day?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IBC's parser rejected '8:00 AM' ('Auto restart time setting must be hh:mm AM/PM'), leaving the daily auto-restart UNCONFIGURED, so the gateway churned ~35 restarts over 2 days instead of one clean daily cycle. Zero-padded to '08:00 AM' — IBC now logs 'Auto restart time set to 08:00 AM' (verified live 2026-07-20, fresh pod RESTARTS=0). 08:00 UTC is before the 09:00 UTC UCITS rebalance window so the daily restart won't collide with execution. Also bumped liveness failureThreshold 5->6 (180s) for the reauth window. Co-Authored-By: Claude Opus 4.8 --- infra/k8s/services/ib-gateway.yaml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/infra/k8s/services/ib-gateway.yaml b/infra/k8s/services/ib-gateway.yaml index f45b133..96c64b1 100644 --- a/infra/k8s/services/ib-gateway.yaml +++ b/infra/k8s/services/ib-gateway.yaml @@ -76,8 +76,11 @@ spec: # full re-login each time). 08:00 UTC is clear of the 14:35 rebalancer + 23:30 nightly windows. - name: TIME_ZONE value: "Etc/UTC" + # MUST be zero-padded hh:mm (IBC's parser rejects "8:00 AM" with "Auto restart time setting must be + # hh:mm AM or hh:mm PM" — the rejection left the daily auto-restart UNCONFIGURED, so the gateway + # churned ~16 restarts/day instead of one clean daily cycle. Verified in the pod log 2026-07-20.) - name: AUTO_RESTART_TIME - value: "8:00 AM" + value: "08:00 AM" # Persist jts.ini + IBC config across pod restarts (a fresh empty volume just does a clean first-run # login) so settings survive a deploy/reschedule instead of being rebuilt each time. - name: TWS_SETTINGS_PATH @@ -105,7 +108,9 @@ spec: port: 4002 initialDelaySeconds: 120 periodSeconds: 30 - failureThreshold: 5 + # 6×30s = 180s tolerance: the daily auto-restart + IBKR reauth window drops port 4002 for up to a + # couple of minutes; too tight a threshold would k8s-kill the pod mid-reauth and compound the churn. + failureThreshold: 6 volumeMounts: - name: tws-settings mountPath: /home/ibgateway/tws_settings