"""XSEC v3 — varianti STRUTTURALI di momentum cross-sectional su Hyperliquid (STAT-MODE). TESI (filone XS). XS01 (sleeve attivo) e' momentum cross-sectional sui 19 major: blend di lookback [30,90] (z-score cross-sectional mediato) + gate di dispersione, vol-target 20%. Lezione del progetto (diari 2026-06-19/20): "i margini su XS sono nella STRUTTURA DEL SEGNALE, non nel numero di asset". Quindi NON allarghiamo l'universo: testiamo 4 COSTRUZIONI di momentum STRUTTURALMENTE diverse e chiediamo se MIGLIORANO o DIVERSIFICANO XS01 (o se sono solo XS01 travestito). Varianti (tutte L/S dollar-neutral, top-k/bottom-k, CAUSALI; long alto score / short basso score): RAMOM - RISK-ADJUSTED momentum: score = ritorno cumulato su L / vol realizzata su L (momentum "Sharpe-like", non grezzo). Penalizza i trend rumorosi. ACCEL - momentum ACCELERATION: score = mom(L_breve) - mom(L_lungo), la curvatura/2a differenza del trend relativo (chi sta accelerando vs chi sta decelerando). FIP - FROG-IN-THE-PAN / information discreteness: score = sign(mom) * ID, dove ID = |%giorni-su - %giorni-giu| su L. Privilegia i trend LISCI (path consistente). VOLSC - VOLATILITY-MANAGED momentum (Moreira-Muir): selezione = momentum, ma la LEVA del book e' scalata dall'inverso della vol di MERCATO cross-section recente (rischia di piu' a mercato calmo, meno in tempesta) invece del vol-target sulla vol della STRATEGIA. GIUDIZIO = MARGINALE vs XS01, non assoluto. Una variant con corr~0.9 a XS01 e Sharpe simile NON aggiunge nulla (e' XS01 travestito). Per ognuna calcolo: (a) corr vs XS01 e TP01; (b) uplift del PORTAFOGLIO 4->5 sleeve a 10%/15%; (c) SOSTITUZIONE di XS01 con la variant a parita' di peso. Vince solo se DIVERSIFICA (corr<0.7) E migliora l'hold-out aggiunta, OPPURE DOMINA XS01 a parita' di slot. GATE (CLAUDE.md, metodologia obbligatoria): 1. griglia L in {30,60,90} (Ls/Ll per ACCEL), H in {5,10}, k in {5,8}, ENTRAMBI universi (51/19). 2. CAUSALE: score a close[i], peso tenuto in i+1 (engine shifta); vol=0 gata; prefix-check ok. 3. NETTO fee 0.10% RT su ogni gamba/ribilancio + turnover; sweep fee monotona (test). 4. DEFLATED Sharpe sul best con TUTTI gli Sharpe FULL come trial (multiple-testing; serve >0.95). 5. per-anno + HOLD-OUT 2025-01-01. ANTI selection-on-holdout: riporto best per IN-SAMPLE(<2025) E best per HOLD, e verifico col deflated-Sharpe. 6. CAVEAT IMMUTABILE: book a molte gambe NON eseguibile a $600 -> STAT-MODE, MAI deploy. uv run python scripts/research/xsec_v3_momstruct.py """ from __future__ import annotations import sys from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research")) sys.path.insert(0, str(PROJECT_ROOT)) import numpy as np import pandas as pd import xsec_v2_nonmom as xv # harness collaudato (load_matrix, xs_engine, evalcfg, ...) from src.portfolio.sleeves import XS_UNIVERSE DPY = xv.DPY TV = xv.TV FEE = xv.FEE HOLDOUT = xv.HOLDOUT # =========================================================================== # SCORE BUILDERS — closure score_at(i)->(score[A], valid[A]) + warmup. CAUSALI (dati <= i). # Modellati su make_mom/make_resid di xsec_v2_nonmom.py. # =========================================================================== def make_ramom(PX, L): """Risk-adjusted momentum: score = (px[i]/px[i-L]-1) / std(ritorni giornalieri su L).""" px, n, A, DR, _ = xv._precompute(PX) RVL = DR.rolling(L, min_periods=int(0.8 * L)).std().values def score_at(i): if i - L < 0: return np.full(A, np.nan), np.zeros(A, bool) r = px[i] / px[i - L] - 1.0 rv = RVL[i] with np.errstate(invalid="ignore", divide="ignore"): score = r / rv valid = np.isfinite(score) & np.isfinite(px[i]) & np.isfinite(px[i - L]) & (rv > 0) return score, valid return score_at, L + 1 def make_accel(PX, Ls, Ll): """Acceleration: score = mom(Ls) - mom(Ll) (Ls |.| alto.""" px, n, A, DR, _ = xv._precompute(PX) up = (DR > 0).astype(float).where(DR.notna()) dn = (DR < 0).astype(float).where(DR.notna()) mp = int(0.8 * L) UPc = up.rolling(L, min_periods=mp).sum().values DNc = dn.rolling(L, min_periods=mp).sum().values CNT = DR.rolling(L, min_periods=mp).count().values def score_at(i): if i - L < 0: return np.full(A, np.nan), np.zeros(A, bool) r = px[i] / px[i - L] - 1.0 c = CNT[i] with np.errstate(invalid="ignore", divide="ignore"): pu = UPc[i] / c pdn = DNc[i] / c idd = np.abs(pu - pdn) score = np.sign(r) * idd valid = (np.isfinite(px[i]) & np.isfinite(px[i - L]) & np.isfinite(idd) & (c >= mp)) return score, valid return score_at, L + 1 # =========================================================================== # ENGINE volatility-managed (VOLSC): selezione momentum top-k/bottom-k IDENTICA a xs_engine, ma il # vol-target NON e' sulla vol della STRATEGIA bensi' sull'inverso della vol di MERCATO cross-section # (equal-weight) recente (Moreira-Muir). Distinzione strutturale unica da XS01. CAUSALE (shift(1)). # =========================================================================== def xs_engine_mktvol(PX, VOL, score_at, H, k, B_mkt=20, target_vol=TV, fee=FEE, min_assets=10, warmup=0, cap=3.0): px = PX.values vol = VOL.values n, A = px.shape dret = np.full((n, A), np.nan) dret[1:] = px[1:] / px[:-1] - 1.0 W = np.zeros((n, A)) w = np.zeros(A) for i in range(n): if i >= warmup and i % H == 0: score, valid = score_at(i) valid = valid & np.isfinite(score) & (vol[i] > 0) idxv = np.where(valid)[0] if len(idxv) >= min_assets: kk = min(k, len(idxv) // 2) order = idxv[np.argsort(score[idxv])] lo, hi = order[:kk], order[-kk:] w = np.zeros(A) w[hi] = 0.5 / kk w[lo] = -0.5 / kk else: w = np.zeros(A) W[i] = w gross = np.zeros(n) gross[1:] = np.nansum(W[:-1] * np.nan_to_num(dret[1:]), axis=1) turn = np.zeros(n) turn[0] = np.abs(W[0]).sum() turn[1:] = np.abs(np.diff(W, axis=0)).sum(axis=1) net = gross - turn * (fee / 2.0) s = pd.Series(net, index=PX.index) # vol-target sulla vol di MERCATO (equal-weight), causale (shift 1): leva alta a mercato calmo mkt = PX.pct_change().mean(axis=1) sig_mkt = mkt.rolling(B_mkt, min_periods=int(0.6 * B_mkt)).std().shift(1) * np.sqrt(DPY) scale = np.clip(np.nan_to_num(target_vol / sig_mkt.replace(0, np.nan).values, nan=0.0), 0, cap) turn_py = float(turn.sum() / (n / DPY)) if n else 0.0 return pd.Series(s.values * scale, index=PX.index), turn_py def caus_check_mktvol(PX, VOL, builder, cfg, B_mkt=20, frac=0.85, tail=60, tol=1e-9): """Prefix-check di causalita' per il pipeline VOLSC (engine custom): ricostruisce su un prefisso e confronta la coda con la run completa. Look-ahead -> divergenza.""" sa, warm = builder(PX, cfg) full, _ = xs_engine_mktvol(PX, VOL, sa, cfg["H"], cfg["k"], B_mkt=B_mkt, warmup=warm) cut = int(len(PX) * frac) PXc, VOLc = PX.iloc[:cut], VOL.iloc[:cut] sa2, warm2 = builder(PXc, cfg) pre, _ = xs_engine_mktvol(PXc, VOLc, sa2, cfg["H"], cfg["k"], B_mkt=B_mkt, warmup=warm2) lo = max(0, cut - tail) a = full.values[lo:cut] b = pre.values[lo:cut] worst = float(np.max(np.abs(a - b))) if len(a) else float("nan") return dict(ok=bool(worst <= tol), max_tail_diff=worst, cut=cut, tail=len(a)) # =========================================================================== # REGISTRY varianti: builder(PX,p)->(score_at,warm), griglia config, engine. # =========================================================================== def variants(): Lg = (30, 60, 90) Hk = [dict(H=H, k=k) for H in (5, 10) for k in (5, 8)] accel_pairs = [(30, 60), (30, 90), (60, 90)] return { "RAMOM": dict( builder=lambda PX, p: make_ramom(PX, p["L"]), cfgs=[dict(L=L, **hk) for L in Lg for hk in Hk], engine="std", B_mkt=None), "ACCEL": dict( builder=lambda PX, p: make_accel(PX, p["Ls"], p["Ll"]), cfgs=[dict(Ls=ls, Ll=ll, **hk) for (ls, ll) in accel_pairs for hk in Hk], engine="std", B_mkt=None), "FIP": dict( builder=lambda PX, p: make_fip(PX, p["L"]), cfgs=[dict(L=L, **hk) for L in Lg for hk in Hk], engine="std", B_mkt=None), "VOLSC": dict( builder=lambda PX, p: xv.make_mom(PX, p["L"], +1), cfgs=[dict(L=L, **hk) for L in Lg for hk in Hk], engine="mktvol", B_mkt=20), } def run_variant_cfg(PX, VOL, v, p): sa, warm = v["builder"](PX, p) if v["engine"] == "mktvol": s, turn = xs_engine_mktvol(PX, VOL, sa, p["H"], p["k"], B_mkt=v["B_mkt"], warmup=warm) else: s, turn = xv.xs_engine(PX, VOL, sa, p["H"], p["k"], warmup=warm) return xv.to_daily(s), turn def tag(p): return " ".join(f"{kk}{vv}" for kk, vv in p.items()) def run_grid(PX, VOL, v, xs_daily, tp_daily, uname): rows = [] for p in v["cfgs"]: daily, turn = run_variant_cfg(PX, VOL, v, p) if daily.std() == 0 or len(daily) < 60: continue f, h, pct = xv.evalcfg(daily) ins = xv.metrics(daily[daily.index < HOLDOUT])["sharpe"] rows.append(dict(cfg=p, uni=uname, daily=daily, full=f["sharpe"], hold=h["sharpe"], ins=ins, dd=f["maxdd"], ret=f["ret"], pct=pct, corrXS=xv._corr(daily, xs_daily), corrTP=xv._corr(daily, tp_daily), turn=turn)) return rows # =========================================================================== # PORTAFOGLIO — base cablata una sola volta (cache sleeve riusate per uplift+sostituzione). # =========================================================================== _BASE = None _BASE_M = None def _base(): global _BASE, _BASE_M if _BASE is None: _BASE = xv.active_sleeves() pf = xv.StrategyPortfolio(_BASE) pf.backtest() # warma le cache degli sleeve _BASE_M = (xv.metrics(pf.combined_daily()), xv.metrics(pf.combined_daily(lo=HOLDOUT))) return _BASE, _BASE_M def add_uplift(daily, fr): base, _ = _base() wraw = fr / (1.0 - fr) cand = xv.Sleeve("XSV3_cand", wraw, lambda d=daily: d) pf = xv.StrategyPortfolio(base + [cand]) return (xv.metrics(pf.combined_daily()), xv.metrics(pf.combined_daily(lo=HOLDOUT)), pf.weights().get("XSV3_cand", 0.0)) def substitute_xs01(daily): base, _ = _base() sub = [xv.Sleeve("XSV3_sub", s.weight, lambda d=daily: d) if s.name == "XS01_xsec_hl" else s for s in base] pf = xv.StrategyPortfolio(sub) return xv.metrics(pf.combined_daily()), xv.metrics(pf.combined_daily(lo=HOLDOUT)) # =========================================================================== # REPORT # =========================================================================== def per_year(daily): out = [] for y, g in daily.groupby(daily.index.year): out.append((int(y), round(float((1 + g).prod() - 1), 3))) return out def variant_verdict(pick, up_best, sub_full_d, sub_hold_d, caus_ok): cx = abs(pick["corrXS"]) if not caus_ok: return "SCARTATO", "non causale (prefix-check fallito)" if pick["full"] <= 0.3 or pick["hold"] <= 0: return "SCARTATO", f"standalone debole (FULL {pick['full']:+.2f}, HOLD {pick['hold']:+.2f})" dominates = (sub_full_d > 0.02 and sub_hold_d > 0.05) diversifies = (cx < 0.7) and (up_best[1] > 0.05) # up_best=(Δfull,Δhold) if dominates: return "MIGLIORA-XS01", f"sostituendo XS01 il book sale FULL {sub_full_d:+.2f} / HOLD {sub_hold_d:+.2f}" if diversifies: return "DIVERSIFICA", f"corrXS {pick['corrXS']:+.2f}<0.7 e uplift HOLD aggiunta {up_best[1]:+.2f}" if cx >= 0.7: return "REDUNDANT", f"corrXS {pick['corrXS']:+.2f} alta -> momentum XS01 travestito" return "REDUNDANT", f"scorrelata (corrXS {pick['corrXS']:+.2f}) ma non additiva (uplift HOLD {up_best[1]:+.2f}, sub HOLD {sub_hold_d:+.2f})" def main(): print("=" * 104) print(" XSEC v3 — VARIANTI STRUTTURALI di momentum cross-sectional (RAMOM/ACCEL/FIP/VOLSC) — STAT-MODE") print("=" * 104) tp_daily = xv.tp01_sleeve().daily() xs_daily = xv.xsec_sleeve().daily() print(" riferimenti: XS01 (momentum blend+gate, sleeve attivo) e TP01 (trend BTC/ETH).") xs_f = xv.metrics(xs_daily) xs_h = xv.metrics(xs_daily[xs_daily.index >= HOLDOUT]) print(f" XS01 standalone: FULL Sh {xs_f['sharpe']:.2f} DD {xs_f['maxdd']*100:.0f}% | " f"HOLD Sh {xs_h['sharpe']:.2f}") universes = {"51-all": None, "19-major": XS_UNIVERSE} mats = {} for uname, u in universes.items(): PX, VOL = xv.load_matrix(u) mats[uname] = (PX, VOL) print(f" universo {uname:<9}: {PX.shape[1]} asset, {PX.shape[0]} giorni " f"[{PX.index[0].date()} -> {PX.index[-1].date()}]") vdefs = variants() all_full = [] per_var_rows = {} for vname, v in vdefs.items(): rows_all = [] for uname, (PX, VOL) in mats.items(): rows = run_grid(PX, VOL, v, xs_daily, tp_daily, uname) rows_all += rows all_full += [r["full"] for r in rows] per_var_rows[vname] = rows_all base, (bf, bh) = _base() print(f"\n BASE portafoglio (4 sleeve attivi): FULL Sh {bf['sharpe']:.2f} DD {bf['maxdd']*100:.0f}%" f" | HOLD Sh {bh['sharpe']:.2f} DD {bh['maxdd']*100:.0f}%") summary = [] for vname, v in vdefs.items(): rows = per_var_rows[vname] if not rows: print(f"\n [{vname}] nessuna config valida.") continue n = len(rows) pos_full = sum(r["full"] > 0 for r in rows) pos_hold = sum(r["hold"] > 0 for r in rows) pick_ins = max(rows, key=lambda r: (r["ins"], r["full"])) # selezione ONESTA (in-sample) pick_hold = max(rows, key=lambda r: r["hold"]) # ceiling ottimistico print("\n" + "#" * 104) print(f"# {vname} | {n} config x2 universi | plateau FULL>0 {pos_full}/{n} | HOLD>0 {pos_hold}/{n}") print("#" * 104) print(f" {'pick':<12}{'cfg':<24}{'uni':<10}{'FULL':>6}{'INS':>6}{'HOLD':>6}{'DD%':>6}" f"{'ret%':>7}{'an+':>6}{'crXS':>7}{'crTP':>7}{'t/y':>7}") for lbl, r in (("by-INS<2025", pick_ins), ("by-HOLD", pick_hold)): print(f" {lbl:<12}{tag(r['cfg']):<24}{r['uni']:<10}{r['full']:>6.2f}{r['ins']:>6.2f}" f"{r['hold']:>6.2f}{r['dd']*100:>6.0f}{r['ret']*100:>+7.0f}{r['pct']*100:>5.0f}%" f"{r['corrXS']:>+7.2f}{r['corrTP']:>+7.2f}{r['turn']:>7.0f}") # top-3 per IN-SAMPLE per leggere il plateau print(" --- top-3 by IN-SAMPLE Sharpe (plateau) ---") for r in sorted(rows, key=lambda r: -r["ins"])[:3]: print(f" {tag(r['cfg']):<24}{r['uni']:<10}FULL {r['full']:+.2f} INS {r['ins']:+.2f}" f" HOLD {r['hold']:+.2f} corrXS {r['corrXS']:+.2f}") # ---- gate sul pick_ins (selezione onesta) ---- pick = pick_ins v_uni = pick["uni"] PX, VOL = mats[v_uni] if v["engine"] == "mktvol": caus = caus_check_mktvol(PX, VOL, v["builder"], pick["cfg"], B_mkt=v["B_mkt"]) else: caus = xv.causality_prefix_check(PX, VOL, v["builder"], pick["cfg"]) dsr, sr0 = xv.deflated_sharpe(pick["full"], all_full, pick["daily"]) print(f" CAUSALITA' (prefix-check) ok={caus['ok']} max_tail_diff={caus['max_tail_diff']:.2e}") print(f" DEFLATED Sharpe (N={len([s for s in all_full if np.isfinite(s)])} trial GLOBALI): " f"{dsr:.3f} | soglia Sharpe-max-null {sr0:.2f} (serve >0.95)") print(f" per-anno (pick-INS): {per_year(pick['daily'])}") # ---- portafoglio: uplift 4->5 e SOSTITUZIONE di XS01 (a parita' di peso) ---- print(" UPLIFT (aggiunta come 5o sleeve):") up_best = (-9.0, -9.0) for fr in (0.10, 0.15): cf, ch, wgt = add_uplift(pick["daily"], fr) df_, dh_ = cf["sharpe"] - bf["sharpe"], ch["sharpe"] - bh["sharpe"] print(f" @{wgt*100:>4.1f}% FULL {cf['sharpe']:.2f} ({df_:+.2f}) DD {cf['maxdd']*100:.0f}%" f" | HOLD {ch['sharpe']:.2f} ({dh_:+.2f})") if (df_ + dh_) > (up_best[0] + up_best[1]): up_best = (df_, dh_) sf, sh = substitute_xs01(pick["daily"]) sub_full_d, sub_hold_d = sf["sharpe"] - bf["sharpe"], sh["sharpe"] - bh["sharpe"] print(f" SOSTITUZIONE XS01->{vname} (peso {base[1].weight:.4f}): " f"FULL {sf['sharpe']:.2f} ({sub_full_d:+.2f}) DD {sf['maxdd']*100:.0f}%" f" | HOLD {sh['sharpe']:.2f} ({sub_hold_d:+.2f})") verdict, why = variant_verdict(pick, up_best, sub_full_d, sub_hold_d, caus["ok"]) print(f" >>> VERDETTO {vname}: {verdict} — {why}") summary.append(dict(name=vname, pick=pick, dsr=dsr, caus=caus["ok"], up=up_best, sub=(sub_full_d, sub_hold_d), verdict=verdict)) # ---- SINTESI ---- print("\n" + "=" * 104) print(" SINTESI — giudizio MARGINALE vs XS01 (sleeve attivo)") print("=" * 104) print(f" {'variant':<8}{'FULL':>6}{'HOLD':>6}{'DD%':>6}{'corrXS':>8}{'corrTP':>8}{'DSR':>7}" f"{'+upHOLD':>9}{'subHOLD':>9} verdetto") for s in summary: p = s["pick"] print(f" {s['name']:<8}{p['full']:>6.2f}{p['hold']:>6.2f}{p['dd']*100:>6.0f}" f"{p['corrXS']:>+8.2f}{p['corrTP']:>+8.2f}{s['dsr']:>7.3f}{s['up'][1]:>+9.2f}" f"{s['sub'][1]:>+9.2f} {s['verdict']}") winners = [s for s in summary if s["verdict"] in ("MIGLIORA-XS01", "DIVERSIFICA")] print("\n CONCLUSIONE:") if not winners: print(" NESSUNA variante batte o diversifica davvero XS01. Tutte sono momentum-family ad") print(" alta corr con XS01 e/o non additive al portafoglio -> REDUNDANT/SCARTATO. La") print(" struttura del segnale (risk-adj/accel/smoothness/vol-timing) NON apre uno slot nuovo.") else: for s in winners: print(f" {s['name']}: {s['verdict']} (forward-monitor). corrXS {s['pick']['corrXS']:+.2f}, " f"+upHOLD {s['up'][1]:+.2f}, subHOLD {s['sub'][1]:+.2f}, DSR {s['dsr']:.3f}.") print("\n CAVEAT (immutabili): storia ~2.5 anni (deflated-Sharpe + multiple-testing); book a molte") print(" gambe NON eseguibile a $600 -> STAT-MODE / forward-monitor, MAI deploy. Nessuno sleeve") print(" registrato: e' lavoro statistico (vincoli del filone XS).") if __name__ == "__main__": main()