Merge: CBU0/CSBGU0 NLV fix + ib-gateway daily-restart fix

- fix(ucits): key the NLV stray-check on IBKR's reported symbol — the IEF UCITS
  line (ticker CBU0) reports holdings as canonical CSBGU0, so the legitimate
  Treasury book position was flagged stray + dropped from NLV. Adds
  UcitsListing.ibkr_symbol (probe-verified: only CBU0 diverges) + probe-ucits-symbols CLI.
- fix(ib-gateway): zero-pad AUTO_RESTART_TIME (8:00->08:00 AM) — IBC rejected the
  malformed time, leaving the daily auto-restart unconfigured -> ~16 restarts/day.
  Fixed + verified live (pod RESTARTS=0). Liveness failureThreshold 5->6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 22:10:01 +00:00
6 changed files with 98 additions and 7 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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."""

View File

@@ -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),

View File

@@ -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