Files
foxhunt/scripts/surfer/multistrat_bot.py
jgrusewski 7662d82232 fix(bot): whole-share orders (IBKR API rejects fractional, err 10243) + state only on accepted
Eyes-on EXECUTE=true validation caught two bugs: (1) fractional qty -> all 6 orders cancelled (10243);
round to whole shares (skip if 0). (2) last_rebalance was set even when all orders were rejected,
which would block retry; now only marked when >=1 order is accepted.

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

206 lines
9.3 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))
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)