diff --git a/scripts/surfer/equity_lowvol_gauntlet.py b/scripts/surfer/equity_lowvol_gauntlet.py new file mode 100644 index 000000000..333460d27 --- /dev/null +++ b/scripts/surfer/equity_lowvol_gauntlet.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Full gauntlet on the equity low-vol/lottery-aversion lead (liquid high-vol cohort). + +The make-or-break for the first non-crypto edge: (1) name-bootstrap (robust to WHICH names? — +the test that killed carry), (2) cohort-param robustness (did $10M/60pct manufacture it?), +(3) realistic short-borrow + long-only (is the edge trapped in the un-cheap short?), +(4) honest deflation + per-year, (5) correlation to the crypto momentum book (diversifier?). +Free — DBEQ on disk. +""" +import math +import os +import sys + +import numpy as np + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from equity_factor_gate import load, roll # noqa: E402 +from signal_sweep import xs_weights, validate, sharpe_t # noqa: E402 +import pit_sweep # noqa: E402 +from surfer_poc import compute_weights, CFG # noqa: E402 +import torch # noqa: E402 + +DEV = "cuda" if torch.cuda.is_available() else "cpu" + + +def main(): + insts, days, close, dvol = 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) + dv30 = roll(np.mean, np.nan_to_num(dvol), 30) + vol63 = roll(np.std, R, 63) + year = (1970 + days / 365.25).astype(int) + T_ = lambda x: torch.tensor(x[np.isfinite(x)], device=DEV, dtype=torch.float64) + + def cohort(dv_floor, vol_pct): + u = np.zeros((T, N), bool) + for t in range(T): + liq = (dv30[t] > dv_floor) & np.isfinite(close[t]) & np.isfinite(vol63[t]) + e = np.where(liq)[0] + if len(e) > 50: + u[t, e[vol63[t, e] >= np.quantile(vol63[t, e], vol_pct)]] = True + return u + + def smooth_weekly(w, K=5): + a = 2.0 / 6 + for t in range(1, T): + w[t] = a * w[t] + (1 - a) * w[t - 1] + wh = w.copy(); last = 0 + for t in range(T): + if t % K == 0: + last = t + wh[t] = w[last] + return wh + + def lowvol_pnl(univ, cols=None, long_only=False, borrow_ann=0.0): + sig = (-vol63).copy(); sig[~univ] = np.nan + if cols is not None: + mask = np.zeros(N, bool); mask[cols] = True; sig[:, ~mask] = np.nan + if long_only: + w = np.zeros((T, N)) + for t in range(T): + e = np.where(np.isfinite(sig[t]))[0] + if len(e) > 20: + k = max(int(0.10 * len(e)), 5); w[t, e[np.argsort(-sig[t, e])[:k]]] = 1.0 / k + else: + w = xs_weights(sig) + w = smooth_weekly(w) + g = np.sum(w[:-1] * R[1:], axis=1) + g -= np.sum(np.abs(w[1:] - w[:-1]), axis=1) * 10.0 / 1e4 # 10bp trade cost + if borrow_ann > 0: # borrow on short notional + short_notional = np.sum(np.clip(-w[:-1], 0, None), axis=1) + g -= short_notional * borrow_ann / 252.0 + return g + + base = cohort(1e7, 0.60) + base_pnl = lowvol_pnl(base) + v = validate(base_pnl, days, 30) + print(f"\n===== EQUITY LOW-VOL GAUNTLET (liquid high-vol cohort, deflate N=30) =====") + print(f"BASE L/S: full {v['full']:+.2f} OOS {v['oos']:+.2f} CPCVmed {v['med']:+.2f} DSR {v['dsr']:.2f}") + + # (1) name-bootstrap + cohort_cols = np.where(base.any(0))[0] + rng = np.random.default_rng(5); sh = [] + for _ in range(200): + c = rng.choice(cohort_cols, size=max(len(cohort_cols) // 2, 20), replace=False) + s = sharpe_t(T_(lowvol_pnl(base, cols=c))) + if not math.isnan(s): + sh.append(s) + sh = np.array(sh) + print(f"(1) name-bootstrap(200): frac>0 {np.mean(sh>0):.2f} median {np.median(sh):+.2f} 5th {np.percentile(sh,5):+.2f}") + + # (2) cohort-param robustness + print("(2) cohort-param grid (L/S full Sharpe):") + for fl in [5e6, 1e7, 2e7, 5e7]: + row = [] + for vp in [0.50, 0.60, 0.70]: + row.append(sharpe_t(T_(lowvol_pnl(cohort(fl, vp))))) + print(f" ${fl/1e6:>3.0f}M floor: " + " ".join(f"vp{int(vp*100)}:{r:+.2f}" for vp, r in zip([0.50,0.60,0.70], row))) + + # (3) short-borrow + long-only + print("(3) short-borrow & long-only (net):") + for ba in [0.0, 0.10, 0.30]: + print(f" L/S borrow {int(ba*100)}%/yr: {sharpe_t(T_(lowvol_pnl(base, borrow_ann=ba))):+.2f}") + lo = lowvol_pnl(base, long_only=True) + vlo = validate(lo, days, 30) + print(f" LONG-ONLY (no borrow): full {vlo['full']:+.2f} OOS {vlo['oos']:+.2f} CPCVmed {vlo['med']:+.2f} DSR {vlo['dsr']:.2f}") + + # (4) per-year (base L/S) + print("(4) per-year (base L/S): " + " ".join(f"{y}:{sharpe_t(T_(base_pnl[year[1:]==y])):+.2f}" for y in range(2023,2027) if (year[1:]==y).sum()>40)) + + # (5) correlation to crypto momentum book + syms, cdays, cc, cqv, cf = pit_sweep.load() + cw, _ = compute_weights(cc, cqv, cdays, CFG) + cR = np.zeros_like(cc); cR[1:] = np.log(cc)[1:] - np.log(cc)[:-1]; cR = np.where(np.isfinite(cR), cR, 0.0) + cff = np.where(np.isfinite(cf), cf, 0.0) + cmom = np.sum(cw[:-1] * (cR - cff)[1:], axis=1) + cmd = {int(cdays[1:][i]): cmom[i] for i in range(len(cmom))} + emd = {int(days[1:][i]): base_pnl[i] for i in range(len(base_pnl))} + common = sorted(set(cmd) & set(emd)) + a = np.array([cmd[d] for d in common]); b = np.array([emd[d] for d in common]) + m = np.isfinite(a) & np.isfinite(b) + corr = float(np.corrcoef(a[m], b[m])[0, 1]) if m.sum() > 50 else float("nan") + print(f"(5) corr to crypto-momentum book: {corr:+.2f} (low => diversifier in a preferred market)") + print("\nVERDICT: bootstrap frac>0~1 + grid broadly + long-only positive + DSR>0.5 + low crypto-corr = real 2nd sleeve.") + + +if __name__ == "__main__": + main()