"""XSEC v3 — fattori cross-sectional "low-risk cousins" su ~51 alt Hyperliquid (1d, STAT-MODE). TESI (filone C, terza ondata). I primi due filoni cross-sectional hanno coperto: momentum (XS01, sleeve attivo), reversal/idio-reversal, TOTAL-low-vol (LOWVOL) e betting-against-beta (BAB). Restano TRE anomalie "cugine del low-risk", documentate in equity ma MAI provate qui, che POTREBBERO diversificare il portafoglio essendo strutturalmente diverse dal momentum e dal total-vol: 1. MAX (lottery-demand, Bali-Cakici-Whitelaw 2011). Gli asset col MASSIMO rendimento giornaliero piu' alto nelle ultime B sedute attraggono domanda "da lotteria" e poi sottoperformano. SHORT high-max / LONG low-max -> score = -max(daily_ret over B). Diverso dal momentum (e' la coda destra recente, non il trend) e dal total-vol (un singolo estremo, non la dispersione). 2. IVOL (idiosyncratic vol, Ang-Hodrick-Xing-Zhang 2006). SHORT alta vol del RESIDUO (dopo aver tolto beta*mercato su finestra B) / LONG bassa. score = -ivol_residuo. DIVERSO da LOWVOL (gia' provato in v2) che usa la vol TOTALE: qui si toglie prima il fattore di mercato, isolando il rischio idiosincratico (ortogonale a BAB, che e' il beta sistematico). 3. AMIHUD (illiquidity, Amihud 2002). Ranking su |ret|/dollar_volume medio su B (dollar_volume = volume_coin * close, perche' il volume HL e' in coin -> va dollarizzato per confrontare asset). Tesi standard: premio di illiquidita' -> LONG illiquido / SHORT liquido. In crypto il segno e' incerto (flight-to-quality verso i major liquidi), quindi si provano ENTRAMBI i segni e si tiene quello con tesi economica + edge: AMIHUD_ILLIQ (score=+amihud) vs AMIHUD_LIQ (score=-amihud). GATE OBBLIGATORI (CLAUDE.md + parita' con xsec_v2_nonmom): - Griglia B in {20,30,60} x H in {5,10} x k in {5,8}, su ENTRAMBI gli universi (51-all, 19-major). - CAUSALE: score a close[i], peso tenuto in i+1 (engine shifta W[i-1]*dret[i]); vol=0 gata. Verifica prefix-consistency (xv.causality_prefix_check) sul best: ok=True, max_tail_diff~0. - NETTO fee 0.10% RT su ogni gamba a ogni ribilancio (engine) + turnover/anno riportato. - DEFLATED Sharpe (Bailey-Lopez de Prado) sul best, con TUTTI gli Sharpe FULL testati come trial (multiple-testing): serve DSR>0.95 per un claim forte. - corr vs XS01 e vs TP01 (vogliamo |corrXS|<0.6 per diversificare). - Uplift del portafoglio 4->5 sleeve a 10% e 15% (active_sleeves, non modificati). - Per-anno (breadth) + HOLD-OUT (2025-01-01+). - ANTI-selection-on-holdout: il best e' scelto per HOLD massimo; si riporta ANCHE il best scelto per Sharpe IN-SAMPLE (<2025) e si verifica che il deflated-Sharpe (che usa il FULL, in-sample incluso) regga comunque. CAVEAT immutabili: storia ~2.5 anni (deflated-Sharpe + multiple-testing), book a molte gambe NON eseguibile a $600 -> STAT-MODE / forward-monitor, MAI deploy. Nessuno sleeve registrato. uv run python scripts/research/xsec_v3_lowrisk.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)) sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research")) import numpy as np import pandas as pd import xsec_v2_nonmom as xv # HARNESS collaudato (engine, metriche, statistica, portafoglio) HOLDOUT = xv.HOLDOUT metrics = xv.metrics # =========================================================================== # SCORE BUILDERS — "low-risk cousins". Tutti CAUSALI (dati <= i). Convenzione # engine: long ALTO score / short BASSO score (vol=0 gata automaticamente). # =========================================================================== def make_max(PX, B): """MAX / lottery-demand: massimo rendimento giornaliero nelle ultime B sedute. score = -max -> long low-max / short high-max (la 'lotteria' sottoperforma).""" px, n, A, DR, m = xv._precompute(PX) ROLLMAX = DR.rolling(B, min_periods=int(0.6 * B)).max().values def score_at(i): mx = ROLLMAX[i] valid = np.isfinite(mx) & np.isfinite(px[i]) return -mx, valid return score_at, B + 1 def make_ivol(PX, B): """IVOL: vol del RESIDUO dopo beta*mercato su finestra B (OLS in-window, esatto: var_resid = var(y) - beta^2*var(m) con beta = cov/var, >= 0 per costruzione). score = -ivol -> long bassa idio-vol / short alta (anomalia Ang et al.).""" px, n, A, DR, m = xv._precompute(PX) beta, varm = xv._rolling_beta(DR, m, B) # beta (n,A), varm (n,) mp = int(0.6 * B) ExDR = DR.rolling(B, min_periods=mp).mean() ExDR2 = (DR * DR).rolling(B, min_periods=mp).mean() varDR = (ExDR2 - ExDR ** 2).values # var population (coerente con _rolling_beta) resid_var = varDR - (beta ** 2) * varm[:, None] ivol = np.sqrt(np.clip(resid_var, 0.0, None)) def score_at(i): iv = ivol[i] valid = np.isfinite(iv) & np.isfinite(beta[i]) & np.isfinite(px[i]) return -iv, valid return score_at, B + 1 def make_amihud(PX, VOL, B, sign): """AMIHUD illiquidity: media su B di |ret| / dollar_volume (volume_coin*close). sign=+1 -> LONG illiquido (premio di illiquidita'); sign=-1 -> LONG liquido.""" px, n, A, DR, m = xv._precompute(PX) dvol = (VOL * PX).replace(0, np.nan) # dollarizza il volume in coin illiq = (DR.abs() / dvol).replace([np.inf, -np.inf], np.nan) AMI = illiq.rolling(B, min_periods=int(0.6 * B)).mean().values def score_at(i): a = AMI[i] valid = np.isfinite(a) & np.isfinite(px[i]) return sign * a, valid return score_at, B + 1 def amihud_builder(VOL_full, sign): """Builder (PX,cfg) per AMIHUD che richiede il volume: chiude su VOL_full e lo RIALLINEA a PX.index. Cosi' la causality_prefix_check (che tronca PX a PXc=PX[:cut] e chiama builder(PXc)) riceve automaticamente VOLc = VOL_full.reindex(PXc.index) -> nessun look-ahead dal volume.""" def builder(PX, p): VOL = VOL_full.reindex(PX.index) return make_amihud(PX, VOL, p["B"], sign) return builder # Griglia condivisa (parita' con i gate): B x H x k. BHK = [dict(B=B, H=H, k=k) for B in (20, 30, 60) for H in (5, 10) for k in (5, 8)] def build_mechanisms(VOL): """Catalogo per-universo: AMIHUD va legato al VOL dell'universo corrente.""" return { "MAX": (lambda PX, p: make_max(PX, p["B"]), BHK), "IVOL": (lambda PX, p: make_ivol(PX, p["B"]), BHK), "AMIHUD_ILLIQ": (amihud_builder(VOL, +1), BHK), # long illiquido / short liquido "AMIHUD_LIQ": (amihud_builder(VOL, -1), BHK), # long liquido / short illiquido } # Tesi economica per il verdetto AMIHUD (quale segno ha senso se mostra edge). AMIHUD_THESIS = { "AMIHUD_ILLIQ": "premio di illiquidita' (long illiquido / short liquido)", "AMIHUD_LIQ": "flight-to-quality verso i major liquidi (long liquido / short illiquido)", } # =========================================================================== # Helper # =========================================================================== def insample_sharpe(daily): pre = daily[daily.index < HOLDOUT] return metrics(pre)["sharpe"] if len(pre) > 30 else float("nan") def per_year(daily): _, yrs = xv.yr_breadth(daily) years = [int(y) for y, _ in daily.groupby(daily.index.year)] return [(y, round(v, 3)) for y, v in zip(years, yrs)] def uplift_for(cand_daily, base, bf, bh, fractions=(0.10, 0.15)): """Uplift portafoglio 4->5 sleeve riusando le CACHE di `base` (Sleeve cached). Ritorna {fr: (cf, ch, wgt)} e il best combinato (dFULL+dHOLD).""" cand_fn = lambda: cand_daily out, best = {}, None for fr in fractions: wraw = fr / (1.0 - fr) # cand_frac ~ fr (sum_base=1) cand = xv.Sleeve("XSV3_cand", wraw, cand_fn) pf1 = xv.StrategyPortfolio(base + [cand]) cf = metrics(pf1.combined_daily()) ch = metrics(pf1.combined_daily(lo=HOLDOUT)) wgt = pf1.weights().get("XSV3_cand", 0.0) out[fr] = (cf, ch, wgt) d = (cf["sharpe"] - bf["sharpe"]) + (ch["sharpe"] - bh["sharpe"]) best = d if best is None else max(best, d) return out, best INSAMPLE_EDGE = 0.5 # gate del progetto (scorer indurito): edge standalone PRE-holdout >=0.5 def robust_candidate(rows): """Candidato GIUDICATO: NON il best-by-HOLD nudo (che premia il holdout-fitting: una config negativa in-sample con HOLD alto e' overfit alla finestra OOS, lezione dello scorer indurito), ma il best fra le config con EDGE IN-SAMPLE (>=0.5) E HOLD>0, ordinate per Sharpe BILANCIATO (insample+hold)/2. Se nessuna ha in-sample edge -> None (il meccanismo non ha edge reale, qualunque HOLD alto e' artefatto di selezione).""" elig = [r for r in rows if np.isfinite(r["insample"]) and r["insample"] >= INSAMPLE_EDGE and r["hold"] > 0] if not elig: return None return max(elig, key=lambda r: 0.5 * (r["insample"] + r["hold"])) def verdict(cand, dsr, caus_ok, uplift_best, has_isedge): full, hold, corrXS = cand["full"], cand["hold"], cand["corrXS"] diversifies = abs(corrXS) < 0.6 helps = (uplift_best is not None) and uplift_best > 0.10 if not has_isedge: return "SCARTATO", "nessuna config con edge in-sample>=0.5 + HOLD>0 (qualunque HOLD alto e' selezione-su-holdout)" strong = (dsr > 0.95) and (hold > 0.30) and (full > 0.70) and caus_ok and diversifies if strong and helps: return "SLEEVE-CANDIDATE", "edge robusto (DSR>0.95, in-sample+OOS, causale, diversifica, alza il portafoglio)" if (full > 0.5 and hold > 0.0 and diversifies and caus_ok) and (helps or dsr > 0.50): return "LEAD-forward-monitor", "edge in-sample E OOS coerente + diversifica, ma DSR<0.95 (96 trial, storia ~2.5y)" if full > 0.3 and hold > 0.0: return "DEBOLE", "segno giusto ma Sharpe/robustezza insufficienti" return "SCARTATO", "no edge (full/hold non positivi o non diversifica)" # =========================================================================== # MAIN # =========================================================================== def main(): print("=" * 104) print(" XSEC v3 — LOW-RISK COUSINS cross-sectional su Hyperliquid (MAX / IVOL / AMIHUD) — STAT-MODE") print("=" * 104) tp_daily = xv.tp01_sleeve().daily() xs_daily = xv.xsec_sleeve().daily() print(" riferimenti corr: TP01 (trend, deployable) e XS01 (momentum cross-sec, sleeve attivo).") universes = {"51-all": None, "19-major": xv.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]:>2} asset, {PX.shape[0]} giorni " f"[{PX.index[0].date()} -> {PX.index[-1].date()}]") # ---- griglia completa: raccoglie tutte le righe + tutti gli Sharpe FULL (trial DSR) ---- MECHS = ("MAX", "IVOL", "AMIHUD_ILLIQ", "AMIHUD_LIQ") rows_by_mech = {mn: [] for mn in MECHS} all_sr = [] builders = {} # (uname, mech) -> builder (per causality) for uname, (PX, VOL) in mats.items(): mechs = build_mechanisms(VOL) print("\n" + "#" * 104) print(f"# UNIVERSO {uname}") print("#" * 104) for mn in MECHS: builder, cfgs = mechs[mn] builders[(uname, mn)] = builder rows = xv.run_grid(PX, VOL, mn, builder, cfgs, xs_daily, tp_daily, uname) for r in rows: r["uni"] = uname r["mech"] = mn r["insample"] = insample_sharpe(r["daily"]) rows_by_mech[mn].extend(rows) all_sr.extend([r["full"] for r in rows]) if not rows: print(f"\n [{mn}] nessuna config valida") continue pos_full = sum(r["full"] > 0 for r in rows) pos_hold = sum(r["hold"] > 0 for r in rows) print(f"\n [{mn}] {len(rows)} config | plateau FULL>0: {pos_full}/{len(rows)}" f" | HOLD>0: {pos_hold}/{len(rows)}") print(f" {'cfg':<18}{'FULL':>7}{'inS':>7}{'HOLD':>7}{'DD%':>6}{'ret%':>7}" f"{'anni+':>7}{'corrXS':>8}{'corrTP':>8}{'turn/y':>8}") for r in sorted(rows, key=lambda r: -r["hold"])[:3]: print(f" {xv.tag(r['cfg']):<18}{r['full']:>7.2f}{r['insample']:>7.2f}{r['hold']:>7.2f}" f"{r['dd']*100:>6.0f}{r['ret']*100:>+7.0f}{r['pct']*100:>6.0f}%" f"{r['corrXS']:>+8.2f}{r['corrTP']:>+8.2f}{r['turn']:>8.0f}") print(f"\n TRIAL TOTALI testati (per deflated-Sharpe): {len([s for s in all_sr if np.isfinite(s)])}") # ---- base portafoglio una sola volta (Sleeve cached, riusati per ogni candidato) ---- base = xv.active_sleeves() pf0 = xv.StrategyPortfolio(base); pf0.backtest() bf = metrics(pf0.combined_daily()); bh = metrics(pf0.combined_daily(lo=HOLDOUT)) # ---- analisi per meccanismo ---- # CANDIDATO GIUDICATO = robust_candidate (edge in-sample>=0.5 E HOLD>0, best bilanciato): evita la # trappola del best-by-HOLD nudo (che premia config negative in-sample = overfit alla finestra OOS). # Si riportano comunque, per trasparenza/anti-cherry, anche il naive best-HOLD e il best-inSAMPLE. summary = [] chosen_daily = {} # mech -> serie del candidato giudicato (per corr-matrix) for mn in MECHS: rows = rows_by_mech[mn] print("\n" + "=" * 104) print(f" MECCANISMO {mn}") print("=" * 104) if not rows: print(" nessuna config valida -> SCARTATO") summary.append((mn, "SCARTATO", "nessuna config valida", None)) continue naive_hold = max(rows, key=lambda r: r["hold"]) best_is = max(rows, key=lambda r: (r["insample"] if np.isfinite(r["insample"]) else -9)) cand = robust_candidate(rows) has_isedge = cand is not None print(f" naive best-HOLD : [{naive_hold['uni']}] {xv.tag(naive_hold['cfg']):<16} " f"FULL {naive_hold['full']:+.2f} inS {naive_hold['insample']:+.2f} HOLD {naive_hold['hold']:+.2f}" f" {'(in-sample NEGATIVO -> holdout-fit)' if naive_hold['insample'] < INSAMPLE_EDGE else ''}") print(f" best-inSAMPLE : [{best_is['uni']}] {xv.tag(best_is['cfg']):<16} " f"FULL {best_is['full']:+.2f} inS {best_is['insample']:+.2f} HOLD {best_is['hold']:+.2f}" f" (anti-selection-on-holdout)") if not has_isedge: print(f" CANDIDATO GIUDICATO: NESSUNA config con in-sample>=0.5 E HOLD>0 " f"-> il meccanismo NON ha edge reale (ogni HOLD alto e' selezione-su-holdout).") verd, why = verdict(naive_hold, float("nan"), True, None, False) print(f"\n >>> VERDETTO {mn}: {verd} — {why}." + (f" tesi: {AMIHUD_THESIS[mn]}" if mn in AMIHUD_THESIS else "")) summary.append((mn, verd, why, dict(uni=naive_hold["uni"], cfg=xv.tag(naive_hold["cfg"]), full=naive_hold["full"], hold=naive_hold["hold"], dd=naive_hold["dd"], dsr=float("nan"), corrXS=naive_hold["corrXS"], up=None, isedge=False))) continue daily = cand["daily"] chosen_daily[mn] = daily f, h, pct = xv.evalcfg(daily) dsr, sr0 = xv.deflated_sharpe(f["sharpe"], all_sr, daily) caus = xv.causality_prefix_check(*mats[cand["uni"]], builders[(cand["uni"], mn)], cand["cfg"]) ups, up_best = uplift_for(daily, base, bf, bh) print(f" CANDIDATO GIUDICATO (in-sample>=0.5 & HOLD>0, best bilanciato):") print(f" [{cand['uni']}] {xv.tag(cand['cfg']):<16} FULL {cand['full']:+.2f} " f"inSAMPLE {cand['insample']:+.2f} HOLD {cand['hold']:+.2f} (in-sample EDGE = OK)") print(f" standalone: DD {f['maxdd']*100:.0f}% ret {f['ret']*100:+.0f}% " f"anni+ {pct*100:.0f}% turnover/y {cand['turn']:.0f}") print(f" corr vs XS01 {cand['corrXS']:+.2f} | corr vs TP01 {cand['corrTP']:+.2f}") 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_sr if np.isfinite(s)])} trial): {dsr:.3f}" f" | soglia Sharpe-max-null annualizz. {sr0:.2f} (serve DSR>0.95)") print(f" per-anno: {per_year(daily)}") print(f" UPLIFT portafoglio (base FULL {bf['sharpe']:.2f} / HOLD {bh['sharpe']:.2f}):") for fr, (cf, ch, wgt) in ups.items(): print(f" +cand @{wgt*100:>4.1f}% FULL {cf['sharpe']:.2f} ({cf['sharpe']-bf['sharpe']:+.2f})" f" DD {cf['maxdd']*100:.0f}% | HOLD {ch['sharpe']:.2f} ({ch['sharpe']-bh['sharpe']:+.2f})") verd, why = verdict(cand, dsr, caus["ok"], up_best, has_isedge) extra = f" tesi: {AMIHUD_THESIS[mn]}" if mn in AMIHUD_THESIS else "" print(f"\n >>> VERDETTO {mn}: {verd} — {why}.{extra}") summary.append((mn, verd, why, dict(uni=cand["uni"], cfg=xv.tag(cand["cfg"]), full=cand["full"], hold=cand["hold"], dd=f["maxdd"], dsr=dsr, corrXS=cand["corrXS"], up=up_best, isedge=True))) # ---- redundancy check: i 3 'low-risk cousins' sono UNA scommessa o TRE? ---- if len(chosen_daily) >= 2: print("\n" + "=" * 104) print(" RIDONDANZA — correlazione tra i candidati giudicati (sono lo stesso bet 'evita-speculativo'?)") print("=" * 104) names = list(chosen_daily.keys()) print(" " + "".join(f"{n[:8]:>10}" for n in names)) for a in names: line = f" {a[:8]:<10}" for b in names: line += f"{xv._corr(chosen_daily[a], chosen_daily[b]):>10.2f}" print(line) print(" NB: corr alta tra i candidati = sono la stessa anomalia low-risk in tre vesti, non tre edge.") # ---- AMIHUD: scegli il segno con tesi economica + edge ---- print("\n" + "=" * 104) print(" AMIHUD — scelta del segno (tesi economica + edge)") print("=" * 104) a_ill = next((s for s in summary if s[0] == "AMIHUD_ILLIQ"), None) a_liq = next((s for s in summary if s[0] == "AMIHUD_LIQ"), None) for s in (a_ill, a_liq): if s and s[3]: print(f" {s[0]:<14} FULL {s[3]['full']:+.2f} HOLD {s[3]['hold']:+.2f} " f"DSR {s[3]['dsr']:.2f} corrXS {s[3]['corrXS']:+.2f} -> {s[1]} ({AMIHUD_THESIS[s[0]]})") cand_signs = [s for s in (a_ill, a_liq) if s and s[3] and s[3]["full"] > 0 and s[3]["hold"] > 0] if cand_signs: win = max(cand_signs, key=lambda s: 0.5 * (s[3]["full"] + s[3]["hold"])) print(f" -> segno con edge+tesi: {win[0]} ({AMIHUD_THESIS[win[0]]})") else: print(" -> NESSUN segno mostra edge positivo (full>0 e hold>0): AMIHUD SCARTATO in entrambi i versi.") # ---- sintesi finale ---- print("\n" + "=" * 104) print(" SINTESI FINALE — c'e' un sopravvissuto reale?") print("=" * 104) for mn, verd, why, info in summary: if info: print(f" {mn:<14} {verd:<22} FULL {info['full']:+.2f} HOLD {info['hold']:+.2f} " f"DSR {info['dsr']:.2f} corrXS {info['corrXS']:+.2f} upliftBest {info['up'] if info['up'] is not None else float('nan'):+.2f}") else: print(f" {mn:<14} {verd}") survivors = [s for s in summary if s[1] == "SLEEVE-CANDIDATE"] leads = [s for s in summary if s[1] == "LEAD-forward-monitor"] if survivors: print(f"\n SOPRAVVISSUTO: {', '.join(s[0] for s in survivors)} (sleeve-candidate, comunque STAT-MODE).") elif leads: print(f"\n Nessuno sleeve-candidate. LEAD da forward-monitor: {', '.join(s[0] for s in leads)}.") else: print("\n NESSUN sopravvissuto: tutti DEBOLE/SCARTATO. Risultato valido (la maggior parte muore).") 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' solo lavoro statistico (vincoli del filone C).") if __name__ == "__main__": main()