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>
206 lines
9.3 KiB
Python
206 lines
9.3 KiB
Python
#!/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))
|
||
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)
|