feat(surfer): Phase 1 RL-gate — no rich signal set; RL not justified (+ resid-mom upgrade)
Tested 5 theoretically-distinct new crypto signals (residual-momentum, MAX/lottery, idiosyncratic-vol, Amihud-illiquidity, acceleration) on PIT top-50 with full gauntlet (OOS/CPCV/deflation/coin-bootstrap) + correlation to momentum. NO new DISTINCT validated signal: survivors = momentum + residual-momentum (corr 0.95 = same edge). Others fail (lottery/accel negative; ivol/amihud fail bootstrap-robustness, amihud closest miss corr+0.20/boot0.75). DECISIVE: validated signal set is thin (one alpha + VRP diversifier) -> RL/PPO has no rich interacting set to combine -> would not beat the linear baseline OOS -> don't build the RL (the falsifiable gate returns NO; avoids the 65th-commit trap). SMALL WIN: residual (beta-stripped) momentum +0.72 > raw +0.57 -- better base construction. Product = simple two-sleeve book. RL needs non-price signals to ever justify itself. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
126
scripts/surfer/phase1_signals.py
Normal file
126
scripts/surfer/phase1_signals.py
Normal file
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Phase 1 — hunt & validate NEW crypto signals; build the linear-blend baseline RL must beat.
|
||||
|
||||
Each candidate is theoretically distinct from momentum (different driver), run through the same
|
||||
gauntlet that killed carry/low-vol: PIT universe (top-50 by dollar-vol, dead coins in), death-excl,
|
||||
~10bp cost, full/IS/OOS Sharpe, CPCV-median, coin-bootstrap (frac>0), and CORRELATION to momentum.
|
||||
Survivors (CPCVmed>0 & OOS>0 & bootstrap frac>0>=0.8) -> linear blend = the baseline to beat.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import pit_sweep # noqa: E402
|
||||
from signal_sweep import xs_weights, pnl_w, validate, sharpe_t # noqa: E402
|
||||
import torch # noqa: E402
|
||||
|
||||
DEV = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
TOPK = 50
|
||||
|
||||
|
||||
def roll(fn, X, L):
|
||||
out = np.full_like(X, np.nan)
|
||||
for t in range(L, len(X)):
|
||||
out[t] = fn(X[t - L:t], axis=0)
|
||||
return out
|
||||
|
||||
|
||||
def trailing(lc, L):
|
||||
out = np.full_like(lc, np.nan); out[L:] = lc[L:] - lc[:-L]; return out
|
||||
|
||||
|
||||
def main():
|
||||
syms, days, close, qv, fund = pit_sweep.load()
|
||||
T, N = close.shape
|
||||
lc = np.log(close)
|
||||
R = np.zeros((T, N)); R[1:] = lc[1:] - lc[:-1]; R = np.where(np.isfinite(R), R, 0.0)
|
||||
fund = np.where(np.isfinite(fund), fund, 0.0)
|
||||
qv = np.nan_to_num(qv)
|
||||
dv30 = roll(np.mean, qv, 30)
|
||||
tradeable = np.ones((T, N), bool)
|
||||
for j in range(N):
|
||||
idx = np.where(np.isfinite(close[:, j]))[0]
|
||||
if len(idx):
|
||||
tradeable[max(0, idx[-1] - 4):idx[-1] + 1, j] = False
|
||||
Reff = np.where(tradeable, R - fund, 0.0)
|
||||
univ = np.zeros((T, N), bool)
|
||||
for t in range(T):
|
||||
elig = np.where((dv30[t] > 0) & np.isfinite(close[t]))[0]
|
||||
if len(elig):
|
||||
univ[t, elig[np.argsort(-dv30[t, elig])[:TOPK]]] = True
|
||||
year = (1970 + days / 365.25).astype(int)
|
||||
|
||||
# rolling BTC-beta -> residual returns (for residual momentum + IVOL)
|
||||
bj = syms.index("BTCUSDT")
|
||||
rb = R[:, bj]
|
||||
beta = np.zeros((T, N)); W = 60
|
||||
for t in range(W, T):
|
||||
rw = rb[t - W:t]; vb = rw.var() + 1e-12
|
||||
cov = (R[t - W:t] * rw[:, None]).mean(0) - R[t - W:t].mean(0) * rw.mean()
|
||||
beta[t] = cov / vb
|
||||
resid = R - beta * rb[:, None]
|
||||
resid_cum = np.cumsum(np.where(np.isfinite(resid), resid, 0.0), axis=0)
|
||||
|
||||
def trail_arr(A, L):
|
||||
out = np.full_like(A, np.nan); out[L:] = A[L:] - A[:-L]; return out
|
||||
|
||||
sigs = {
|
||||
"mom_30 (ref)": trailing(lc, 30),
|
||||
"resid_mom_30": trail_arr(resid_cum, 30),
|
||||
"MAX_lottery": -roll(np.max, R, 20), # short extreme-spike (lottery) coins
|
||||
"ivol_low": -roll(np.std, resid, 30), # low idiosyncratic vol
|
||||
"amihud_illiq": roll(np.mean, np.abs(R) / np.maximum(qv, 1.0), 30), # illiquidity premium
|
||||
"accel": trailing(lc, 10) - trailing(lc, 30), # momentum acceleration
|
||||
}
|
||||
|
||||
def book(sig):
|
||||
s = sig.copy(); s[~univ] = np.nan
|
||||
return pnl_w(xs_weights(s), Reff, cost_bp=10)
|
||||
|
||||
mom_pnl = book(sigs["mom_30 (ref)"])
|
||||
rng = np.random.default_rng(3)
|
||||
print(f"\n===== PHASE 1 — candidate crypto signals (PIT top{TOPK}, death-excl, 10bp) =====")
|
||||
print(f"{'signal':>16} {'full':>6} {'IS':>6} {'OOS':>6} {'CPCVmed':>8} {'boot>0':>7} {'corr_mom':>8}")
|
||||
pnls = {}
|
||||
for nm, sg in sigs.items():
|
||||
p = book(sg); pnls[nm] = p
|
||||
v = validate(p, days, 40)
|
||||
# coin-bootstrap
|
||||
sh = []
|
||||
for _ in range(80):
|
||||
cols = rng.choice(N, size=max(N // 2, 10), replace=False)
|
||||
sub = np.full((T, N), np.nan); sub[:, cols] = sg[:, cols]
|
||||
s = sharpe_t(torch.tensor(book(sub), device=DEV, dtype=torch.float64))
|
||||
if not math.isnan(s):
|
||||
sh.append(s)
|
||||
bootp = float(np.mean(np.array(sh) > 0)) if sh else float("nan")
|
||||
a, b = mom_pnl, p; m = np.isfinite(a) & np.isfinite(b)
|
||||
corr = float(np.corrcoef(a[m], b[m])[0, 1])
|
||||
print(f"{nm:>16} {v['full']:>+6.2f} {v['is_']:>+6.2f} {v['oos']:>+6.2f} {v['med']:>+8.2f} {bootp:>7.2f} {corr:>+8.2f}")
|
||||
v["_boot"] = bootp
|
||||
sigs[nm] = (sg, v)
|
||||
|
||||
# survivors -> linear blend baseline (sum of cross-sectional z-scores)
|
||||
def zc(x):
|
||||
mu = np.nanmean(x, axis=1, keepdims=True); sd = np.nanstd(x, axis=1, keepdims=True)
|
||||
return np.nan_to_num((x - mu) / np.where(sd > 0, sd, 1))
|
||||
surv = [nm for nm, (sg, v) in sigs.items() if v["med"] > 0 and v["oos"] > 0 and v.get("_boot", 0) >= 0.8]
|
||||
print(f"\nSURVIVORS (CPCVmed>0 & OOS>0 & boot>0>=0.8): {surv}")
|
||||
if len(surv) >= 2:
|
||||
blend = sum(zc(sigs[nm][0]) for nm in surv)
|
||||
bp = book(blend)
|
||||
vb = validate(bp, days, 40)
|
||||
T_ = lambda x: torch.tensor(x, device=DEV, dtype=torch.float64)
|
||||
py = " ".join(f"{y}:{sharpe_t(T_(bp[year[1:]==y])):+.2f}" for y in range(2020, 2027) if (year[1:] == y).sum() > 30)
|
||||
print(f"LINEAR-BLEND BASELINE ({len(surv)} signals): full {vb['full']:+.2f} OOS {vb['oos']:+.2f} CPCVmed {vb['med']:+.2f}")
|
||||
print(" per-year: " + py)
|
||||
print(" -> this is the bar an RL combiner must BEAT out-of-sample (deflated) to justify itself.")
|
||||
else:
|
||||
print("Too few survivors for a blend — RL has no signal set to combine; momentum stays solo.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user