Files
foxhunt/scripts/surfer/multistrat_bot.py
jgrusewski 3f321bb4bf fix: broker-data sanity-check + drop incomplete intraday bar (two live-only bugs)
(1) IBKR paper reports summary fields (cash/GrossPositionValue) inconsistent with actual positions
(found: $141k/13% gap, fills+positions are ground truth). Bot now cross-checks NLV vs cash+position-
market-value, WARNs on >2% gap, sizes on the CONSERVATIVE NLV (never inflates), and a gate refuses to
trade if the gap exceeds MULTISTRAT_DATA_TOL (default 25%).
(2) build() included today's INCOMPLETE intraday bar when run mid-session -> spiked vol estimate ->
halved the leverage (1.0->0.5) -> would have sold half the book as churn at the 14:35 cron. Now drops
today's partial bar; uses completed closes only (matches the daily-close backtest). Verified live:
leverage back to 1.0, book recognized on-target (in_band), no spurious orders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:01:04 +02:00

219 lines
11 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")),
"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)