diff --git a/scripts/surfer/multistrat_bot.py b/scripts/surfer/multistrat_bot.py index 320fd0462..065f60cd3 100644 --- a/scripts/surfer/multistrat_bot.py +++ b/scripts/surfer/multistrat_bot.py @@ -46,6 +46,7 @@ C = { "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")), @@ -88,13 +89,15 @@ def target_weights(): return {TICKER[nm]: float(w[j] * lev) for j, (_, nm) in enumerate(INSTR)} -def gates(nlv, acct_is_paper, price_age, tw, state): +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']}") @@ -130,18 +133,28 @@ def main(): 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) + 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()} - log("INFO", "account", id=acct, paper=is_paper, nlv=round(nlv), positions=pos or None) + # 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(nlv, is_paper, max_age, tw, state) + 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 else "WARN", "gate_fail", reason=r) + 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), 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"): @@ -153,12 +166,12 @@ def main(): # 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 + 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"] * nlv: + 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: diff --git a/scripts/surfer/multistrat_paper.py b/scripts/surfer/multistrat_paper.py index 665acbafc..470f98d09 100644 --- a/scripts/surfer/multistrat_paper.py +++ b/scripts/surfer/multistrat_paper.py @@ -49,6 +49,11 @@ def yhist(sym): def build(): data = {nm: yhist(sym) for sym, nm in INSTR} dates = sorted(set.intersection(*[set(d) for d in data.values()])) + # drop today's INCOMPLETE intraday bar if present — this is a daily-CLOSE strategy; an in-session + # partial bar spikes the vol estimate and corrupts the leverage (e.g. halves it). Use completed closes. + today = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d") + if dates and dates[-1] == today: + dates = dates[:-1] R = np.zeros((len(dates), len(INSTR))) for j, (_, nm) in enumerate(INSTR): s = np.array([data[nm][d] for d in dates])