From 4bbf07bddf1ba1b78bd1ef1afffe48c5239a7e49 Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Mon, 8 Jun 2026 00:10:24 +0200 Subject: [PATCH] feat: production multi-strat rebalance bot (lean enterprise core) Single-file cron-ready IBKR rebalancer for the adaptive book. Money-touching core only: env config (no secrets), paper/live guard (refuses live without MULTISTRAT_ALLOW_LIVE_CONFIRMED), leverage cap, HWM drawdown circuit-breaker, fat-finger per-order cap, once-per-period idempotency, stale-price guard, post-trade reconciliation, structured JSON audit log. Default dry-run; run needs MULTISTRAT_EXECUTE=true. Verified vs cluster ib-gateway paper (DU9600528): gates pass, 6-order plan, JSON audit trail + HWM state persisted. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/surfer/multistrat_bot.py | 197 +++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 scripts/surfer/multistrat_bot.py diff --git a/scripts/surfer/multistrat_bot.py b/scripts/surfer/multistrat_bot.py new file mode 100644 index 000000000..731564b84 --- /dev/null +++ b/scripts/surfer/multistrat_bot.py @@ -0,0 +1,197 @@ +#!/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")), + "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): + """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") + 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") + nlv = next((float(v.value) for v in ib.accountSummary() if v.tag == "NetLiquidation"), 0.0) + pos = {p.contract.symbol: p.position for p in ib.positions()} + log("INFO", "account", id=acct, paper=is_paper, nlv=round(nlv), positions=pos or None) + + ok, reasons = gates(nlv, is_paper, max_age, tw, state) + for r in reasons: + log("CRITICAL" if "CIRCUIT" in r or "LIVE" 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), 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 = nlv * w / prices[t]; cur = pos.get(t, 0.0); delta = tgt - cur + thresh = (0.005 if cur == 0 else C["hyst"]) * nlv + val = abs(delta * prices[t]) + if val <= thresh: + continue + if val > C["max_order"] * nlv: + log("WARN", "order_capped", ticker=t, value=round(val), cap=round(C["max_order"] * nlv)) + orders.append((t, "BUY" if delta > 0 else "SELL", round(abs(delta), 4), 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) + filled = sum(1 for tr in placed if tr.orderStatus.status == "Filled") + newpos = {p.contract.symbol: p.position for p in ib.positions()} + state["last_rebalance"] = dt.date.today().isoformat(); state["rebalances"] = state.get("rebalances", 0) + 1 + log("INFO", "reconcile", placed=len(placed), filled=filled, positions=newpos or None) + return 0 + finally: + save_state(state) + ib.disconnect() + + +if __name__ == "__main__": + sys.exit(main() or 0)