Files
foxhunt/scripts/surfer/research_ab.py
jgrusewski 7edeec9106 feat(surfer): proper (a)robust-construction + (b)diversifier research
(a) All 6 momentum constructions collapse to ~0.2 under aggressive +/-20% winsor
(momentum leans on large moves); volscaled/winsorinput degrade least, none robust to
the cap. (b) LOWVOL is genuinely uncorrelated (corr -0.13) and lifts the stressed book
+0.19->+0.34; TStrend correlated; carry/reversal/size don't help. Book=momentum+lowvol.
Honest deployable bracket ~0.2 (harsh winsor) to ~0.6 (fair); +/-20% blanket winsor
double-counts (death-spiral already excluded) so truth ~0.5. Deciding next: per-coin
ADV slippage model, not blanket winsor. Corrected the script's over-claiming verdict.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:12:55 +02:00

164 lines
7.0 KiB
Python

#!/usr/bin/env python3
"""Proper research: (a) robust momentum construction + (b) diversifying second edge.
All point-in-time (universe = daily top-50 by trailing dollar-volume, dead coins included) and
under REALISM stress (Reff winsorized ±20%/day, death-spiral last-5d excluded, 20bp cost) — we
optimize for robust/realistic performance, not headline.
(a) 6 pre-registered momentum constructions judged by STRESSED Sharpe + degradation ratio + every-year.
(b) canonical diversifiers (carry, TS-trend, long/short reversal, low-vol, size) judged by correlation
to the momentum sleeve + COMBINED inverse-vol book Sharpe (a weak-but-uncorrelated sleeve can help).
"""
import math
import os
import sys
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import pit_sweep as ps # 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"
WINSOR, COST, TOPK = 0.20, 20.0, 50
def tr(lc, L, skip=0):
out = np.full_like(lc, np.nan)
if skip == 0:
out[L:] = lc[L:] - lc[:-L]
else: # return from t-L-skip to t-skip (classic 12-1 skip)
out[L + skip:] = lc[L:-skip] - lc[:-(L + skip)]
return out
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 row_rank(x): # per-row percentile rank in [-0.5,0.5], nan-aware
out = np.full_like(x, np.nan)
for t in range(x.shape[0]):
m = np.isfinite(x[t])
if m.sum() > 1:
r = x[t, m].argsort().argsort().astype(float)
out[t, m] = r / (m.sum() - 1) - 0.5
return out
def main():
syms, days, close, qv, fund = ps.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)
year = (1970 + days / 365.25).astype(int)
yrs = list(range(2020, 2027))
# PIT universe + tradeable (death-spiral) mask
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
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_base = R - fund
Reff_str = np.where(tradeable, np.clip(Reff_base, -WINSOR, WINSOR), 0.0)
vol20 = roll(np.std, R, 20)
def pnl(sig, reff, cost):
s = sig.copy(); s[~univ] = np.nan
return pnl_w(xs_weights(s), reff, cost_bp=cost)
def peryear(p):
return [sharpe_t(torch.tensor(p[year[1:] == y], device=DEV, dtype=torch.float64))
if (year[1:] == y).sum() > 30 else float("nan") for y in yrs]
# ---------- (a) momentum constructions ----------
raw20 = tr(lc, 20)
cons = {
"raw_20": raw20,
"rank_20": row_rank(raw20),
"volscaled_20": raw20 / np.where(vol20 > 0, vol20, np.nan),
"pathconsist_20": roll(np.mean, np.sign(R), 20),
"skiprecent_20s3": tr(lc, 20, skip=3),
"winsorinput_20": roll(np.sum, np.clip(R, -0.05, 0.05), 20),
}
print(f"\n===== (a) ROBUST MOMENTUM CONSTRUCTION (PIT top-{TOPK}, stress=winsor±{int(WINSOR*100)}%+death+{int(COST)}bp) =====")
print(f"{'construction':>16} {'base_full':>9} {'str_full':>8} {'str_OOS':>7} {'str_CPCV':>8} {'ratio':>5} | per-year(stress) 2020..26")
abest, abest_pnl, abest_score = None, None, -9
for nm, sg in cons.items():
pb = pnl(sg, Reff_base, 10.0); ps_ = pnl(sg, Reff_str, COST)
sb = sharpe_t(torch.tensor(pb, device=DEV, dtype=torch.float64))
v = validate(ps_, days, 6)
ratio = v["full"] / sb if sb and not math.isnan(sb) and sb != 0 else float("nan")
py = peryear(ps_)
pys = " ".join(f"{p:>+5.2f}" if not math.isnan(p) else " n/a" for p in py)
nneg = sum(1 for p in py if not math.isnan(p) and p < 0)
score = v["med"] - 0.3 * nneg # robust: high stressed CPCV-med, few negative years
print(f"{nm:>16} {sb:>+9.2f} {v['full']:>+8.2f} {v['oos']:>+7.2f} {v['med']:>+8.2f} {ratio:>5.2f} | {pys}")
if score > abest_score:
abest, abest_pnl, abest_score = nm, ps_, score
print(f" -> robust momentum sleeve = {abest}")
# ---------- (b) diversifying second edge ----------
sleeve = abest_pnl
cand = {
"carry_7": -roll(np.mean, fund, 7),
"TStrend_30": np.sign(tr(lc, 30)) * np.where(univ, 1.0, np.nan),
"LTreversal_180": -tr(lc, 180),
"STreversal_2": -tr(lc, 2),
"lowvol": -vol20,
"size_small": -np.log(np.where(dv30 > 0, dv30, np.nan)),
}
print(f"\n===== (b) DIVERSIFYING EDGE vs momentum sleeve ({abest}) — stress applied =====")
print(f"{'candidate':>16} {'standalone':>10} {'corr_to_mom':>11} {'combined':>9} | combined per-year 2020..26")
sl = torch.tensor(sleeve, device=DEV, dtype=torch.float64)
sl_sd = float(sl.std())
combos = []
for nm, sg in cand.items():
pc = pnl(sg, Reff_str, COST)
a, b = np.asarray(sleeve), np.asarray(pc)
m = np.isfinite(a) & np.isfinite(b)
corr = float(np.corrcoef(a[m], b[m])[0, 1]) if m.sum() > 50 else float("nan")
sc = float(torch.tensor(pc, device=DEV, dtype=torch.float64).std())
wm, wc = (1 / sl_sd), (1 / sc if sc > 0 else 0)
comb = (wm * a + wc * b) / (wm + wc)
vc = sharpe_t(torch.tensor(comb, device=DEV, dtype=torch.float64))
py = peryear(comb)
pys = " ".join(f"{p:>+5.2f}" if not math.isnan(p) else " n/a" for p in py)
sa = sharpe_t(torch.tensor(pc, device=DEV, dtype=torch.float64))
print(f"{nm:>16} {sa:>+10.2f} {corr:>+11.2f} {vc:>+9.2f} | {pys}")
combos.append((nm, corr, vc, pc))
# ---------- final BOOK: momentum + diversifiers that lift combined Sharpe and are low-corr ----------
base_sh = sharpe_t(sl)
keep = [(nm, pc) for nm, corr, vc, pc in combos if vc > base_sh and (math.isnan(corr) or corr < 0.5)]
print(f"\n===== BOOK = momentum + {[k for k,_ in keep]} (inverse-vol weighted, stress) =====")
sleeves = [np.asarray(sleeve)] + [np.asarray(pc) for _, pc in keep]
sds = [float(np.nanstd(s)) for s in sleeves]
ws = np.array([1 / s if s > 0 else 0 for s in sds]); ws /= ws.sum()
book = sum(w * s for w, s in zip(ws, sleeves))
vb = validate(book, days, 6)
py = peryear(book)
pys = " ".join(f"{p:>+5.2f}" if not math.isnan(p) else " n/a" for p in py)
print(f" momentum-only Sharpe (stress) = {base_sh:+.2f}")
print(f" BOOK: full {vb['full']:+.2f} IS {vb['is_']:+.2f} OOS {vb['oos']:+.2f} CPCVmed {vb['med']:+.2f} CPCV5% {vb['p5']:+.2f}")
print(f" BOOK per-year 2020..26: {pys}")
print("\nVERDICT: book Sharpe > momentum-only with every-year positive => diversification works (deployable book).")
if __name__ == "__main__":
main()