""" Trade one registered strategy in an Interactive Brokers account with market-on-close orders (first: trend_rider_v10, $10k, IBKR Pro). Each session it computes the strategy's target for the close exactly as trader.py's morning phase does (session_signals on refreshed Yahoo prices, so the target equals the simulated account in data/trader_us_trend_rider_v10.json), sizes whole-share orders against the account's actual positions and sends them as MOC orders through IB Gateway. After the close it records IBKR's executions and commissions and scores every fill against the primary listing exchange's closing cross (SIP condition 6, as in research/lab/auction_audit.py). uv run python ibkr_trader.py check # account, positions, open orders (read-only) uv run python ibkr_trader.py plan [--what-if] # today's orders; --what-if has IBKR validate and price # each order without placing it uv run python ibkr_trader.py submit [--live] # send the MOC orders (close - 30 to close - 15 min) uv run python ibkr_trader.py report # executions, fills vs the closing cross, live vs simulated uv run python ibkr_trader.py daemon [--live] # submit and report on Alpaca's market calendar Connection settings come from the environment or .env: IBKR_ACCOUNT (required), IBKR_HOST (127.0.0.1), IBKR_PORT (4001 = live Gateway; the paper Gateway of ../ibkr-quant listens on 4002), IBKR_CLIENT_ID (31; ibkr-quant uses 17 and 18). Live account numbers are masked in output. Guards, each enforced in code: account the Gateway login must manage IBKR_ACCOUNT; an account that is not a paper account (DU/DF) also needs --live and the file data/ibkr_live/ARMED (delete it to stop sending orders) holdings submit refuses an account holding anything outside the strategy's universe (plan only warns, so it can run on a shared paper account) size orders are sized on min(net liquidation, MAX_EQUITY) less CASH_BUFFER orders MOC only, whole shares, universe symbols, sells no larger than the position; sells are sent and accepted before any buy once one submission per session, none while an order of ours is open A buy that IBKR rejects (for example buying power while the sell that funds it is still pending) leaves the cash idle; the next session's plan buys again. The live record therefore differs from the simulated account by fills, whole-share sizing, the cash buffer, commissions and any such delay. Boundary: NAV-based returns in the report assume no deposits or withdrawals. State and logs: data/ibkr_live/ (state.json, orders.jsonl, runs/.json, fills.csv, scored.csv, daily.csv, prereg.json, verdict.json). """ from __future__ import annotations import argparse import asyncio import json import math import os import time from datetime import date, datetime, timedelta, timezone from zoneinfo import ZoneInfo import pandas as pd from execution import compute_trades OUT = os.path.join("data", "ibkr_live") NY = ZoneInfo("America/New_York") ARMED = os.path.join(OUT, "ARMED") MARKET = "us" STRATEGY = "trend_rider_v10" SIM_STATE = os.path.join("data", f"trader_{MARKET}_{STRATEGY}.json") MAX_EQUITY = 10_500.0 CASH_BUFFER = 0.01 # MOC buys are sized at 15:30 prices and paid at the close PAPER_PREFIXES = ("DU", "DF") REF = "ibkr_trader" SUBMIT_BEFORE_CLOSE = timedelta(minutes=30) SUBMIT_CUTOFF = timedelta(minutes=15) # NYSE takes MOC orders until 15:50 ET REPORT_AFTER_CLOSE = timedelta(minutes=60) # after the monitor's 16:35 evening phase ACCEPTED = {"PreSubmitted", "Submitted", "Filled"} DONE = {"Filled", "Cancelled", "ApiCancelled", "Inactive"} PREREG = { "written": "before the first live order", "sample": "the first 20 filled MOC orders of the live account", "reference": "primary exchange closing cross, largest SIP print with condition 6 in 15:59:59-16:10 ET", "cost_per_side_bp": "fill vs cross, adverse positive, value-weighted; commission excluded (tracked separately)", "rules": {"cost_per_side_bp": {"PASS": 1.0, "MEASURED": 2.5}, "fill_rate": {"PASS": 0.99, "MEASURED": 0.97}, "cross_match": {"PASS": 0.95, "MEASURED": 0.90, "tolerance_usd": 0.01}}, "decision": "the worst of the three; STOP means stop sending orders and ask IBKR about the fills", } # --------------------------------------------------------------------------- # guards (pure, tested in tests/test_ibkr_trader.py) # --------------------------------------------------------------------------- def mask(account: str) -> str: return account if account.startswith(PAPER_PREFIXES) else f"{account[:1]}***{account[-3:]}" def check_account(managed: list[str], configured: str, live: bool, armed: bool, trading: bool = True) -> bool: """True for a live account; SystemExit unless the login (and, to trade, the flags) allow it.""" if not configured: raise SystemExit("IBKR_ACCOUNT is not set (environment or .env)") if configured not in managed: raise SystemExit(f"the Gateway login manages {[mask(a) for a in managed]}, not {mask(configured)}") is_live = not configured.startswith(PAPER_PREFIXES) if not trading: return is_live if is_live and not live: raise SystemExit(f"{mask(configured)} is a live account; pass --live to trade it") if is_live and not armed: raise SystemExit(f"{mask(configured)} is a live account and {ARMED} does not exist") return is_live def size_orders(target: dict[str, float], holdings: dict[str, int], prices: dict[str, float], nav: float, resize_band: float, cap: float = MAX_EQUITY, buffer: float = CASH_BUFFER) -> list[dict]: """Whole-share trades taking ``holdings`` to ``target`` on min(nav, cap) less the buffer.""" if missing := sorted(s for s in set(target) | set(holdings) if not prices.get(s, 0) > 0): raise SystemExit(f"no price for {missing}") equity = min(nav, cap) cash = equity * (1 - buffer) - sum(q * prices[s] for s, q in holdings.items()) return compute_trades(holdings, cash, target, prices, min_trade_value=max(50.0, 0.001 * equity), integer_shares=True, lot_size=1, resize_band=resize_band) def validate_orders(trades: list[dict], universe: set[str], holdings: dict[str, int]) -> None: for t in trades: q = t["shares_delta"] if t["ticker"] not in universe: raise SystemExit(f"{t['ticker']} is outside the strategy's universe") if q != int(q) or q == 0: raise SystemExit(f"{t['ticker']}: {q} is not a whole, nonzero share count") if q < 0 and -q > holdings.get(t["ticker"], 0): raise SystemExit(f"{t['ticker']}: selling {-q} of {holdings.get(t['ticker'], 0)} held") def moc_order(delta: int, account: str, ref: str): from ib_async import Order if delta == 0 or delta != int(delta): raise ValueError(f"MOC order needs a whole, nonzero quantity, got {delta}") order = Order(action="BUY" if delta > 0 else "SELL", totalQuantity=abs(int(delta)), orderType="MOC", tif="DAY") order.account = account order.orderRef = ref return order # --------------------------------------------------------------------------- # configuration, state, logs # --------------------------------------------------------------------------- def settings() -> dict: from overnight_paper import _env env = _env() return {"host": env.get("IBKR_HOST", "127.0.0.1"), "port": int(env.get("IBKR_PORT", "4001")), "client_id": int(env.get("IBKR_CLIENT_ID", "31")), "account": env.get("IBKR_ACCOUNT", "")} def _state() -> dict: path = os.path.join(OUT, "state.json") return json.load(open(path)) if os.path.exists(path) else {} def _save_state(st: dict) -> None: os.makedirs(OUT, exist_ok=True) tmp = os.path.join(OUT, ".state.json.tmp") with open(tmp, "w") as fh: json.dump(st, fh, indent=1) os.replace(tmp, os.path.join(OUT, "state.json")) def _append_jsonl(name: str, row: dict) -> None: os.makedirs(OUT, exist_ok=True) with open(os.path.join(OUT, name), "a") as fh: fh.write(json.dumps(row, default=str) + "\n") def now_utc() -> datetime: return datetime.now(timezone.utc) # --------------------------------------------------------------------------- # target and prices # --------------------------------------------------------------------------- def session_target() -> tuple[date, dict[str, float], list[str]]: """The strategy's target for the close of the latest session with an open bar, as trader.py morning computes it.""" import trader tickers, benchmark = trader.strategy_universe(MARKET, STRATEGY) close, open_data, close_raw = trader.load_strategy_prices(MARKET, STRATEGY, tickers, benchmark, with_open=True) tickers = trader.filter_tradable_tickers(close, tickers) day = open_data.index[-1] row = trader.session_signals(MARKET, STRATEGY, tickers, pd.DatetimeIndex([day]), close_raw, open_data).loc[day] return day.date(), {t: float(w) for t, w in row.items() if w > 1e-6}, sorted(trader.ETF_STRATEGY_UNIVERSES[STRATEGY]) def monitor_target(day: date) -> dict[str, float] | None: """The simulated account's pending target for ``day`` (written by the monitor's 09:45 morning phase).""" if not os.path.exists(SIM_STATE): return None pending = json.load(open(SIM_STATE)).get("pending_trades") or {} return pending.get("target_weights") if pending.get("date") == day.isoformat() else None def sizing_prices(symbols: list[str]) -> dict[str, float]: from overnight_paper import Alpaca, latest_prices return latest_prices(Alpaca(), symbols) # --------------------------------------------------------------------------- # IB Gateway # --------------------------------------------------------------------------- class Gateway: def __init__(self, cfg: dict) -> None: from ib_async import IB self.cfg, self.ib = cfg, IB() async def __aenter__(self) -> "Gateway": address = f"{self.cfg['host']}:{self.cfg['port']}" try: # Without raiseSyncErrors a Gateway that lost IBKR's servers connects with an # empty portfolio, which would read as no holdings (ibkr-quant, 2026-09-29). await self.ib.connectAsync(self.cfg["host"], self.cfg["port"], self.cfg["client_id"], timeout=15, account=self.cfg["account"], raiseSyncErrors=True) except ConnectionRefusedError: raise SystemExit(f"nothing listening on {address}; start IB Gateway") from None except TimeoutError: raise SystemExit(f"IB Gateway on {address} did not finish the API handshake") from None except ConnectionError as e: raise SystemExit(f"IB Gateway on {address} failed {e}; it may have lost IBKR's servers") from None return self async def __aexit__(self, *exc) -> None: self.ib.disconnect() async def account(self, account: str) -> dict: tags = {v.tag: v for v in await self.ib.accountSummaryAsync(account)} positions, foreign = {}, [] for p in self.ib.positions(account): if not p.position: continue c = p.contract if (c.secType, c.currency) == ("STK", "USD") and p.position == int(p.position): positions[c.symbol] = int(p.position) else: foreign.append(f"{c.symbol} {c.secType} {c.currency} {p.position}") return {"nav": float(tags["NetLiquidation"].value), "cash": float(tags["TotalCashValue"].value), "available": float(tags["AvailableFunds"].value), "positions": positions, "foreign": foreign} async def our_open_orders(self, account: str) -> list: trades = await self.ib.reqAllOpenOrdersAsync() return [t for t in trades if t.order.account in ("", account) and t.order.orderRef.startswith(REF)] async def contracts(self, symbols: list[str]) -> dict: from ib_async import Stock qualified = await self.ib.qualifyContractsAsync(*[Stock(s, "SMART", "USD") for s in symbols]) found = {c.symbol: c for c in qualified if c is not None and getattr(c, "conId", 0)} if missing := sorted(set(symbols) - set(found)): raise SystemExit(f"IBKR could not qualify {missing}") return found async def confirm(self, trades: list, timeout: float = 30) -> list: """Wait until each order is accepted or done; return the ones not accepted.""" loop = asyncio.get_running_loop() deadline = loop.time() + timeout while any(t.orderStatus.status not in ACCEPTED | DONE for t in trades) and loop.time() < deadline: await asyncio.sleep(0.1) return [t for t in trades if t.orderStatus.status not in ACCEPTED] def _errors(trade) -> str: return "; ".join(f"{e.errorCode} {e.message}" for e in trade.log if e.errorCode) def _order_row(kind: str, trade, day: date) -> dict: return {"logged_at": now_utc().isoformat(), "kind": kind, "session": day.isoformat(), "symbol": trade.contract.symbol, "action": trade.order.action, "qty": trade.order.totalQuantity, "order_id": trade.order.orderId, "perm_id": trade.order.permId, "status": trade.orderStatus.status, "errors": _errors(trade)} # --------------------------------------------------------------------------- # commands # --------------------------------------------------------------------------- async def check(cfg: dict) -> None: async with Gateway(cfg) as gw: managed = gw.ib.managedAccounts() acct = cfg["account"] or (managed[0] if len(managed) == 1 else "") if acct not in managed: raise SystemExit(f"set IBKR_ACCOUNT; the login manages {[mask(a) for a in managed]}") a = await gw.account(acct) print(f"account {mask(acct)} NAV ${a['nav']:,.2f} cash ${a['cash']:,.2f} available ${a['available']:,.2f}") print(f"positions {a['positions'] or 'none'}" + (f"; other {a['foreign']}" if a["foreign"] else "")) print(f"open orders of ours: {len(await gw.our_open_orders(acct))}") async def plan(cfg: dict, what_if: bool = False, submit: bool = False, live: bool = False) -> dict: day, target, universe = session_target() today = datetime.now(NY).date() if submit and day != today: raise SystemExit(f"latest open bar is {day}, not today {today}; Yahoo has not printed today's open") mon = monitor_target(day) agree = mon is not None and set(mon) == set(target) and all(abs(mon[s] - target[s]) < 1e-6 for s in target) print(f"{STRATEGY} target for the close of {day}: " + (", ".join(f"{s} {w:.1%}" for s, w in sorted(target.items())) or "cash") + ("" if mon is None else f" (monitor's target {'agrees' if agree else 'DIFFERS: ' + str(mon)})")) async with Gateway(cfg) as gw: is_live = check_account(gw.ib.managedAccounts(), cfg["account"], live, os.path.exists(ARMED), trading=submit) acct = cfg["account"] a = await gw.account(acct) held = {s: q for s, q in a["positions"].items() if s in universe} other = a["foreign"] + [f"{s} {q}" for s, q in a["positions"].items() if s not in universe] if other: if submit: raise SystemExit(f"the account holds positions outside {STRATEGY}'s universe: {other}") print(f" ignoring positions outside the universe (plan only): {len(other)}") prices = sizing_prices(sorted(set(target) | set(held))) trades = size_orders(target, held, prices, a["nav"], resize_band=_resize_band()) validate_orders(trades, set(universe), held) print(f"account {mask(acct)}{' (live)' if is_live else ' (paper)'} NAV ${a['nav']:,.2f} " f"sized on ${min(a['nav'], MAX_EQUITY) * (1 - CASH_BUFFER):,.2f} held {held or 'nothing'}") for t in trades: print(f" {t['direction']:4s} {abs(t['shares_delta']):5d} {t['ticker']:5s} ~${t['est_value']:>9,.2f} " f"@ {t['price']:.2f} -> {t['target_shares']} shares") if not trades: print(" no orders") run = {"session": day.isoformat(), "computed_at": now_utc().isoformat(), "target": target, "monitor_target": mon, "nav": a["nav"], "held": held, "prices": prices, "trades": trades} if what_if and trades: contracts = await gw.contracts([t["ticker"] for t in trades]) errors: list[str] = [] on_error = lambda req_id, code, message, contract: errors.append(f"{code} {message}") # noqa: E731 gw.ib.errorEvent += on_error try: for t in trades: errors.clear() st = await gw.ib.whatIfOrderAsync(contracts[t["ticker"]], moc_order(t["shares_delta"], acct, REF)) if not hasattr(st, "initMarginChange"): # a rejected what-if returns no OrderState t["what_if"] = {"rejected": "; ".join(errors) or "no answer"} print(f" what-if {t['ticker']:5s}: REJECTED {t['what_if']['rejected'].replace('
', ' ')[:300]}") continue t["what_if"] = {k: getattr(st, k, None) for k in ("status", "initMarginChange", "maintMarginChange", "equityWithLoanAfter", "commission", "minCommission", "maxCommission", "warningText")} print(f" what-if {t['ticker']:5s}: accepted, commission " f"{_money(st.commission, st.minCommission, st.maxCommission)}, init margin change " f"{st.initMarginChange}{', warning: ' + st.warningText if st.warningText else ''}") finally: gw.ib.errorEvent -= on_error if not submit: return run st = _state() if st.get("submitted") == day.isoformat(): raise SystemExit(f"{day}: orders already submitted") if mine := await gw.our_open_orders(acct): raise SystemExit(f"{len(mine)} orders of ours are still open") if is_live and not os.path.exists(os.path.join(OUT, "prereg.json")): with open(os.path.join(OUT, "prereg.json"), "w") as fh: json.dump({**PREREG, "written_at": now_utc().isoformat(), "strategy": STRATEGY}, fh, indent=2) st["submitted"] = day.isoformat() # before sending: a crash must not resend _save_state(st) os.makedirs(os.path.join(OUT, "runs"), exist_ok=True) contracts = await gw.contracts([t["ticker"] for t in trades]) if trades else {} failed = [] for side in ("SELL", "BUY"): batch = [gw.ib.placeOrder(contracts[t["ticker"]], moc_order(t["shares_delta"], acct, f"{REF} {day}")) for t in trades if t["direction"] == side] bad = await gw.confirm(batch) if batch else [] for tr in batch: _append_jsonl("orders.jsonl", _order_row("moc", tr, day)) print(f" {tr.order.action:4s} {tr.order.totalQuantity:>5.0f} {tr.contract.symbol:5s} MOC -> " f"{tr.orderStatus.status}{' (' + _errors(tr) + ')' if _errors(tr) else ''}") failed += bad if side == "SELL" and bad: break # no buys on top of a sell that did not go in run["orders"] = [o for o in (json.loads(l) for l in open(os.path.join(OUT, "orders.jsonl"))) if o["session"] == day.isoformat()] if trades else [] with open(os.path.join(OUT, "runs", f"{day}.json"), "w") as fh: json.dump(run, fh, indent=1, default=str) if failed: raise SystemExit("not accepted: " + "; ".join(f"{t.contract.symbol} {t.orderStatus.status} {_errors(t)}" for t in failed)) return run def _resize_band() -> float: import trader return trader.strategy_resize_band(STRATEGY) def _money(c, lo, hi) -> str: ok = lambda x: x is not None and math.isfinite(x) and abs(x) < 1e9 if ok(c): return f"${c:.2f}" return f"${lo:.2f}-${hi:.2f}" if ok(lo) and ok(hi) else "n/a" async def report(cfg: dict) -> None: from ib_async import ExecutionFilter async with Gateway(cfg) as gw: acct = cfg["account"] if acct not in gw.ib.managedAccounts(): raise SystemExit(f"the Gateway login does not manage {mask(acct)}") fills = await gw.ib.reqExecutionsAsync(ExecutionFilter(acctCode=acct)) a = await gw.account(acct) rows = [] for f in fills: e, cr = f.execution, f.commissionReport if not e.orderRef.startswith(REF): continue commission = cr.commission if cr and math.isfinite(cr.commission) and cr.commission < 1e9 else float("nan") rows.append({"exec_id": e.execId, "time": e.time.astimezone(NY).isoformat(), "session": e.time.astimezone(NY).date().isoformat(), "symbol": f.contract.symbol, "side": "BUY" if e.side == "BOT" else "SELL", "shares": e.shares, "price": e.price, "exchange": e.exchange, "perm_id": e.permId, "order_ref": e.orderRef, "commission": commission}) path = os.path.join(OUT, "fills.csv") old = pd.read_csv(path) if os.path.exists(path) else pd.DataFrame() F = pd.concat([old, pd.DataFrame(rows)], ignore_index=True) if len(F): F = F.drop_duplicates("exec_id", keep="last").sort_values("time") F.to_csv(path, index=False) today = datetime.now(NY).date().isoformat() _append_daily(today, a) if F.empty: print("no fills yet") F = pd.DataFrame(columns=["session", "symbol", "side", "shares", "price", "commission"]) _score(F) _compare_with_simulation() def _append_daily(day: str, a: dict) -> None: path = os.path.join(OUT, "daily.csv") D = pd.read_csv(path) if os.path.exists(path) else pd.DataFrame() row = pd.DataFrame([{"date": day, "nav": a["nav"], "cash": a["cash"], "positions": json.dumps(a["positions"])}]) D = pd.concat([D[D["date"] != day] if len(D) else D, row], ignore_index=True) D.to_csv(path, index=False) def _score(F: pd.DataFrame) -> None: """Each accepted MOC order against the closing cross; preregistered verdict on the first 20 orders. Orders and fills are matched on (session, symbol, side): a session sends at most one order per symbol. An accepted order with no fill counts against the fill rate. """ from research.lab.auction_audit import crosses import research.lab.fetch_options as fo path = os.path.join(OUT, "orders.jsonl") if not os.path.exists(path): return O = pd.DataFrame([json.loads(l) for l in open(path)]) O = O[O["status"].isin(ACCEPTED)].drop_duplicates(["session", "symbol", "action"], keep="last") settled = pd.Timestamp.now(tz=NY) - pd.Timedelta(hours=16, minutes=20) # free SIP data lags 15 min O = O[pd.to_datetime(O["session"]).dt.tz_localize(NY) <= settled] if O.empty: return f = F.assign(value=F["shares"] * F["price"]).groupby(["session", "symbol", "side"]) \ .agg(filled=("shares", "sum"), value=("value", "sum"), commission=("commission", "sum")) g = O.join(f, on=["session", "symbol", "action"])[["session", "symbol", "action", "qty", "filled", "value", "commission"]].fillna({"filled": 0.0}) g["avg"] = g["value"] / g["filled"] api, ref = fo.Api(min_interval=0.4), {} for day, h in g.groupby("session"): for sym, v in crosses(api, sorted(h["symbol"].unique()), pd.Timestamp(day), "15:59:59", "16:10:00", "6").items(): ref[(day, sym)] = v["p"] g["cross"] = [ref.get((d, sym)) for d, sym in zip(g["session"], g["symbol"])] g["slip_bp"] = g["action"].map({"BUY": 1, "SELL": -1}) * (g["avg"] / g["cross"] - 1) * 1e4 g["match"] = (g["avg"] - g["cross"]).abs() <= PREREG["rules"]["cross_match"]["tolerance_usd"] g["commission_bp"] = g["commission"] / g["value"] * 1e4 g = g.sort_values(["session", "symbol"]).reset_index(drop=True) g.to_csv(os.path.join(OUT, "scored.csv"), index=False) print(g.tail(10)[["session", "symbol", "action", "qty", "filled", "avg", "cross", "slip_bp", "commission_bp"]] .to_string(index=False, float_format=lambda x: f"{x:.3f}")) def measure(x: pd.DataFrame) -> dict: y = x[x["filled"] > 0].dropna(subset=["cross"]) return {"cost_per_side_bp": (y["slip_bp"] * y["value"]).sum() / y["value"].sum() if len(y) else float("nan"), "fill_rate": x["filled"].sum() / x["qty"].sum(), "cross_match": y["match"].mean() if len(y) else float("nan"), "commission_bp": y["commission"].sum() / y["value"].sum() * 1e4 if len(y) else float("nan")} m = measure(g) print(f"orders {len(g)}: cost {m['cost_per_side_bp']:+.2f} bp a side (ex commission), fill rate " f"{m['fill_rate']:.1%}, cross match {m['cross_match']:.0%}, commission {m['commission_bp']:.2f} bp a side") verdict_path = os.path.join(OUT, "verdict.json") if len(g) < 20 or not os.path.exists(os.path.join(OUT, "prereg.json")) or os.path.exists(verdict_path): return v, r = measure(g.head(20)), PREREG["rules"] grades = { "cost_per_side_bp": "PASS" if v["cost_per_side_bp"] <= r["cost_per_side_bp"]["PASS"] else "MEASURED" if v["cost_per_side_bp"] <= r["cost_per_side_bp"]["MEASURED"] else "STOP", "fill_rate": "PASS" if v["fill_rate"] >= r["fill_rate"]["PASS"] else "MEASURED" if v["fill_rate"] >= r["fill_rate"]["MEASURED"] else "STOP", "cross_match": "PASS" if v["cross_match"] >= r["cross_match"]["PASS"] else "MEASURED" if v["cross_match"] >= r["cross_match"]["MEASURED"] else "STOP", } decision = max(grades.values(), key=["PASS", "MEASURED", "STOP"].index) with open(verdict_path, "w") as fh: json.dump({"decided_at": now_utc().isoformat(), "orders": 20, "values": v, "grades": grades, "decision": decision}, fh, indent=2) print(f"verdict on the first 20 orders: {decision} {grades}") def _compare_with_simulation() -> None: path = os.path.join(OUT, "daily.csv") if not (os.path.exists(path) and os.path.exists(SIM_STATE)): return live = pd.read_csv(path).set_index("date")["nav"] sim = pd.Series(json.load(open(SIM_STATE))["daily_equity"]) both = pd.concat({"live": live, "sim": sim}, axis=1).dropna() if len(both) < 2: return r = both / both.iloc[0] - 1 print(f"since {both.index[0]}: live {r['live'].iloc[-1]:+.2%}, simulated account {r['sim'].iloc[-1]:+.2%}, " f"gap {r['live'].iloc[-1] - r['sim'].iloc[-1]:+.2%} over {len(both) - 1} sessions") # --------------------------------------------------------------------------- # daemon # --------------------------------------------------------------------------- def daemon(cfg: dict, live: bool) -> None: from overnight_paper import Alpaca, sessions cl = Alpaca() print(f"{now_utc():%Y-%m-%d %H:%M} UTC ibkr_trader daemon started ({STRATEGY})", flush=True) done: set[tuple[str, str]] = set() while True: try: now = now_utc() cal = sessions(cl, (now - timedelta(days=1)).date(), (now + timedelta(days=7)).date()) for d, row in cal.iterrows(): key = d.isoformat() if ("submit", key) not in done and row["close"] - SUBMIT_BEFORE_CLOSE <= now < row["close"] - SUBMIT_CUTOFF: # Retried each minute inside the window until the session is marked submitted, # which happens just before the first order goes out: nothing is ever sent twice. try: if _state().get("submitted") != key: asyncio.run(plan(cfg, submit=True, live=live)) finally: if _state().get("submitted") == key: done.add(("submit", key)) if ("report", key) not in done and row["close"] + REPORT_AFTER_CLOSE <= now < row["close"] + timedelta(hours=7): done.add(("report", key)) asyncio.run(report(cfg)) except SystemExit as exc: print(f"{now_utc():%Y-%m-%d %H:%M} UTC stopped: {exc}", flush=True) except Exception as exc: # noqa: BLE001 - keep the loop alive print(f"{now_utc():%Y-%m-%d %H:%M} UTC error: {type(exc).__name__}: {str(exc)[:300]}", flush=True) time.sleep(60) def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("what", choices=["check", "plan", "submit", "report", "daemon"]) ap.add_argument("--what-if", action="store_true", help="plan: have IBKR validate and price each order") ap.add_argument("--live", action="store_true", help="allow a live (non-DU) account; also needs data/ibkr_live/ARMED") a = ap.parse_args() cfg = settings() if a.what == "check": asyncio.run(check(cfg)) elif a.what == "plan": asyncio.run(plan(cfg, what_if=a.what_if)) elif a.what == "submit": from overnight_paper import Alpaca, sessions today = datetime.now(NY).date() cal = sessions(Alpaca(), today, today) if today not in cal.index: raise SystemExit(f"{today}: no session") close = cal.loc[today, "close"] if not close - SUBMIT_BEFORE_CLOSE <= now_utc() <= close - SUBMIT_CUTOFF: raise SystemExit(f"outside the MOC window ({(close - SUBMIT_BEFORE_CLOSE).astimezone(NY):%H:%M}-" f"{(close - SUBMIT_CUTOFF).astimezone(NY):%H:%M} ET)") asyncio.run(plan(cfg, submit=True, live=a.live)) elif a.what == "report": asyncio.run(report(cfg)) else: daemon(cfg, a.live) if __name__ == "__main__": main()