Compare commits
7 Commits
365f0c7181
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 35d750f0a5 | |||
| e55c4ffbdb | |||
| 8238651af6 | |||
| e2b06f1b1c | |||
| a87a0c3b54 | |||
| eaa77ef187 | |||
| 76d5e44186 |
3
Makefile
3
Makefile
@@ -20,8 +20,9 @@ check:
|
||||
$(RUN) check
|
||||
|
||||
# On the server: probe the Gateway and restart it if it has lost IBKR's servers.
|
||||
# The trade job passes GATEWAY_ARGS=--history to require historical bars too.
|
||||
gateway:
|
||||
sh deploy/ensure-gateway.sh
|
||||
sh deploy/ensure-gateway.sh $(GATEWAY_ARGS)
|
||||
|
||||
bars:
|
||||
$(RUN) bars
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
# with its API port still open (2026-09-29, for 9 hours): every request times
|
||||
# out, yet java keeps running, so systemd's Restart= never fires.
|
||||
# Runs before each job (ExecStartPre=make gateway) and by hand as `make gateway`.
|
||||
# Arguments go to the probe: `--history` also requires daily bars, because the
|
||||
# historical data farm can fail while account requests work (2026-10-12).
|
||||
cd "$(dirname "$0")/.." || exit 1
|
||||
|
||||
probe() { uv run ibkr-quant gateway; }
|
||||
PROBE_ARGS="$*"
|
||||
probe() { uv run ibkr-quant gateway $PROBE_ARGS; }
|
||||
|
||||
probe && exit 0
|
||||
# Gateway rides out short outages by reconnecting on its own; give it a minute.
|
||||
|
||||
@@ -4,12 +4,8 @@ After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# Reach IBKR through pc's "ipads" proxy (mihomo, SOCKS5/HTTP on 7892), as
|
||||
# `proxy ipads` does in a shell. IBC drops -D lines from ibgateway.vmoptions and
|
||||
# clears JAVA_TOOL_OPTIONS, so the java launcher gets them via JDK_JAVA_OPTIONS.
|
||||
Environment="JDK_JAVA_OPTIONS=-DsocksProxyHost=127.0.0.1 -DsocksProxyPort=7892"
|
||||
Environment=http_proxy=http://127.0.0.1:7892 https_proxy=http://127.0.0.1:7892 all_proxy=http://127.0.0.1:7892
|
||||
Environment=HTTP_PROXY=http://127.0.0.1:7892 HTTPS_PROXY=http://127.0.0.1:7892 ALL_PROXY=http://127.0.0.1:7892
|
||||
# No proxy env on purpose: mihomo TUN (auto-route) transparently routes all
|
||||
# traffic by rules; IBKR domains go DEFAULT -> RES residential exit.
|
||||
ExecStart=/usr/bin/xvfb-run -a -s "-screen 0 1024x768x24" %h/ibc/gatewaystart.sh -inline
|
||||
Restart=always
|
||||
RestartSec=60
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
[Unit]
|
||||
Description=ibkr-quant daily run: today's bars, then MOC orders on rebalance sessions
|
||||
After=ibgateway.service
|
||||
# make gateway restarts a Gateway that has lost IBKR. Retries cover today's bar
|
||||
# arriving late (delayed data) and Gateway restarts; make submit refuses to
|
||||
# double-submit while orders are open.
|
||||
StartLimitIntervalSec=7200
|
||||
StartLimitBurst=6
|
||||
# make gateway restarts a Gateway that has lost IBKR or its historical data
|
||||
# farm. Retries cover today's bar arriving late (delayed data) and Gateway
|
||||
# restarts; make submit refuses to double-submit while orders are open and
|
||||
# succeeds once IBKR holds every order an earlier run sent.
|
||||
# Every 10 minutes from 09:50 ET until the 15:45 end of the order window: on
|
||||
# 2026-10-12 six retries 5 minutes apart gave up after 35 minutes while the
|
||||
# data farm was down. The start limit is a backstop; ExecCondition ends the day.
|
||||
StartLimitIntervalSec=21600
|
||||
StartLimitBurst=34
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
WorkingDirectory=%h/projects/ibkr-quant
|
||||
Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin
|
||||
ExecStartPre=/usr/bin/make gateway
|
||||
# Past 15:45 ET make submit cannot send orders; a failed condition skips the
|
||||
# start without counting as a failure, so it is not restarted.
|
||||
ExecCondition=/bin/sh -c 'test "$(TZ=America/New_York date +%%H%%M)" -lt 1545'
|
||||
ExecStartPre=/usr/bin/make gateway GATEWAY_ARGS=--history
|
||||
ExecStart=/usr/bin/make bars submit
|
||||
Restart=on-failure
|
||||
RestartSec=300
|
||||
RestartSec=600
|
||||
|
||||
@@ -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,40 @@ class Broker:
|
||||
order.orderRef = ref
|
||||
return self.ib.placeOrder(contract, order)
|
||||
|
||||
async def confirm_orders(self, trades: list[Trade], timeout: float = 60, refresh_every: float = 5) -> None:
|
||||
# The paper account reports MOC orders as PendingSubmit until the closing auction: on
|
||||
# 2026-09-29 and 10-13 every order stayed PendingSubmit and the 09-29 ones all filled at
|
||||
# the close. Such an order counts as accepted once IBKR lists it with a permanent id,
|
||||
# which the 10-13 run never saw unprompted, so this client's open orders are re-read.
|
||||
acknowledged = {OrderStatus.PreSubmitted, OrderStatus.Submitted, OrderStatus.Filled}
|
||||
|
||||
def accepted(t: Trade) -> bool:
|
||||
status = t.orderStatus.status
|
||||
return status in acknowledged or (status == OrderStatus.PendingSubmit and t.order.permId != 0)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + timeout
|
||||
next_refresh = loop.time() + refresh_every
|
||||
while not all(accepted(t) or t.orderStatus.status in OrderStatus.DoneStates for t in trades):
|
||||
remaining = deadline - loop.time()
|
||||
if remaining <= 0:
|
||||
break
|
||||
if loop.time() >= next_refresh:
|
||||
try:
|
||||
await asyncio.wait_for(self.ib.reqOpenOrdersAsync(), remaining)
|
||||
except TimeoutError:
|
||||
break
|
||||
next_refresh = loop.time() + refresh_every
|
||||
await asyncio.sleep(min(0.1, remaining))
|
||||
failed = [t for t in trades if not accepted(t)]
|
||||
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(
|
||||
|
||||
@@ -93,11 +93,22 @@ async def check(cfg: Config) -> None:
|
||||
print("Delayed data is already requested; check market data permissions and availability in IB Gateway/TWS.")
|
||||
|
||||
|
||||
async def probe_gateway(cfg: Config) -> None:
|
||||
async def probe_gateway(cfg: Config, history: bool = False) -> None:
|
||||
# Broker's connect fails unless the Gateway answers positions, orders and
|
||||
# account updates, which needs a live session with IBKR's servers.
|
||||
async with Broker(dataclasses.replace(cfg.ib, client_id=cfg.ib.health_client_id)) as broker:
|
||||
print(f"IB Gateway OK: account {broker.account}, server version {broker.ib.client.serverVersion()}")
|
||||
status = f"account {broker.account}, server version {broker.ib.client.serverVersion()}"
|
||||
if history:
|
||||
# The historical data farm can fail on its own: on 2026-10-12 account requests
|
||||
# worked while every bar request timed out for 35 minutes, until the Gateway's
|
||||
# daily restart. Without this check `make gateway` never restarted it.
|
||||
contracts, _ = await broker.qualify_stocks(["SPY"])
|
||||
bars = (await broker.daily_bars(contracts, "5 D", timeout=30))[0] if contracts else None
|
||||
if bars is None or bars.empty:
|
||||
raise SystemExit(f"IB Gateway answers account requests ({status}) but returned no SPY bars "
|
||||
"within 30 s; its historical data farm is not serving")
|
||||
status += f", historical data OK (SPY through {bars['date'].max()})"
|
||||
print(f"IB Gateway OK: {status}")
|
||||
|
||||
|
||||
async def update_bars(cfg: Config) -> None:
|
||||
@@ -148,15 +159,18 @@ async def rebalance(cfg: Config, submit: bool) -> None:
|
||||
|
||||
async with Broker(cfg.ib) as broker:
|
||||
state = await broker.account_state()
|
||||
managed = [h for h in state.holdings if h.sec_type == "STK" and h.currency == "USD" and h.symbol in universe]
|
||||
unmanaged_value = sum(h.market_value_usd for h in state.holdings if h not in managed)
|
||||
investable = state.nav_usd * (1 - ex.cash_buffer) - unmanaged_value
|
||||
weights = select_with_buffer(score, {h.symbol for h in managed}, st.top_n, st.buffer)
|
||||
# Sized at today's open, the latest price in the bars; filled at the close.
|
||||
# The strategy owns the whole account: every position it does not select is sold.
|
||||
# Orders go out as SMART/USD stocks, so any other kind of position stops the run.
|
||||
if foreign := [h.symbol for h in state.holdings if (h.sec_type, h.currency) != ("STK", "USD")]:
|
||||
raise SystemExit(f"positions that are not USD stocks: {foreign}; close them before rebalancing")
|
||||
investable = state.nav_usd * (1 - ex.cash_buffer)
|
||||
weights = select_with_buffer(score, {h.symbol for h in state.holdings}, st.top_n, st.buffer)
|
||||
# Sized at today's open, the latest price in the bars; filled at the close. A holding
|
||||
# outside the index has no bars and takes the account's price; its exit size does not use it.
|
||||
orders = plan_orders(
|
||||
weights,
|
||||
{h.symbol: h.quantity for h in managed},
|
||||
opens.iloc[-1].dropna().to_dict(),
|
||||
{h.symbol: h.quantity for h in state.holdings},
|
||||
{h.symbol: h.market_price for h in state.holdings} | opens.iloc[-1].dropna().to_dict(),
|
||||
investable,
|
||||
max(ex.min_trade_value, ex.min_trade_fraction * state.nav_usd),
|
||||
ex.resize_band,
|
||||
@@ -176,6 +190,17 @@ async def rebalance(cfg: Config, submit: bool) -> None:
|
||||
raise SystemExit(f"{now:%H:%M} ET is outside the order window {ORDER_WINDOW[0]}-{ORDER_WINDOW[1]}")
|
||||
|
||||
if open_trades := await broker.open_trades():
|
||||
# A run whose confirmation timed out exits non-zero and the trade service retries it;
|
||||
# if IBKR holds every order that run sent, the session's orders are in.
|
||||
refs = {t.order.orderRef for t in open_trades}
|
||||
sent = [cfg.root / "runs" / ref.removeprefix("ibkr-quant ") / "orders.csv" for ref in refs]
|
||||
if all(ref.startswith(f"ibkr-quant {now:%Y%m%d}-") for ref in refs) and all(p.exists() for p in sent):
|
||||
sent_ids = set(pd.concat(pd.read_csv(p) for p in sent)["order_id"])
|
||||
if missing := sent_ids - {t.order.orderId for t in open_trades}:
|
||||
raise SystemExit(f"today's orders {sorted(missing)} are no longer open at IBKR "
|
||||
"(rejected, cancelled or filled); see `make check`")
|
||||
print(f"{len(open_trades)} orders from {', '.join(sorted(refs))} are open at IBKR; not submitting again")
|
||||
return
|
||||
raise SystemExit(f"{len(open_trades)} open orders already exist; cancel or let them finish first")
|
||||
contracts, unknown = await broker.qualify_stocks([o.symbol for o in orders])
|
||||
if unknown:
|
||||
@@ -195,7 +220,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 +242,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:
|
||||
@@ -270,7 +301,8 @@ def main() -> None:
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
commands.add_parser("universe", help="download S&P 500 constituents")
|
||||
commands.add_parser("check", help="connect and show account, positions, open orders, SPY price")
|
||||
commands.add_parser("gateway", help="exit non-zero unless IB Gateway answers account requests")
|
||||
gateway_parser = commands.add_parser("gateway", help="exit non-zero unless IB Gateway answers account requests")
|
||||
gateway_parser.add_argument("--history", action="store_true", help="also require SPY daily bars from the historical data farm")
|
||||
commands.add_parser("bars", help="download adjusted daily bars for the universe")
|
||||
rebalance_parser = commands.add_parser("rebalance", help="plan orders (dry run unless --submit)")
|
||||
rebalance_parser.add_argument("--submit", action="store_true")
|
||||
@@ -290,7 +322,7 @@ def main() -> None:
|
||||
case "check":
|
||||
asyncio.run(check(cfg))
|
||||
case "gateway":
|
||||
asyncio.run(probe_gateway(cfg))
|
||||
asyncio.run(probe_gateway(cfg, args.history))
|
||||
case "bars":
|
||||
asyncio.run(update_bars(cfg))
|
||||
case "rebalance":
|
||||
|
||||
@@ -7,7 +7,12 @@ import pandas as pd
|
||||
SP500_URL = "https://en.wikipedia.org/wiki/List_of_S%26P_500_companies"
|
||||
|
||||
|
||||
# Tickers Wikipedia lists differently from what IBKR carries.
|
||||
IBKR_SYMBOL_OVERRIDES = {"PSKY": "SKYD"} # Paramount Skydance trades as SKYD/NYSE on IBKR
|
||||
|
||||
|
||||
def to_ib_symbol(ticker: str) -> str:
|
||||
ticker = IBKR_SYMBOL_OVERRIDES.get(ticker, ticker)
|
||||
# IBKR writes share classes with a space: BRK.B -> "BRK B".
|
||||
return ticker.replace(".", " ")
|
||||
|
||||
|
||||
@@ -62,3 +62,24 @@ def test_check_explains_snapshot_result(monkeypatch, capsys, mode, error, price,
|
||||
assert absent not in output
|
||||
if error:
|
||||
assert f"IBKR {error[0]}: {error[1]}" in output
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bars, ok", [(0, False), (5, True)])
|
||||
def test_gateway_probe_requires_historical_bars(monkeypatch, capsys, bars, ok):
|
||||
import pandas as pd
|
||||
cfg = load_config(Path(__file__).resolve().parents[1] / "config.toml")
|
||||
broker = Broker(cfg.ib)
|
||||
broker.account = "DU123"
|
||||
frame = pd.DataFrame({"date": pd.date_range("2026-10-06", periods=bars), "symbol": "SPY", "close": 1.0})
|
||||
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.ib.client, "serverVersion", Mock(return_value=178))
|
||||
monkeypatch.setattr(broker, "qualify_stocks", AsyncMock(return_value=({"SPY": Stock("SPY", "SMART", "USD")}, [])))
|
||||
monkeypatch.setattr(broker, "daily_bars", AsyncMock(return_value=(frame, [] if bars else ["SPY"])))
|
||||
if ok:
|
||||
asyncio.run(cli.probe_gateway(cfg, history=True))
|
||||
assert "historical data OK" in capsys.readouterr().out
|
||||
else:
|
||||
with pytest.raises(SystemExit, match="historical data farm"):
|
||||
asyncio.run(cli.probe_gateway(cfg, history=True))
|
||||
|
||||
166
tests/test_order_confirmation.py
Normal file
166
tests/test_order_confirmation.py
Normal file
@@ -0,0 +1,166 @@
|
||||
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)])
|
||||
@@ -1,5 +1,15 @@
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import datetime as dt
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from ibkr_quant import cli
|
||||
from ibkr_quant.broker import AccountState, Broker, Holding
|
||||
from ibkr_quant.config import load_config
|
||||
from ibkr_quant.rebalance import plan_orders
|
||||
|
||||
|
||||
@@ -41,3 +51,35 @@ def test_resize_band_keeps_near_target_holdings():
|
||||
)
|
||||
# AAPL is 8% under target and stays; MSFT is 40% under and tops up.
|
||||
assert [(o.symbol, o.delta) for o in orders] == [("MSFT", 6)]
|
||||
|
||||
|
||||
|
||||
def dry_run(monkeypatch, tmp_path, holdings):
|
||||
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))
|
||||
idx = pd.bdate_range(end=dt.date(2026, 10, 13), periods=300)
|
||||
panel = pd.DataFrame(100.0, index=idx, columns=["AAA", "BBB", "CCC"])
|
||||
monkeypatch.setattr(cli, "load_universe", lambda _: list(panel.columns))
|
||||
monkeypatch.setattr(cli, "load_bars", lambda *_: (panel, panel))
|
||||
monkeypatch.setattr(cli, "sessions_until_rebalance", lambda *_: 0)
|
||||
monkeypatch.setattr(cli, "signal_overnight_recovery",
|
||||
lambda o, c: pd.DataFrame({"AAA": 0.9, "BBB": 0.8, "CCC": 0.1}, index=o.index))
|
||||
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", 10_000, 4_800, holdings)))
|
||||
asyncio.run(cli.rebalance(cfg, submit=False))
|
||||
return pd.read_csv(next((tmp_path / "runs").glob("*/plan.csv")))
|
||||
|
||||
|
||||
def test_rebalance_manages_every_position_in_the_account(monkeypatch, tmp_path):
|
||||
plan = dry_run(monkeypatch, tmp_path, [Holding("AAA", "STK", "USD", 49, 100.0, 4_900.0),
|
||||
Holding("OLD", "STK", "USD", 10, 30.0, 300.0)]) # not in the index: no bars
|
||||
# investable = 10,000 * 0.98 = 9,800: 4,900 per name, so AAA is on target.
|
||||
assert list(zip(plan.symbol, plan.delta, plan.price)) == [("OLD", -10, 30.0), ("BBB", 49, 100.0)]
|
||||
|
||||
|
||||
def test_rebalance_refuses_positions_it_cannot_trade(monkeypatch, tmp_path):
|
||||
with pytest.raises(SystemExit, match="not USD stocks: \\['ES'\\]"):
|
||||
dry_run(monkeypatch, tmp_path, [Holding("ES", "FUT", "USD", 1, 6_000.0, 300_000.0)])
|
||||
|
||||
Reference in New Issue
Block a user