diff --git a/src/ibkr_quant/broker.py b/src/ibkr_quant/broker.py index eb3f93a..6c3b173 100644 --- a/src/ibkr_quant/broker.py +++ b/src/ibkr_quant/broker.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from zoneinfo import ZoneInfo import pandas as pd -from ib_async import IB, Contract, LimitOrder, Order, Stock, Trade +from ib_async import IB, Contract, LimitOrder, Order, OrderStatus, Stock, Trade from .config import IBConfig @@ -170,6 +170,25 @@ class Broker: order.orderRef = ref return self.ib.placeOrder(contract, order) + async def confirm_orders(self, trades: list[Trade], timeout: float = 30) -> None: + accepted = {OrderStatus.PreSubmitted, OrderStatus.Submitted, OrderStatus.Filled} + terminal = accepted | OrderStatus.DoneStates + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while any(t.orderStatus.status not in terminal for t in trades): + remaining = deadline - loop.time() + if remaining <= 0: + break + await asyncio.sleep(min(0.1, remaining)) + failed = [t for t in trades if t.orderStatus.status not in accepted] + if failed: + details = [] + for trade in failed: + errors = "; ".join(entry.message for entry in trade.log if entry.errorCode) + details.append(f"{trade.contract.symbol} order {trade.order.orderId}: {trade.orderStatus.status}" + + (f" ({errors})" if errors else "")) + raise RuntimeError("orders not accepted (rejected or confirmation timed out): " + "; ".join(details)) + async def executions(self) -> pd.DataFrame: fills = await self.ib.reqExecutionsAsync() return pd.DataFrame( diff --git a/src/ibkr_quant/cli.py b/src/ibkr_quant/cli.py index 039a6c1..cbeb1b3 100644 --- a/src/ibkr_quant/cli.py +++ b/src/ibkr_quant/cli.py @@ -195,7 +195,11 @@ async def rebalance(cfg: Config, submit: bool) -> None: broker.place(contracts[o.symbol], o.delta, ex.order_type, limits.get(o.symbol), f"ibkr-quant {run_dir.name}") for o in orders ] - await asyncio.sleep(3) # let TWS acknowledge before recording status + confirmation_error = None + try: + await broker.confirm_orders(trades) + except RuntimeError as exc: + confirmation_error = exc submitted = pd.DataFrame( [ { @@ -213,7 +217,9 @@ async def rebalance(cfg: Config, submit: bool) -> None: ) submitted.to_csv(run_dir / "orders.csv", index=False) print(submitted.to_string(index=False)) - print(f"submitted {len(trades)} orders -> {run_dir}") + if confirmation_error: + raise SystemExit(f"{confirmation_error}; recorded status -> {run_dir}") + print(f"accepted {len(trades)} orders -> {run_dir}") def run_backtest(cfg: Config, top_ns: list[int], everies: list[int], capitals: list[float], fee: str) -> None: diff --git a/tests/test_order_confirmation.py b/tests/test_order_confirmation.py new file mode 100644 index 0000000..181974c --- /dev/null +++ b/tests/test_order_confirmation.py @@ -0,0 +1,84 @@ +import asyncio +import dataclasses +import datetime as dt +from pathlib import Path +from unittest.mock import AsyncMock + +import pandas as pd +import pytest +from ib_async import Order, OrderStatus, Stock, Trade, TradeLogEntry + +from ibkr_quant import cli +from ibkr_quant.broker import AccountState, Broker +from ibkr_quant.config import load_config + + +def trade(status): + return Trade(Stock("AAA", "SMART", "USD"), Order(orderId=41, action="BUY", totalQuantity=10), + OrderStatus(status=status)) + + +def test_confirmation_waits_for_delayed_acknowledgement(): + async def run(): + order = trade("PendingSubmit") + cfg = load_config(Path(__file__).resolve().parents[1] / "config.toml") + asyncio.get_running_loop().call_later(0.02, setattr, order.orderStatus, "status", "Submitted") + await Broker(cfg.ib).confirm_orders([order], timeout=0.2) + assert order.orderStatus.status == "Submitted" + asyncio.run(run()) + + +@pytest.mark.parametrize("status", ["PreSubmitted", "Submitted", "Filled"]) +def test_confirmation_accepts_broker_acknowledged_orders(status): + cfg = load_config(Path(__file__).resolve().parents[1] / "config.toml") + asyncio.run(Broker(cfg.ib).confirm_orders([trade(status)], timeout=0)) + + +@pytest.mark.parametrize("status", ["Inactive", "Cancelled", "ApiCancelled", "PendingSubmit", "ApiPending"]) +def test_confirmation_rejects_terminal_failure_and_missing_acknowledgement(status): + cfg = load_config(Path(__file__).resolve().parents[1] / "config.toml") + order = trade(status) + order.log.append(TradeLogEntry(dt.datetime.now(), status, "insufficient margin", 201)) + with pytest.raises(RuntimeError, match=f"AAA order 41: {status}.*insufficient margin"): + asyncio.run(Broker(cfg.ib).confirm_orders([trade("Submitted"), order], timeout=0)) + + +def test_rebalance_records_partial_failure_and_exits_nonzero(monkeypatch, tmp_path, capsys): + cfg = load_config(Path(__file__).resolve().parents[1] / "config.toml") + cfg = dataclasses.replace(cfg, root=tmp_path, strategy=dataclasses.replace(cfg.strategy, top_n=2)) + now = dt.datetime(2026, 10, 12, 10, 0, tzinfo=cli.NEW_YORK) + + class Clock(dt.datetime): + @classmethod + def now(cls, tz=None): + return now + + monkeypatch.setattr(cli.dt, "datetime", Clock) + idx = pd.bdate_range(end=now.date(), periods=300) + panel = pd.DataFrame({"AAA": 100.0, "BBB": 100.0}, index=idx) + monkeypatch.setattr(cli, "load_universe", lambda _: ["AAA", "BBB"]) + monkeypatch.setattr(cli, "load_bars", lambda *_: (panel, panel)) + monkeypatch.setattr(cli, "sessions_until_rebalance", lambda *_: 0) + monkeypatch.setattr(cli, "signal_overnight_recovery", lambda *_: panel.iloc[:-1]) + + async def run(): + broker = Broker(cfg.ib) + 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", 10000, 10000, []))) + monkeypatch.setattr(broker, "open_trades", AsyncMock(return_value=[])) + contracts = {s: Stock(s, "SMART", "USD") for s in panel.columns} + monkeypatch.setattr(broker, "qualify_stocks", AsyncMock(return_value=(contracts, []))) + + def place(contract, quantity, *_): + return Trade(contract, Order(orderId=41, action="BUY", totalQuantity=quantity), + OrderStatus(status="Submitted" if contract.symbol == "AAA" else "Inactive")) + monkeypatch.setattr(broker, "place", place) + with pytest.raises(SystemExit, match="BBB order 41: Inactive"): + await cli.rebalance(cfg, submit=True) + + asyncio.run(run()) + orders = pd.read_csv(next((tmp_path / "runs").glob("*/orders.csv"))) + assert dict(zip(orders.symbol, orders.status)) == {"AAA": "Submitted", "BBB": "Inactive"} + assert "accepted 2 orders" not in capsys.readouterr().out