Files
foxhunt/scripts/surfer/multistrat_bot.py
jgrusewski 4bbf07bddf 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) <noreply@anthropic.com>
2026-06-08 00:10:24 +02:00

198 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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·(1this)
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)