#!/usr/bin/env python3 """Production rebalance bot for the adaptive multi-strat book on IBKR — lean enterprise core. Only what money-touching code actually needs: env config (no secrets in code), paper/live guard, leverage cap, drawdown circuit-breaker (HWM-based), once-per-period idempotency, post-trade reconciliation, structured JSON audit log. Default DRY-RUN. Designed to run from cron. Run: python3 multistrat_bot.py status # account + targets + gates, no trading python3 multistrat_bot.py dry # full plan incl. orders, no placement (default) python3 multistrat_bot.py run # place orders (still requires MULTISTRAT_EXECUTE=true) Env (all optional unless noted): IB_HOST=127.0.0.1 IB_PORT=4002 IB_CLIENT_ID=7 MULTISTRAT_MAXLEV=1.0 gross exposure cap (× NLV); book is unlevered MULTISTRAT_HYST=0.03 rebalance hysteresis (× NLV); entry-from-zero uses 0.5% floor MULTISTRAT_REBALANCE_DAYS=7 min days between rebalances (idempotency) MULTISTRAT_DD_HALT=0.20 halt trading if NLV < HWM·(1−this) MULTISTRAT_MAX_ORDER=0.30 refuse any single order > this × NLV (fat-finger guard) MULTISTRAT_EXECUTE=false must be "true" for `run` to actually place MULTISTRAT_ALLOW_LIVE_CONFIRMED must equal "I_UNDERSTAND_REAL_MONEY" to trade a non-paper acct MULTISTRAT_STATE / MULTISTRAT_LOG override state/log paths """ import datetime as dt import json import math import os import sys import urllib.request import numpy as np sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from multistrat_paper import build, book_series, INSTR # noqa: E402 _REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) TICKER = {"equity": "SPY", "bond": "IEF", "gold": "GLD", "commod": "PDBC", "trend": "DBMF", "crypto": "IBIT"} def cfg(k, d): return os.environ.get(k, d) C = { "host": cfg("IB_HOST", "127.0.0.1"), "port": int(cfg("IB_PORT", "4002")), "cid": int(cfg("IB_CLIENT_ID", "7")), "maxlev": float(cfg("MULTISTRAT_MAXLEV", "1.0")), "hyst": float(cfg("MULTISTRAT_HYST", "0.03")), "rebal_days": int(cfg("MULTISTRAT_REBALANCE_DAYS", "7")), "dd_halt": float(cfg("MULTISTRAT_DD_HALT", "0.20")), "max_order": float(cfg("MULTISTRAT_MAX_ORDER", "0.30")), "data_tol": float(cfg("MULTISTRAT_DATA_TOL", "0.25")), # halt if broker summary vs positions disagree > this "execute": cfg("MULTISTRAT_EXECUTE", "false").lower() == "true", "live_ok": cfg("MULTISTRAT_ALLOW_LIVE_CONFIRMED", "") == "I_UNDERSTAND_REAL_MONEY", "state": cfg("MULTISTRAT_STATE", os.path.join(_REPO, "data/surfer/multistrat_bot_state.json")), "log": cfg("MULTISTRAT_LOG", os.path.join(_REPO, "data/surfer/multistrat_bot.log")), } def log(level, event, **kv): rec = {"ts": dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds"), "level": level, "event": event, **kv} os.makedirs(os.path.dirname(C["log"]), exist_ok=True) with open(C["log"], "a") as f: f.write(json.dumps(rec) + "\n") print(f"[{level}] {event} " + " ".join(f"{k}={v}" for k, v in kv.items())) def load_state(): try: return json.load(open(C["state"])) except Exception: return {"last_rebalance": None, "hwm_nlv": 0.0, "rebalances": 0} def save_state(s): os.makedirs(os.path.dirname(C["state"]), exist_ok=True) json.dump(s, open(C["state"], "w"), indent=2) def yhist_last(sym): res = json.loads(urllib.request.urlopen(urllib.request.Request( f"https://query1.finance.yahoo.com/v8/finance/chart/{sym}?interval=1d&range=5d", headers={"User-Agent": "Mozilla/5.0"}), timeout=30).read())["chart"]["result"][0] ts = res["timestamp"]; c = [x for x in res["indicators"]["quote"][0]["close"] if x is not None] age_days = (dt.datetime.now(dt.timezone.utc) - dt.datetime.fromtimestamp(ts[-1], dt.timezone.utc)).days return float(c[-1]), age_days def target_weights(): _, R = build() _, w, lev = book_series(R) return {TICKER[nm]: float(w[j] * lev) for j, (_, nm) in enumerate(INSTR)} def gates(nlv, acct_is_paper, price_age, tw, state, data_gap=0.0): """Pre-trade safety gates. Returns (ok, [reasons]).""" fail = [] if not acct_is_paper and not C["live_ok"]: fail.append("LIVE account but MULTISTRAT_ALLOW_LIVE_CONFIRMED not set — refusing to trade real money") if nlv <= 0: fail.append("NLV <= 0") if data_gap > C["data_tol"]: fail.append(f"ACCOUNT DATA INCONSISTENT: NLV vs cash+positions gap {100*data_gap:.0f}% > {100*C['data_tol']:.0f}% — broker data unreliable, refusing to size") gross = sum(tw.values()) if gross > C["maxlev"] + 1e-6: fail.append(f"gross exposure {gross:.2f} > maxlev {C['maxlev']}") if price_age > 4: fail.append(f"stale prices ({price_age}d old) — data feed issue") hwm = max(state.get("hwm_nlv", 0.0), nlv) if hwm > 0 and nlv < hwm * (1 - C["dd_halt"]): fail.append(f"DRAWDOWN CIRCUIT-BREAKER: NLV ${nlv:,.0f} < HWM ${hwm:,.0f}·(1-{C['dd_halt']}) — halting") return (len(fail) == 0, fail) def main(): mode = sys.argv[1] if len(sys.argv) > 1 else "dry" state = load_state() tw = target_weights() prices, max_age = {}, 0 for t in tw: px, age = yhist_last(t); prices[t] = px; max_age = max(max_age, age) log("INFO", "targets", mode=mode, weights={t: round(w, 3) for t, w in tw.items()}, price_age_d=max_age) from ib_async import IB, Stock, MarketOrder ib = IB() last_err = None for attempt in range(3): try: ib.connect(C["host"], C["port"], clientId=C["cid"], timeout=15); break except Exception as e: last_err = e; ib.sleep(3) if not ib.isConnected(): log("ERROR", "connect_failed", host=C["host"], port=C["port"], err=str(last_err)[:80]); return 1 try: accts = ib.managedAccounts(); acct = accts[0] if accts else "?" is_paper = acct.startswith("DU") summ = {v.tag: float(v.value) for v in ib.accountSummary() if v.tag in ("NetLiquidation", "TotalCashValue")} nlv = summ.get("NetLiquidation", 0.0); cash = summ.get("TotalCashValue", 0.0) port_mv = sum(it.marketValue for it in ib.portfolio()) pos = {p.contract.symbol: p.position for p in ib.positions()} # IBKR (esp. paper) can report summary fields inconsistent with actual positions. Cross-check # NLV against an independent estimate (cash + actual position market value); size on the # CONSERVATIVE (lower) value so a glitch never inflates positions; gate if they disagree badly. est_portfolio = cash + port_mv data_gap = abs(nlv - est_portfolio) / max(nlv, est_portfolio, 1.0) rel_nlv = min(nlv, est_portfolio) if (nlv > 0 and est_portfolio > 0) else max(nlv, est_portfolio) log("INFO", "account", id=acct, paper=is_paper, nlv=round(nlv), cash=round(cash), port_mv=round(port_mv), reliable_nlv=round(rel_nlv), data_gap=round(data_gap, 3), positions=pos or None) if data_gap > 0.02: log("WARN", "account_data_inconsistent", nlv=round(nlv), cash_plus_positions=round(est_portfolio), gap_pct=round(100 * data_gap, 1), note="broker summary disagrees with actual positions (IBKR paper quirk) — sizing on conservative NLV") ok, reasons = gates(rel_nlv, is_paper, max_age, tw, state, data_gap) for r in reasons: log("CRITICAL" if "CIRCUIT" in r or "LIVE" in r or "INCONSISTENT" in r else "WARN", "gate_fail", reason=r) if not ok: log("ERROR", "aborted", gates_failed=len(reasons)); return 2 # update high-water mark (only after gates pass = healthy NLV) state["hwm_nlv"] = max(state.get("hwm_nlv", 0.0), rel_nlv) # idempotency: skip a `run` if rebalanced within the window if mode == "run" and state.get("last_rebalance"): last = dt.date.fromisoformat(state["last_rebalance"]) age = (dt.date.today() - last).days if age < C["rebal_days"]: log("INFO", "skip_idempotent", days_since=age, window=C["rebal_days"]); return 0 # compute orders (entry-from-zero floor 0.5% NLV; rebalance uses hysteresis) orders = [] for t, w in tw.items(): tgt = rel_nlv * w / prices[t]; cur = pos.get(t, 0.0); delta = tgt - cur thresh = (0.005 if cur == 0 else C["hyst"]) * rel_nlv val = abs(delta * prices[t]) if val <= thresh: continue if val > C["max_order"] * rel_nlv: log("WARN", "order_capped", ticker=t, value=round(val), cap=round(C["max_order"] * nlv)) qty = int(round(abs(delta))) # whole shares — IBKR API rejects fractional (error 10243) if qty == 0: log("INFO", "skip_subshare", ticker=t, note="rounds to 0 whole shares") continue orders.append((t, "BUY" if delta > 0 else "SELL", qty, round(val))) for t, side, qty, val in orders: log("INFO", "planned_order", ticker=t, side=side, qty=qty, value=val) if not orders: log("INFO", "in_band", note="book within hysteresis — nothing to do"); return 0 if mode != "run": log("INFO", "dry_run", n_orders=len(orders), note="set mode=run + MULTISTRAT_EXECUTE=true to place"); return 0 if not C["execute"]: log("WARN", "execute_disabled", note="MULTISTRAT_EXECUTE != true — not placing"); return 0 # place + reconcile placed = [] for t, side, qty, val in orders: try: c = Stock(t, "SMART", "USD"); ib.qualifyContracts(c) o = MarketOrder(side, qty); o.account = acct tr = ib.placeOrder(c, o); ib.sleep(2) log("INFO", "order_sent", ticker=t, side=side, qty=qty, status=tr.orderStatus.status) placed.append(tr) except Exception as e: log("ERROR", "order_failed", ticker=t, err=str(e)[:120]) # continue — partial book is recoverable next run ib.sleep(3) accepted = sum(1 for tr in placed if tr.orderStatus.status not in ("Cancelled", "ApiCancelled", "Inactive")) filled = sum(1 for tr in placed if tr.orderStatus.status == "Filled") newpos = {p.contract.symbol: p.position for p in ib.positions()} if accepted > 0: # mark the period done only if orders were actually accepted (not all rejected) state["last_rebalance"] = dt.date.today().isoformat(); state["rebalances"] = state.get("rebalances", 0) + 1 else: log("WARN", "no_orders_accepted", note="all rejected — not marking rebalance; will retry next run") log("INFO", "reconcile", placed=len(placed), accepted=accepted, filled=filled, positions=newpos or None) return 0 finally: save_state(state) ib.disconnect() if __name__ == "__main__": sys.exit(main() or 0)