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)) def test_confirmation_accepts_pending_moc_orders_that_ibkr_lists(): # The paper account keeps MOC orders PendingSubmit until the close (2026-09-29, 10-13). async def run(): order = trade("PendingSubmit") cfg = load_config(Path(__file__).resolve().parents[1] / "config.toml") broker = Broker(cfg.ib) async def open_orders(): order.order.permId = 114178004 return [order] broker.ib.reqOpenOrdersAsync = AsyncMock(side_effect=open_orders) await broker.confirm_orders([order], timeout=1, refresh_every=0.01) assert order.orderStatus.status == "PendingSubmit" broker.ib.reqOpenOrdersAsync.assert_awaited() asyncio.run(run()) def test_confirmation_rejects_pending_orders_ibkr_never_lists(): async def run(): cfg = load_config(Path(__file__).resolve().parents[1] / "config.toml") broker = Broker(cfg.ib) broker.ib.reqOpenOrdersAsync = AsyncMock(return_value=[]) with pytest.raises(RuntimeError, match="AAA order 41: PendingSubmit"): await broker.confirm_orders([trade("PendingSubmit")], timeout=0.5, refresh_every=0.01) broker.ib.reqOpenOrdersAsync.assert_awaited() asyncio.run(run()) @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 def submit_with_open_orders(monkeypatch, tmp_path, open_trades, earlier_run="20261012-095323", earlier_ids=(64, 65)): """Run `rebalance --submit` at 10:00 ET on 2026-10-12 while `open_trades` are open at IBKR.""" 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) run_dir = tmp_path / "runs" / earlier_run run_dir.mkdir(parents=True) pd.DataFrame({"symbol": ["AAA", "BBB"][:len(earlier_ids)], "order_id": list(earlier_ids), "status": "PendingSubmit"}).to_csv(run_dir / "orders.csv", index=False) 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]) 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=open_trades)) monkeypatch.setattr(broker, "place", lambda *_: pytest.fail("placed an order while orders were open")) asyncio.run(cli.rebalance(cfg, submit=True)) def open_order(order_id, ref): return Trade(Stock("AAA", "SMART", "USD"), Order(orderId=order_id, permId=114178000 + order_id, orderRef=ref), OrderStatus(status="PendingSubmit")) def test_retry_succeeds_when_ibkr_holds_every_order_an_earlier_run_sent(monkeypatch, tmp_path, capsys): ref = "ibkr-quant 20261012-095323" submit_with_open_orders(monkeypatch, tmp_path, [open_order(64, ref), open_order(65, ref)]) assert f"2 orders from {ref} are open at IBKR; not submitting again" in capsys.readouterr().out def test_retry_fails_when_an_order_from_the_earlier_run_is_gone(monkeypatch, tmp_path): with pytest.raises(SystemExit, match=r"today's orders \[65\] are no longer open"): submit_with_open_orders(monkeypatch, tmp_path, [open_order(64, "ibkr-quant 20261012-095323")]) @pytest.mark.parametrize("ref", ["ibkr-quant 20261009-095323", "", "manual"]) def test_retry_refuses_open_orders_it_did_not_send_today(monkeypatch, tmp_path, ref): with pytest.raises(SystemExit, match="1 open orders already exist"): submit_with_open_orders(monkeypatch, tmp_path, [open_order(64, ref)])