diff --git a/cli/src/_common.py b/cli/src/_common.py index c6a5144..fbf6cad 100644 --- a/cli/src/_common.py +++ b/cli/src/_common.py @@ -114,6 +114,7 @@ def print_output( request_id: str | None = None, title: str | None = None, strict: bool | None = None, + warnings: list[str] | None = None, ) -> None: _ = json_output _ = title @@ -121,6 +122,7 @@ def print_output( "ok": True, "data": data, "error": None, + "warnings": warnings, "meta": build_meta(command=command, request_id=request_id, strict=strict), } print(json.dumps(payload, default=str, separators=(",", ":"))) diff --git a/cli/src/market.py b/cli/src/market.py index adcc4ed..2a108ef 100644 --- a/cli/src/market.py +++ b/cli/src/market.py @@ -92,8 +92,9 @@ def quote( result = run_async(daemon_request(state, command, params)) data = result.data quotes = data.get("quotes", []) + warnings: list[str] = [] if isinstance(quotes, list): - _warn_on_quote_results( + warnings = _warn_on_quote_results( quotes, provider=state.config.provider, intent=str(data.get("intent") or state.config.market_data.quote_intent_default), @@ -105,6 +106,7 @@ def quote( command=command, request_id=result.request_id, strict=state.strict, + warnings=warnings or None, ) except BrokerError as exc: handle_error(exc, json_output=state.json_output, command=command, strict=state.strict) @@ -319,43 +321,45 @@ def _warn_on_quote_results( provider: str, intent: str, provider_capabilities: object | None, -) -> None: +) -> list[str]: + warnings: list[str] = [] + symbols = _symbols_with_empty_quotes(quotes) if symbols: symbol_text = ", ".join(symbols) if provider == "ib": delayed_supported = _provider_supports(provider_capabilities, "delayed") - delayed_text = "Delayed fallback appears unavailable for this session/account." if not delayed_supported else "" - typer.echo( + delayed_text = " Delayed fallback appears unavailable for this session/account." if not delayed_supported else "" + msg = ( f"No quote data returned for {symbol_text} (bid/ask/last/volume are null). " - "Verify IBKR market-data permissions/subscriptions for the requested symbol. " - f"{delayed_text}".strip(), - err=True, + "Verify IBKR market-data permissions/subscriptions for the requested symbol." + f"{delayed_text}" ) - return - typer.echo( - f"No quote data returned for {symbol_text} (bid/ask/last/volume are null). " - "Verify symbol validity and provider market-data permissions.", - err=True, - ) - return + else: + msg = ( + f"No quote data returned for {symbol_text} (bid/ask/last/volume are null). " + "Verify symbol validity and provider market-data permissions." + ) + warnings.append(msg) + typer.echo(msg, err=True) + return warnings if intent == QuoteIntent.TOP_OF_BOOK.value: partial = _symbols_with_missing_top_of_book(quotes) if partial: - typer.echo( - f"Top-of-book data is incomplete for {', '.join(partial)} (bid and/or ask is null).", - err=True, - ) - return + msg = f"Top-of-book data is incomplete for {', '.join(partial)} (bid and/or ask is null)." + warnings.append(msg) + typer.echo(msg, err=True) + return warnings if intent == QuoteIntent.BEST_EFFORT.value: last_only_symbols = _symbols_with_last_only(quotes) if last_only_symbols and provider == "ib": - typer.echo( - f"Bid/ask unavailable for {', '.join(last_only_symbols)}; showing last price from available market data.", - err=True, - ) + msg = f"Bid/ask unavailable for {', '.join(last_only_symbols)}; showing last price from available market data." + warnings.append(msg) + typer.echo(msg, err=True) + + return warnings def _symbols_with_missing_top_of_book(quotes: list[dict[str, object]]) -> list[str]: diff --git a/daemon/pyproject.toml b/daemon/pyproject.toml index 1c6b6b8..873d0a4 100644 --- a/daemon/pyproject.toml +++ b/daemon/pyproject.toml @@ -38,3 +38,9 @@ packages = ["src/broker_daemon"] [tool.pytest.ini_options] asyncio_mode = "auto" pythonpath = ["src"] + +[dependency-groups] +dev = [ + "pytest>=9.0.2", + "pytest-asyncio>=1.3.0", +] diff --git a/daemon/src/broker_daemon/providers/ib.py b/daemon/src/broker_daemon/providers/ib.py index 720a9cf..5a7be3b 100644 --- a/daemon/src/broker_daemon/providers/ib.py +++ b/daemon/src/broker_daemon/providers/ib.py @@ -578,11 +578,11 @@ async def option_chain( ticker = (await self._ib.reqTickersAsync(contract))[0] market_price_attr = getattr(ticker, "marketPrice", None) if callable(market_price_attr): - underlying = _to_float_or_none(market_price_attr()) + underlying = _to_float_or_none(market_price_attr(), reject_zero=True) else: - underlying = _to_float_or_none(market_price_attr) + underlying = _to_float_or_none(market_price_attr, reject_zero=True) if underlying is None: - underlying = _to_float_or_none(getattr(ticker, "last", None)) + underlying = _to_float_or_none(getattr(ticker, "last", None), reject_zero=True) chain_rows = await self._ib.reqSecDefOptParamsAsync(symbol.upper(), "", contract.secType, contract.conId) if not chain_rows: return OptionChain(symbol=symbol.upper(), underlying_price=underlying, entries=[]) @@ -891,7 +891,7 @@ async def fills(self) -> list[FillRecord]: self._raise_mapped_error("fills", exc) -def _to_float_or_none(value: Any) -> float | None: +def _to_float_or_none(value: Any, *, reject_zero: bool = False) -> float | None: if value is None: return None try: @@ -906,6 +906,10 @@ def _to_float_or_none(value: Any) -> float | None: # IB also uses -1.0 to indicate "no data available" for prices. if out == -1.0: return None + # IB returns 0.0 for price fields when no data is available (e.g. off-hours). + # No exchange-traded instrument has a $0.00 price, so treat as sentinel. + if reject_zero and out == 0.0: + return None return out @@ -942,9 +946,9 @@ def _ticker_to_quote( contract = getattr(ticker, "contract", None) quote = Quote( symbol=getattr(contract, "symbol", ""), - bid=_to_float_or_none(getattr(ticker, "bid", None)), - ask=_to_float_or_none(getattr(ticker, "ask", None)), - last=_to_float_or_none(getattr(ticker, "last", None)), + bid=_to_float_or_none(getattr(ticker, "bid", None), reject_zero=True), + ask=_to_float_or_none(getattr(ticker, "ask", None), reject_zero=True), + last=_to_float_or_none(getattr(ticker, "last", None), reject_zero=True), volume=_to_float_or_none(getattr(ticker, "volume", None)), timestamp=ts, exchange=getattr(contract, "exchange", None), diff --git a/daemon/tests/test_daemon/test_ib_quote_fallback.py b/daemon/tests/test_daemon/test_ib_quote_fallback.py index e1e34fb..ea86e02 100644 --- a/daemon/tests/test_daemon/test_ib_quote_fallback.py +++ b/daemon/tests/test_daemon/test_ib_quote_fallback.py @@ -135,6 +135,26 @@ async def test_quote_retries_with_delayed_data_when_live_snapshot_has_nan_values assert ib.market_data_type_calls == [3, 1] +@pytest.mark.asyncio +async def test_quote_retries_with_delayed_data_when_live_returns_zero_sentinel(fake_ib_module: type[_FakeIB]) -> None: + """IB returns last=0.0 during off-hours; should trigger delayed fallback.""" + fake_ib_module.live_by_symbol = {"AAPL": 0.0} + fake_ib_module.delayed_by_symbol = {"AAPL": 185.22} + + provider = IBProvider(GatewayConfig()) + quotes = await provider.quote(["AAPL"]) + await provider.stop() + + ib = fake_ib_module.instances[-1] + assert quotes[0].symbol == "AAPL" + assert quotes[0].last == pytest.approx(185.22) + assert quotes[0].meta is not None + assert quotes[0].meta.source == "delayed" + assert quotes[0].meta.fallback_used is True + assert ib.req_tickers_calls == [("AAPL",), ("AAPL",)] + assert ib.market_data_type_calls == [3, 1] + + @pytest.mark.asyncio async def test_quote_keeps_live_data_when_available(fake_ib_module: type[_FakeIB]) -> None: fake_ib_module.live_by_symbol = {"AAPL": 190.01} @@ -201,3 +221,8 @@ def test_to_float_or_none_rejects_nan_and_ib_unset_sentinel() -> None: assert _to_float_or_none(-1) is None assert _to_float_or_none(0.0) == 0.0 assert _to_float_or_none(150.25) == 150.25 + # reject_zero: 0.0 treated as sentinel for price fields + assert _to_float_or_none(0.0, reject_zero=True) is None + assert _to_float_or_none(0.0, reject_zero=False) == 0.0 + assert _to_float_or_none(150.25, reject_zero=True) == 150.25 + assert _to_float_or_none(-1.0, reject_zero=True) is None diff --git a/daemon/uv.lock b/daemon/uv.lock index e6fb680..6bc0c51 100644 --- a/daemon/uv.lock +++ b/daemon/uv.lock @@ -80,6 +80,12 @@ reauth = [ { name = "playwright" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + [package.metadata] requires-dist = [ { name = "aiosqlite", specifier = ">=0.20.0" }, @@ -95,6 +101,12 @@ requires-dist = [ ] provides-extras = ["reauth", "dev"] +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-asyncio", specifier = ">=1.3.0" }, +] + [[package]] name = "certifi" version = "2026.1.4"