Daily UpCap+MomGap trading on an IBKR paper account

Connects to IB Gateway through ib_async and trades the S&P 500 with the
UpCap+MomGap factor combo ported from quant-trading's
fc_up_cap_mom_gap_daily: signal on opens through today's open, equal
weight top 10, MOC orders filled at today's close, 10% resize band.

- make universe/bars/check/plan/submit/fills for the daily loop; refuses
  non-paper accounts unless allow_live, open orders, stale bars, and
  orders outside 09:31-15:45 ET
- make backtest replays the live loop day by day with plan_orders and
  IBKR Fixed commissions; selection uses each day's index members
- make bars fails fast when the Gateway's historical farm is down

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 21:53:31 +08:00
commit dc94f63839
16 changed files with 1286 additions and 0 deletions

229
src/ibkr_quant/cli.py Normal file
View File

@@ -0,0 +1,229 @@
import argparse
import asyncio
import dataclasses
import datetime as dt
import json
import logging
from pathlib import Path
import pandas as pd
from .broker import NEW_YORK, Broker
from .config import Config, load_config
from .rebalance import plan_orders
from . import backtest
from .strategy import signal_up_cap_mom_gap, up_cap_mom_gap_weights
from .universe import fetch_sp500, load_universe
# MOC orders must reach NYSE by 15:50 ET; keep a margin.
ORDER_WINDOW = (dt.time(9, 31), dt.time(15, 45))
def update_universe(cfg: Config) -> None:
table = fetch_sp500()
cfg.data.universe.parent.mkdir(parents=True, exist_ok=True)
table.to_csv(cfg.data.universe, index=False)
print(f"{len(table)} symbols -> {cfg.data.universe}")
async def check(cfg: Config) -> None:
async with Broker(cfg.ib) as broker:
state = await broker.account_state()
print(f"account {state.account} NAV ${state.nav_usd:,.2f} cash ${state.cash_usd:,.2f}")
holdings = pd.DataFrame([dataclasses.asdict(h) for h in state.holdings])
print(holdings.to_string(index=False) if len(holdings) else "no positions")
trades = await broker.open_trades()
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}")
contracts, _ = await broker.qualify_stocks(["SPY"])
prices = await broker.snapshot_prices(contracts)
if "SPY" in prices:
print(f"SPY snapshot {prices['SPY']:.2f} (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")
async def update_bars(cfg: Config) -> None:
symbols = load_universe(cfg.data.universe)
async with Broker(cfg.ib) as broker:
contracts, unknown = await broker.qualify_stocks(symbols)
# Fail fast when the historical farm is down instead of timing out every symbol.
probe = dict([next(iter(contracts.items()))])
if (await broker.daily_bars(probe, "5 D", timeout=30))[0].empty:
raise SystemExit(f"no historical data for {next(iter(probe))}; check the Gateway's HMDS farm status")
bars, failed = await broker.daily_bars(contracts, cfg.data.bars_duration)
if bars.empty:
raise SystemExit(f"no bars for any of {len(contracts)} symbols; kept existing {cfg.data.bars}")
cfg.data.bars.parent.mkdir(parents=True, exist_ok=True)
bars.to_csv(cfg.data.bars, index=False)
print(f"{bars['symbol'].nunique()} symbols, {bars['date'].min()} .. {bars['date'].max()} -> {cfg.data.bars}")
if unknown:
print(f"unqualified contracts ({len(unknown)}): {unknown}")
if failed:
print(f"no bars ({len(failed)}): {failed}")
def load_opens(path: Path, today: dt.date) -> pd.DataFrame:
bars = pd.read_csv(path, parse_dates=["date"])
opens = bars.pivot(index="date", columns="symbol", values="open").sort_index()
if opens.index[-1].date() != today:
raise SystemExit(f"bars end {opens.index[-1].date()}, need today's open ({today}); run `make bars` after 09:30 ET")
return opens
async def rebalance(cfg: Config, submit: bool) -> None:
ex = cfg.execution
now = dt.datetime.now(NEW_YORK)
opens = load_opens(cfg.data.bars, now.date())
weights = up_cap_mom_gap_weights(opens, cfg.strategy.top_n)
universe = set(load_universe(cfg.data.universe))
run_dir = cfg.root / "runs" / now.strftime("%Y%m%d-%H%M%S")
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
# Sized at today's open, as the source strategy does; filled at the close.
orders = plan_orders(
weights,
{h.symbol: h.quantity for h in managed},
opens.iloc[-1].dropna().to_dict(),
investable,
max(ex.min_trade_value, ex.min_trade_fraction * state.nav_usd),
ex.resize_band,
)
run_dir.mkdir(parents=True)
plan = pd.DataFrame([dataclasses.asdict(o) for o in orders], columns=["symbol", "current", "target", "delta", "price"])
plan["weight"] = plan["symbol"].map(weights).fillna(0.0)
plan.to_csv(run_dir / "plan.csv", index=False)
(run_dir / "account.json").write_text(json.dumps(dataclasses.asdict(state), indent=2))
print(f"account {state.account} NAV ${state.nav_usd:,.2f} investable ${investable:,.2f} signal on opens through {opens.index[-1].date()}")
print(plan.to_string(index=False) if len(plan) else "no orders")
if not submit or not orders:
print(f"dry run -> {run_dir}")
return
if not ORDER_WINDOW[0] <= now.time() <= ORDER_WINDOW[1]:
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():
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:
raise SystemExit(f"unqualified contracts: {unknown}")
limits: dict[str, float] = {}
if ex.order_type == "LMT":
snapshot = await broker.snapshot_prices(contracts)
if missing := [o.symbol for o in orders if o.symbol not in snapshot]:
raise SystemExit(f"no snapshot price for {missing}")
offset = ex.limit_offset_bps / 1e4
limits = {
o.symbol: round(snapshot[o.symbol] * (1 + offset if o.delta > 0 else 1 - offset), 2)
for o in orders
}
trades = [
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
submitted = pd.DataFrame(
[
{
"symbol": t.contract.symbol,
"action": t.order.action,
"quantity": t.order.totalQuantity,
"order_type": ex.order_type,
"limit": limits.get(t.contract.symbol),
"order_id": t.order.orderId,
"perm_id": t.order.permId,
"status": t.orderStatus.status,
}
for t in trades
]
)
submitted.to_csv(run_dir / "orders.csv", index=False)
print(submitted.to_string(index=False))
print(f"submitted {len(trades)} orders -> {run_dir}")
def run_backtest(cfg: Config, top_ns: list[int], everies: list[int], capitals: list[float], fee: str) -> None:
bars = pd.read_csv(cfg.data.bars, parse_dates=["date"])
opens = bars.pivot(index="date", columns="symbol", values="open").sort_index()
closes = bars.pivot(index="date", columns="symbol", values="close").sort_index()
# As live: pick among the index members of each day, scoring them on their
# full price history. Only today's members are known, so names removed from
# the index are missing (survivorship bias remains).
added = pd.read_csv(cfg.data.universe, parse_dates=["added"]).set_index("symbol")["added"]
member = pd.DataFrame(opens.index.values[:, None] >= added.reindex(opens.columns).values[None, :],
index=opens.index, columns=opens.columns)
signal = signal_up_cap_mom_gap(opens, member)
fee_model = backtest.ibkr_fixed_fee if fee == "ibkr-fixed" else backtest.flat_fee(2.0)
rows = []
for capital in capitals:
for every in everies:
for top_n in top_ns:
run_cfg = backtest.RunConfig(top_n, every, capital, fee_model)
result = backtest.run(signal, opens, closes, run_cfg)
rows.append({"capital": capital, "every": every, "top_n": top_n} | backtest.metrics(result, capital))
days = signal.index[backtest.WARMUP:]
# Same-universe baseline: equal weight, rebalanced at every close, no costs.
ew = closes.ffill().pct_change().shift(-1).loc[days[:-1]].mean(axis=1)
table = pd.DataFrame(rows)
path = cfg.root / "runs" / f"backtest-{dt.datetime.now(NEW_YORK):%Y%m%d-%H%M%S}.csv"
path.parent.mkdir(exist_ok=True)
table.to_csv(path, index=False)
pct = ["total", "h1", "h2", "vol", "max_dd", "turnover_day", "fees_pct"]
print(f"{days[0].date()} .. {days[-1].date()} ({len(days)} sessions), fee={fee}")
print(f"equal-weight universe: total {((1 + ew).prod() - 1) * 100:.1f}%, sharpe {ew.mean() / ew.std() * 252 ** 0.5:.2f}")
print(table.assign(**{c: (table[c] * 100).round(1) for c in pct}).round({"sharpe": 2}).to_string(index=False))
print(f"-> {path}")
async def fills(cfg: Config) -> None:
async with Broker(cfg.ib) as broker:
executions = await broker.executions()
if executions.empty:
print("no executions")
return
path = cfg.root / "runs" / f"fills-{dt.datetime.now(NEW_YORK):%Y%m%d}.csv"
path.parent.mkdir(exist_ok=True)
executions.to_csv(path, index=False)
print(executions.to_string(index=False))
print(f"-> {path}")
def main() -> None:
parser = argparse.ArgumentParser(prog="ibkr-quant")
parser.add_argument("--config", type=Path, default=Path("config.toml"))
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("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")
commands.add_parser("fills", help="show today's executions")
backtest_parser = commands.add_parser("backtest", help="replay the strategy on the downloaded bars")
backtest_parser.add_argument("--top-n", type=int, nargs="+", default=[10])
backtest_parser.add_argument("--every", type=int, nargs="+", default=[1], help="rebalance interval in sessions")
backtest_parser.add_argument("--capital", type=float, nargs="+", default=[1_000_000])
backtest_parser.add_argument("--fee", choices=["ibkr-fixed", "flat2"], default="ibkr-fixed")
args = parser.parse_args()
logging.basicConfig(level=logging.WARNING, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
cfg = load_config(args.config)
match args.command:
case "universe":
update_universe(cfg)
case "check":
asyncio.run(check(cfg))
case "bars":
asyncio.run(update_bars(cfg))
case "rebalance":
asyncio.run(rebalance(cfg, args.submit))
case "fills":
asyncio.run(fills(cfg))
case "backtest":
run_backtest(cfg, args.top_n, args.every, args.capital, args.fee)