Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cli/src/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,13 +114,15 @@ 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
payload = {
"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=(",", ":")))
Expand Down
50 changes: 27 additions & 23 deletions cli/src/market.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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)
Expand Down Expand Up @@ -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]:
Expand Down
6 changes: 6 additions & 0 deletions daemon/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
18 changes: 11 additions & 7 deletions daemon/src/broker_daemon/providers/ib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=[])
Expand Down Expand Up @@ -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:
Expand All @@ -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


Expand Down Expand Up @@ -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),
Expand Down
25 changes: 25 additions & 0 deletions daemon/tests/test_daemon/test_ib_quote_fallback.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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
12 changes: 12 additions & 0 deletions daemon/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.