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>
This commit is contained in:
jgrusewski
2026-06-08 00:10:24 +02:00
parent 66e37955ce
commit 4bbf07bddf

View File

@@ -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·(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)