From 8df1c7eea2f4e280aeb89c2b341d9e8135e46465 Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Sat, 6 Jun 2026 01:31:37 +0200 Subject: [PATCH] =?UTF-8?q?feat(surfer):=20Phase=200=20GPU=20floor=20+=20v?= =?UTF-8?q?alidation=20harness=20=E2=80=94=20first=20real=20(weak)=20OOS?= =?UTF-8?q?=20trend=20edge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PyTorch-GPU diversified-trend floor (TSMOM 1/3/12mo + inverse-vol + 10% vol-target) + CPCV/Deflated-Sharpe validation, over 22 CME futures x 19.7y (Databento GLBX ohlcv-1d via budget-capped fetcher, ~$32 credits). Verdict: Sharpe +0.32, CPCV median +0.30, IS +0.36/OOS +0.08 (sign-consistent), Deflated Sharpe ~0.92 at honest n_trials. Real but weak edge; fails deploy-grade gates (correct), passes edge-exists. Includes roll-neutralization fix (max-vol outright + zero roll-day returns) that eliminated 988%/day continuous-contract artifacts (RB vol 550%->31%). Plus install_torch_gpu.sh. Data gitignored. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 1 + scripts/install_torch_gpu.sh | 41 +++++++ scripts/surfer/fetch_daily.py | 73 ++++++++++++ scripts/surfer/floor_experiment.py | 185 +++++++++++++++++++++++++++++ 4 files changed, 300 insertions(+) create mode 100644 scripts/install_torch_gpu.sh create mode 100644 scripts/surfer/fetch_daily.py create mode 100644 scripts/surfer/floor_experiment.py diff --git a/.gitignore b/.gitignore index cd2714475..cad2d6e6b 100644 --- a/.gitignore +++ b/.gitignore @@ -204,3 +204,4 @@ crates/ml/ml/ # Foxhunt audit hook dedup state (cleared at SessionStart) .claude/.foxhunt-audit-state /config/ml/alpha_logits_cache.bin +data/surfer/ diff --git a/scripts/install_torch_gpu.sh b/scripts/install_torch_gpu.sh new file mode 100644 index 000000000..8371851e3 --- /dev/null +++ b/scripts/install_torch_gpu.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Install GPU PyTorch for the surfer experiments (local RTX 3050 Ti, driver 580 → CUDA 12.x). +# +# RECOMMENDED (no sudo — installs to ~/.local, matching your existing numpy/scipy/databento): +# bash scripts/install_torch_gpu.sh +# +# System-wide (only if you really want it): +# sudo bash scripts/install_torch_gpu.sh +# +# Pick a different CUDA wheel tag if cu124 ever 404s (cu126 / cu128 also work on driver 580): +# bash scripts/install_torch_gpu.sh cu126 +set -euo pipefail + +CUDA_TAG="${1:-cu124}" +TORCH_INDEX="https://download.pytorch.org/whl/${CUDA_TAG}" + +if [ "$(id -u)" -eq 0 ]; then + echo "[install] running as ROOT → system-wide site-packages (--break-system-packages)" + FLAGS="--break-system-packages" +else + echo "[install] running as USER → ~/.local (matches existing packages; no sudo needed)" + FLAGS="--user --break-system-packages" +fi + +echo "[install] torch from ${TORCH_INDEX}" +python3 -m pip install ${FLAGS} torch --index-url "${TORCH_INDEX}" + +echo "[verify] importing torch + checking CUDA on the local GPU" +python3 - <<'PY' +import torch +print(" torch:", torch.__version__) +print(" cuda available:", torch.cuda.is_available()) +if torch.cuda.is_available(): + print(" device:", torch.cuda.get_device_name(0), + "| capability:", torch.cuda.get_device_capability(0)) + x = torch.randn(1_000_000, device="cuda") + print(" GPU tensor op OK, sum =", float(x.sum())) +else: + raise SystemExit("CUDA NOT available to torch — check driver / try a different CUDA_TAG (cu126/cu128)") +print(" ✅ GPU PyTorch ready") +PY diff --git a/scripts/surfer/fetch_daily.py b/scripts/surfer/fetch_daily.py new file mode 100644 index 000000000..cee8287da --- /dev/null +++ b/scripts/surfer/fetch_daily.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Download daily OHLCV for the surfer universe — continuous front-month, BUDGET-CAPPED. + +Re-confirms get_cost (free, no download) and ABORTS if over the cap before spending. +Saves raw DBN to data/surfer/ (gitignored). Reads DATABENTO_API_KEY from env (never printed). +""" +import os +import sys + +import databento as db + +CAP_USD = 40.00 # hard ceiling on cumulative get_cost; covered by $125 free credits ($0 cash) +PER_ROOT_CAP = 10.00 # per-root sanity cap +DS = "GLBX.MDP3" +SCHEMA = "ohlcv-1d" +START = "2010-06-06" +END = "2026-06-05" +ROOTS = ["ES", "NQ", "YM", "RTY", "ZN", "ZB", "ZF", "ZT", "6E", "6J", "6B", "6A", + "6C", "GC", "SI", "HG", "CL", "NG", "RB", "ZC", "ZS", "ZW"] +# parent symbology = all outright expiries per root (fast; continuous chain resolution 504s). +# We build the continuous series ourselves (volume-roll + ratio-adjust) from these. +STYPE = "parent" +OUT_DIR = "data/surfer" + + +def main(): + key = os.environ.get("DATABENTO_API_KEY") + if not key: + print("DATABENTO_API_KEY not set"); return 2 + client = db.Historical(key) + + os.makedirs(OUT_DIR, exist_ok=True) + print(f"fetching {SCHEMA} per-root ({len(ROOTS)} roots, {STYPE}) {START}..{END}; " + f"per-root get_cost gate (≤${PER_ROOT_CAP}), cumulative cap ${CAP_USD}") + import time + total_recs = 0 + spent = 0.0 + failed = [] + for r in ROOTS: + sym = r + ".FUT" + out_r = f"{OUT_DIR}/{r}.dbn" + # per-root cost gate (free, no download) + try: + c_r = client.metadata.get_cost(dataset=DS, symbols=[sym], schema=SCHEMA, + start=START, end=END, stype_in=STYPE) + except Exception as e: + print(f" {r}: get_cost failed ({type(e).__name__}); skipping"); failed.append(r); continue + if c_r > PER_ROOT_CAP or spent + c_r > CAP_USD: + print(f" {r}: cost ${c_r:.4f} would breach cap (spent ${spent:.2f}) — SKIP"); failed.append(r); continue + ok = False + for attempt in range(1, 4): + try: + data = client.timeseries.get_range(dataset=DS, symbols=[sym], schema=SCHEMA, + start=START, end=END, stype_in=STYPE) + data.to_file(out_r) + n = sum(1 for _ in data) + total_recs += n; spent += c_r + print(f" {r}: ${c_r:.4f} {n:,} recs -> {out_r} (cum ${spent:.2f})") + ok = True + break + except Exception as e: + print(f" {r}: attempt {attempt} {type(e).__name__}; retry in 3s") + time.sleep(3) + if not ok: + failed.append(r) + print(f"DONE: {total_recs:,} records, get_cost-cum ${spent:.2f}, " + f"{len(ROOTS)-len(failed)}/{len(ROOTS)} roots ok" + + (f"; FAILED/SKIPPED: {failed}" if failed else "")) + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/surfer/floor_experiment.py b/scripts/surfer/floor_experiment.py new file mode 100644 index 000000000..dfec8fc58 --- /dev/null +++ b/scripts/surfer/floor_experiment.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +"""Surfer Phase 0 — diversified-trend FLOOR + validation, on the GPU (PyTorch). + +Loads per-root parent OHLCV-1d (data/surfer/*.dbn), builds ratio-adjusted continuous +front-month series (volume-roll), then runs the floor + the anti-overfit validation +ENTIRELY on the GPU (torch.cuda): TSMOM(1/3/12mo) → inverse-vol size → vol-target → +net-of-cost daily portfolio returns; CPCV(5th-pct OOS Sharpe), Deflated Sharpe, PBO, +IS↔OOS rank-consistency. Prints the PASS/FAIL verdict. + +Data-loading/continuous-assembly is CPU (I/O staging, like mapped-pinned upload); ALL +strategy + statistics compute is on the GPU. Verdict gates per the spec §5. +""" +import glob +import math +from itertools import combinations + +import numpy as np +import torch + +DEV = "cuda" if torch.cuda.is_available() else None +TICK_NA = float("nan") + + +# ---------- data loading + continuous assembly (CPU staging) ---------- +_MONTHS = "FGHJKMNQUVXZ" + +def load_continuous(path): + """Roll-neutralized continuous daily series from a root's parent OHLCV-1d. + Front = max-volume OUTRIGHT per day; daily return only when the front contract is + unchanged (roll-day returns ZEROED — no ratio-adjust, no fabricated roll jumps). + Returns (day_int[N], adj_close[N]) where adj_close = exp(cumsum(clean_return)).""" + import re + import databento as db + root = path.split("/")[-1][:-4] + pat = re.compile("^" + re.escape(root) + "[" + _MONTHS + r"]\d{1,2}$") # outrights only (e.g. RBZ5) + df = db.DBNStore.from_file(path).to_df().reset_index() + df = df[df["close"] > 0].copy() + df = df[df["symbol"].astype(str).str.match(pat)] + 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 + 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() + 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 + return days, np.exp(np.cumsum(lr)) + + +def build_panel(): + paths = sorted(glob.glob("data/surfer/*.dbn")) + series = {} + for p in paths: + root = p.split("/")[-1][:-4] + try: + d, c = load_continuous(p) + if len(d) > 300: + series[root] = (d, c) + except Exception as e: + print(f" skip {root}: {type(e).__name__} {e}") + roots = sorted(series) + alldays = sorted(set().union(*[set(series[r][0].tolist()) for r in roots])) + didx = {d: i for i, d in enumerate(alldays)} + C = np.full((len(alldays), len(roots)), np.nan) + for j, r in enumerate(roots): + d, c = series[r] + for dd, cc in zip(d.tolist(), c.tolist()): + C[didx[dd], j] = cc + return roots, np.array(alldays), C + + +# ---------- floor + validation (GPU) ---------- +def sharpe(x): # x: [..., T] torch; Sharpe over last dim, NaN-aware + m = torch.nanmean(x, dim=-1) + v = torch.nanmean((x - m.unsqueeze(-1)) ** 2, dim=-1).clamp_min(1e-18).sqrt() + return m / v * math.sqrt(252) + + +def run(roots, days, C): + Ct = torch.tensor(C, device=DEV, dtype=torch.float64) # [T, N] + logC = torch.log(Ct) + R = torch.zeros_like(logC); R[1:] = logC[1:] - logC[:-1] # daily log returns + R = torch.nan_to_num(R, nan=0.0) + T, N = R.shape + + # EWMA vol (per root), halflife 21d + a = 1 - math.exp(math.log(0.5) / 21) + v = torch.zeros_like(R); v[0] = R[0] ** 2 + for t in range(1, T): + 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) + s = torch.zeros_like(R) + for L, skip in [(21, 0), (63, 0), (252, 21)]: + sig = torch.zeros_like(R) + end = T + 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) + s = torch.nan_to_num(s, nan=0.0) + + # inverse-vol target weights, vol-targeted to 10% annual + risk_budget = 0.10 / math.sqrt(N) + w = s * (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) + + full_sr = float(sharpe(net)) + # 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)] + oos_sr = [] + for combo in combinations(range(nb), k): + idx = torch.cat([blocks[b] for b in combo]) + oos_sr.append(float(sharpe(net[idx]))) + oos_sr = np.array(oos_sr) + oos_5pct = float(np.percentile(oos_sr, 5)) + + # IS(<2024)/OOS(>=2024) split by day epoch (days are UTC-day ints) + split_day = 19723 # 2024-01-01 in days since epoch + is_mask = days[1:] < split_day + oos_mask = ~is_mask + 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 + 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)) + + 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(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}") + + +def _ppf(p): # inverse normal cdf (Acklam approx) + from statistics import NormalDist + return NormalDist().inv_cdf(p) +def _ncdf(x): + from statistics import NormalDist + return NormalDist().cdf(x) + + +def main(): + if DEV is None: + raise SystemExit("CUDA not available to torch") + roots, days, C = build_panel() + if not roots: + raise SystemExit("no data in data/surfer/*.dbn yet") + run(roots, days, C) + + +if __name__ == "__main__": + main()