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