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>
This commit is contained in:
jgrusewski
2026-06-08 16:01:04 +02:00
parent 02b851ca8b
commit 3f321bb4bf
2 changed files with 27 additions and 9 deletions

View File

@@ -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:

View File

@@ -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])