feat(surfer): equity low-vol gauntlet — real structure, marginal after realistic cost
Full gauntlet on the lottery-aversion lead (liquid high-vol cohort). BASE L/S +0.74/OOS +0.72 but: name-bootstrap frac>0 only 0.77 (fragile vs momentum's 1.00); edge lives at $5-20M floor (smaller names, real spreads > the 10bp assumed), dies at $50M; the L/S edge is mostly shorting hard-to-borrow hype names (30%/yr borrow -> +0.04 gone); long-only realistic = +0.33, DSR 0.07 (not significant). corr to crypto book -0.05 (uncorrelated -> would diversify IF real). DSR 0.22/0.07 -- neither clears 0.5. Closest non-crypto market, theoretically sound, uncorrelated -- but marginal after realistic borrow+small-name cost, NOT deploy-grade. A lead on the shelf, not a strategy. Crypto momentum+VRP remains the only deploy-grade edge. Re-confirms the boundary. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
129
scripts/surfer/equity_lowvol_gauntlet.py
Normal file
129
scripts/surfer/equity_lowvol_gauntlet.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user