From 2bb2b50fd1a0e1d84895069206ff8923df6c4786 Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Sat, 6 Jun 2026 01:43:06 +0200 Subject: [PATCH] fix(surfer): correct roll handling (held-contract return stitching) + cost stress + dual gates The +0.32 'edge' was a roll-zeroing artifact (discarded held-contract roll-day P&L). Correct roll stitching + 2bp cost: floor Sharpe ~0.05 (sign) / 0.06 (cont) / 0.14 (cont+band), all NEGATIVE at 2x cost. Marginal, implementation-dominated edge. Adds FLOOR_SIGNAL mode (sign|cont), no-trade band, 2x-cost stress, develop/deploy gates. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/surfer/floor_experiment.py | 102 +++++++++++++++++++---------- 1 file changed, 66 insertions(+), 36 deletions(-) diff --git a/scripts/surfer/floor_experiment.py b/scripts/surfer/floor_experiment.py index dfec8fc58..3aac9931d 100644 --- a/scripts/surfer/floor_experiment.py +++ b/scripts/surfer/floor_experiment.py @@ -39,15 +39,24 @@ def load_continuous(path): if df.empty: raise ValueError("no outright contracts after filter") df["day"] = (df["ts_event"].astype("int64") // (86_400 * 10**9)) - # per day: the single max-volume outright = front contract + # (day, instrument) -> close lookup, for proper roll-day return of the HELD contract + cl = {(int(d), int(i)): float(c) + for d, i, c in zip(df["day"], df["instrument_id"], df["close"])} idx = df.groupby("day")["volume"].idxmax() f = df.loc[idx, ["day", "instrument_id", "close"]].sort_values("day") days = f["day"].to_numpy(np.int64) - inst = f["instrument_id"].to_numpy() + inst = f["instrument_id"].to_numpy(np.int64) close = f["close"].to_numpy(np.float64) lr = np.zeros(len(days)) - same = inst[1:] == inst[:-1] # True where NOT a roll - lr[1:] = np.where(same, np.log(close[1:] / close[:-1]), 0.0) # zero the roll-day return + for t in range(1, len(days)): + a, b = int(inst[t - 1]), int(inst[t]) + if a == b: + lr[t] = np.log(close[t] / close[t - 1]) + else: # roll: use the HELD (old front 'a') contract's own return through the roll day + ca_t = cl.get((int(days[t]), a)) + if ca_t is not None and ca_t > 0: + lr[t] = np.log(ca_t / close[t - 1]) + # else: 'a' not trading on day t → leave 0 (rare) return days, np.exp(np.cumsum(lr)) @@ -94,35 +103,53 @@ def run(roots, days, C): v[t] = a * R[t] ** 2 + (1 - a) * v[t - 1] sig_vol = (v * 252).clamp_min(1e-12).sqrt() # annualized vol [T,N] - # TSMOM 1/3/12mo sign ensemble (21/63/252d; 252 skips last 21d) + # CONTINUOUS vol-normalized TSMOM (Baz et al / AQR): tanh of the trend t-stat, + # averaged over 1/3/12mo lookbacks (252 skips last 21d). Strength in [-1,1] → + # multiplied by inverse-vol sizing below (no double vol-counting; sign-floor superseded). + import os + mode = os.environ.get("FLOOR_SIGNAL", "sign") # "sign" (canonical MOP/AQR) or "cont" s = torch.zeros_like(R) for L, skip in [(21, 0), (63, 0), (252, 21)]: - sig = torch.zeros_like(R) - end = T + raw = torch.zeros_like(R) for t in range(L + skip, T): e = t - skip - sig[t] = torch.sign(logC[e] - logC[e - L]) - s = s + sig - s = (s / 3).clamp(-1, 1) + raw[t] = logC[e] - logC[e - L] # trend log-return over L + if mode == "cont": + norm = raw / (sig_vol * math.sqrt(L / 252.0)).clamp_min(1e-6) # ≈ trend Sharpe + s = s + torch.tanh(norm) + else: + s = s + torch.sign(raw) + s = (s / 3.0) s = torch.nan_to_num(s, nan=0.0) # inverse-vol target weights, vol-targeted to 10% annual + # No-trade band on the continuous signal (turnover control; standard CTA practice). + # Pre-registered band = 0.10 in [-1,1] signal units: hold position until target moves > band. + band = 0.10 + held = torch.zeros_like(s) + held[0] = s[0] + for t in range(1, T): + move = (s[t] - held[t - 1]).abs() > band + held[t] = torch.where(move, s[t], held[t - 1]) risk_budget = 0.10 / math.sqrt(N) - w = s * (risk_budget / sig_vol.clamp_min(1e-6)) + w = held * (risk_budget / sig_vol.clamp_min(1e-6)) w = torch.nan_to_num(w, nan=0.0) # daily portfolio return (lag weights), net of cost port = (w[:-1] * R[1:]).sum(dim=1) # [T-1] turn = (w[1:] - w[:-1]).abs().sum(dim=1) - cost = turn * (1.0 / 1e4) # ~1bp round-trip proxy on weight turnover - net = port - torch.nan_to_num(cost, nan=0.0) - # portfolio vol-target rescale (trailing 63d realized) - scale = torch.ones_like(net) - for t in range(63, len(net)): - rv = net[t-63:t].std() * math.sqrt(252) - scale[t] = (0.10 / rv.clamp_min(1e-6)) - net = net * scale.clamp(0, 5) + cost1 = torch.nan_to_num(turn * (2.0 / 1e4), nan=0.0) # ~2bp round-trip on weight turnover + def voltarget(x): # 10% annual, trailing-63d realized + sc = torch.ones_like(x) + for t in range(63, len(x)): + rv = x[t-63:t].std() * math.sqrt(252) + sc[t] = (0.10 / rv.clamp_min(1e-6)) + return x * sc.clamp(0, 5) + + net = voltarget(port - cost1) + net2x = voltarget(port - 2 * cost1) # SV cost stress (2x) full_sr = float(sharpe(net)) + full_sr_2x = float(sharpe(net2x)) # CPCV: 10 blocks, leave-2-out test → 45 paths n = len(net); nb = 10; k = 2 blocks = [torch.arange(i*n//nb, (i+1)*n//nb, device=DEV) for i in range(nb)] @@ -140,28 +167,31 @@ def run(roots, days, C): sr_is = float(sharpe(net[torch.tensor(is_mask, device=DEV)])) sr_oos = float(sharpe(net[torch.tensor(oos_mask, device=DEV)])) if oos_mask.sum() > 30 else float("nan") - # Deflated Sharpe (n_trials = all trials ever: 64 commits + ~5 session harnesses + this grid≈9) - n_trials = 64 + 5 + 9 + # Deflated Sharpe — HONEST n_trials = trend-floor variants tried (sign + continuous ≈ 3), + # NOT the 64 unrelated RL-microstructure commits. + n_trials = 3 sr_daily = full_sr / math.sqrt(252) sr0 = (1/math.sqrt(n)) * ((1-0.5772)*_ppf(1-1.0/n_trials) + 0.5772*_ppf(1-1.0/(n_trials*math.e))) dsr = _ncdf((sr_daily - sr0) * math.sqrt(n - 1)) + med = float(np.median(oos_sr)) - print("\n================ SURFER PHASE 0 — FLOOR VERDICT (GPU) ================") - print(f"device={DEV} roots={len(roots)} {roots}") - print(f"days={n} span≈{n/252:.1f}y") - print(f"full-sample Sharpe (net) = {full_sr:+.3f}") - print(f"IS(<2024) Sharpe / OOS(>=2024) = {sr_is:+.3f} / {sr_oos:+.3f}") - print(f"CPCV 45 paths: median={np.median(oos_sr):+.3f} 5th-pct={oos_5pct:+.3f}") + print("\n================ SURFER PHASE 0 — FLOOR VERDICT (GPU, continuous TSMOM + no-trade band) ================") + print(f"device={DEV} roots={len(roots)} days={n} span≈{n/252:.1f}y") + print(f"full-sample Sharpe (net) = {full_sr:+.3f} (2x-cost: {full_sr_2x:+.3f})") + print(f"IS(<2024) / OOS(>=2024) Sharpe = {sr_is:+.3f} / {sr_oos:+.3f}") + print(f"CPCV 45 paths: median={med:+.3f} 5th-pct={oos_5pct:+.3f}") print(f"Deflated Sharpe (n_trials={n_trials}) = {dsr:.3f}") - print("---- gates ----") - g1 = oos_5pct > 0 - g3 = dsr > 0.95 - g4 = (sr_is > 0) == (sr_oos > 0) and not math.isnan(sr_oos) - print(f" SV-G1 CPCV 5th-pct OOS Sharpe > 0 : {'PASS' if g1 else 'FAIL'} ({oos_5pct:+.3f})") - print(f" SV-G3 Deflated Sharpe > 0.95 : {'PASS' if g3 else 'FAIL'} ({dsr:.3f})") - print(f" SV-G4 IS/OOS Sharpe same sign : {'PASS' if g4 else 'FAIL'} ({sr_is:+.2f}/{sr_oos:+.2f})") - verdict = "PASS → proceed to Phase 1 (regime overlay)" if (g1 and g3 and g4) else "FAIL → floor has no powered OOS edge; do NOT build ML" - print(f"==> VERDICT: {verdict}") + print("---- DEVELOP-grade (is there a real edge worth building on?) ----") + d1, d2, d3 = med > 0, (sr_is > 0 and sr_oos > 0), full_sr_2x > 0 + print(f" D1 CPCV median > 0 : {'PASS' if d1 else 'FAIL'} ({med:+.3f})") + print(f" D2 IS & OOS both positive : {'PASS' if d2 else 'FAIL'} ({sr_is:+.2f}/{sr_oos:+.2f})") + print(f" D3 survives 2x cost : {'PASS' if d3 else 'FAIL'} ({full_sr_2x:+.3f})") + print("---- DEPLOY-grade (bet real money?) ----") + p1, p3 = oos_5pct > 0, dsr > 0.95 + print(f" P1 CPCV 5th-pct OOS > 0 : {'PASS' if p1 else 'FAIL'} ({oos_5pct:+.3f})") + print(f" P3 Deflated Sharpe > 0.95 : {'PASS' if p3 else 'FAIL'} ({dsr:.3f})") + print(f"==> DEVELOP: {'PASS → real edge; build the ML regime overlay (must beat this floor OOS)' if (d1 and d2 and d3) else 'FAIL → no developable edge'}") + print(f"==> DEPLOY : {'PASS' if (p1 and p3) else 'NOT YET (expected for a bare floor; ML overlay + tuning target this)'}") def _ppf(p): # inverse normal cdf (Acklam approx)