Confirm MOC orders were accepted before reporting success
rebalance used to sleep 3 s and record whatever status TWS had sent; on 2026-09-29 every order was still PendingSubmit with permId 0. Now it waits up to 30 s for PreSubmitted/Submitted/Filled, records the final statuses in orders.csv either way, and exits non-zero naming any rejected or unacknowledged order so the systemd unit's failure is visible. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
84
tests/test_order_confirmation.py
Normal file
84
tests/test_order_confirmation.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user