From 365f0c7181687b2d3cc043e0b85d7586a91e7edb Mon Sep 17 00:00:00 2001 From: Gahow Wang Date: Thu, 1 Oct 2026 11:11:53 -0700 Subject: [PATCH] fix --- src/ibkr_quant/cli.py | 55 +++++++++++++++++++++++++++++++++++-- tests/test_check.py | 64 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 tests/test_check.py diff --git a/src/ibkr_quant/cli.py b/src/ibkr_quant/cli.py index 2e62ba5..039a6c1 100644 --- a/src/ibkr_quant/cli.py +++ b/src/ibkr_quant/cli.py @@ -26,6 +26,30 @@ def update_universe(cfg: Config) -> None: print(f"{len(table)} symbols -> {cfg.data.universe}") +def show_rebalance_schedule(cfg: Config, today: dt.date) -> None: + if not cfg.data.bars.exists(): + print("rebalance schedule unavailable: no daily bars; run `make bars`") + return + dates = pd.read_csv(cfg.data.bars, usecols=["date"], parse_dates=["date"])["date"] + sessions = pd.DatetimeIndex(dates.dropna().unique()).sort_values() + sessions = sessions[sessions.date <= today] + if sessions.empty: + print("rebalance schedule unavailable: no sessions through today; run `make bars`") + return + last = sessions[-1].date() + st = cfg.strategy + wait = sessions_until_rebalance(sessions, st.anchor, st.rebalance_every) + if wait is None: + schedule = f"first trading session on or after {st.anchor}" + elif wait == 0: + schedule = f"{last} (0 trading sessions remaining)" + else: + schedule = f"in {wait} trading sessions" + print(f"next rebalance: {schedule} (as of {last}; every {st.rebalance_every} trading sessions)") + if last < today: + print(f"Bars end {last}; run `make bars` to update the rebalance countdown for {today}.") + + async def check(cfg: Config) -> None: async with Broker(cfg.ib) as broker: state = await broker.account_state() @@ -36,12 +60,37 @@ async def check(cfg: Config) -> None: print(f"{len(trades)} open orders") for t in trades: print(f" {t.contract.symbol} {t.order.action} {t.order.totalQuantity} {t.order.orderType} {t.orderStatus.status}") + show_rebalance_schedule(cfg, dt.datetime.now(NEW_YORK).date()) contracts, _ = await broker.qualify_stocks(["SPY"]) - prices = await broker.snapshot_prices(contracts) + if "SPY" not in contracts: + print("no SPY price: IBKR could not qualify the SPY contract") + return + errors: dict[int, str] = {} + + def on_error(req_id, code, message, contract): + if contract == contracts["SPY"]: + errors[code] = message + + broker.ib.errorEvent += on_error + try: + prices = await broker.snapshot_prices(contracts) + finally: + broker.ib.errorEvent -= on_error if "SPY" in prices: - print(f"SPY snapshot {prices['SPY']:.2f} (market_data_type={cfg.ib.market_data_type})") + print(f"SPY snapshot {prices['SPY']:.2f} (requested market_data_type={cfg.ib.market_data_type})") else: - print("no SPY price: check the market data subscription or set ib.market_data_type = 3") + reason = "; ".join(f"IBKR {code}: {message}" for code, message in errors.items()) or "no valid snapshot received" + print(f"no SPY price (requested market_data_type={cfg.ib.market_data_type}): {reason}") + if 10197 in errors: + print( + "Log out of the competing live market-data session, then retry `make check`. " + "Delayed mode (3/4) does not force delayed quotes when live data permissions exist; " + "changing ib.market_data_type cannot resolve this session conflict." + ) + elif cfg.ib.market_data_type in (1, 2): + print("Check the market data subscription, or set ib.market_data_type = 3 to allow delayed data.") + else: + print("Delayed data is already requested; check market data permissions and availability in IB Gateway/TWS.") async def probe_gateway(cfg: Config) -> None: diff --git a/tests/test_check.py b/tests/test_check.py new file mode 100644 index 0000000..8c92d16 --- /dev/null +++ b/tests/test_check.py @@ -0,0 +1,64 @@ +import asyncio +import dataclasses +from pathlib import Path +from unittest.mock import AsyncMock, Mock + +import pytest +from ib_async import Stock + +from ibkr_quant import cli +from ibkr_quant.broker import AccountState, Broker +from ibkr_quant.config import load_config + + +@pytest.mark.parametrize( + "mode, error, price, expected, absent", + [ + (3, (10197, "No market data during competing live session"), None, + "Log out of the competing live market-data session", "set ib.market_data_type = 3"), + (1, (10197, "No market data during competing live session"), None, + "changing ib.market_data_type cannot resolve this session conflict", "set ib.market_data_type = 3"), + (1, (354, "Requested market data is not subscribed"), None, + "set ib.market_data_type = 3", "competing live market-data session"), + (3, (354, "Requested market data is not subscribed"), None, + "Delayed data is already requested", "set ib.market_data_type = 3"), + (3, None, None, "no valid snapshot received", "set ib.market_data_type = 3"), + (3, None, 600.0, "SPY snapshot 600.00 (requested market_data_type=3)", "no SPY price"), + ], +) +def test_check_explains_snapshot_result(monkeypatch, capsys, mode, error, price, expected, absent): + cfg = load_config(Path(__file__).resolve().parents[1] / "config.toml") + cfg = dataclasses.replace(cfg, ib=dataclasses.replace(cfg.ib, market_data_type=mode)) + + async def run(): + broker = Broker(cfg.ib) + listeners = len(broker.ib.errorEvent) + contract = Stock("SPY", "SMART", "USD", conId=756733) + monkeypatch.setattr(Broker, "__aenter__", AsyncMock(return_value=broker)) + monkeypatch.setattr(Broker, "__aexit__", AsyncMock(return_value=None)) + monkeypatch.setattr(cli, "Broker", lambda _: broker) + monkeypatch.setattr(broker, "account_state", AsyncMock(return_value=AccountState("DU123", 1000, 1000, []))) + monkeypatch.setattr(broker, "open_trades", AsyncMock(return_value=[])) + monkeypatch.setattr(broker, "qualify_stocks", AsyncMock(return_value=({"SPY": contract}, []))) + monkeypatch.setattr(broker.ib.client, "getReqId", Mock(return_value=59)) + + def respond(req_id, *args): + # Exercise ib_async's real request completion and errorEvent path. + if error: + broker.ib.wrapper.error(req_id, *error, "") + else: + if price is not None: + broker.ib.wrapper.priceSizeTick(req_id, 4, price, 1) + broker.ib.wrapper.tickSnapshotEnd(req_id) + + monkeypatch.setattr(broker.ib.client, "reqMktData", respond) + await cli.check(cfg) + assert len(broker.ib.errorEvent) == listeners + + asyncio.run(run()) + output = capsys.readouterr().out + assert "0 open orders" in output + assert expected in output + assert absent not in output + if error: + assert f"IBKR {error[0]}: {error[1]}" in output