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:
@@ -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:
|
||||
|
||||
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user