#!/usr/bin/env python """r0726_xsr_equity — T3 dell'ondata 2026-07-26: XSR01 regge fuori dal crypto e fuori dai 2.6 anni monoregime? Test di FALSIFICAZIONE su 26+ anni di ETF settoriali. PERCHE'. XSR01 (candidato in forward-monitor dal 25/07, gate pre-registrato al 2026-10-23) ha superato tutto quello che gli e' stato chiesto — marginale ADDS, deflated-Sharpe 0.985, corr ~0 a tutti e 5 gli sleeve, tre null a fee-neutrale — MA la sua debolezza numero uno e' dichiarata: "storia 2.6 anni MONOREGIME e CRESCENTE (Sharpe 2024 1.03 / 2025 1.98 / 2026 3.11): l'edge e' recente." Con 2.6 anni non si distingue un meccanismo reale da un regime fortunato. L'unico modo di guadagnare potenza statistica senza aspettare anni e' portare il meccanismo CONGELATO su un pannello **diverso per asset class e lungo decenni**, e vedere se sopravvive. Se regge su 26 anni di settoriali US, l'ipotesi "e' un artefatto del bear alt 2024-2026" e' molto piu' difficile da sostenere. Se non regge, il gate del 23/10 deve saperlo. NON E' IL TEST DEL 25/07. `r0725_statarb_eq.py` ha gia' portato il meccanismo sulle azioni ed e' stato SCARTATO (paniere Sharpe -1.00 netta, -0.17 lorda) — ma quello era il meccanismo a **COPPIE** (12 coppie a priori, ampiezza effettiva 9.9). XSR01 non e' un paniere di coppie: e' la versione **DEMEANATA cross-sezionalmente**, ed e' proprio il demeaning che ha cambiato tutto (ampiezza effettiva 4.5 -> 37.4) annullando algebricamente la gamba comune. Testare il demean su equity e' quindi una domanda DIVERSA, mai posta. Il risultato lordo del 25/07 (-0.17, cioe' il residuo REVERTE debolmente sulle azioni mentre CONTINUA sul crypto) e' l'ipotesi nulla di questo test. DISEGNO * meccanismo CONGELATO importato da r0725_statarb_multi (W=45, sgn=+1, residuo OLS causale su un fattore comune, z-score, tanh, vol-target 20%, cap 2x) — nessuna reimplementazione; * demeaning cross-sezionale giornaliero, identico a r0725_statarb_basket_gate; * fattore comune = **SPY** (analogo di BTC: il fattore che il demeaning deve annullare); * due universi, entrambi a priori: SECT9 = i 9 settoriali SPDR classici (1998+, ~26 anni) — cross-section OMOGENEA, il parallelo piu' stretto degli alt (un solo fattore comune forte); ALL28 = tutti gli ETF su disco tranne SPY — parallelo del "ALL50" di XSR01; * split NON aggiustati riparati in lettura (eqlib.load_eq -> src/data/eq_splits) — senza questo IWM/EFA avvelenerebbero il pannello (difetto trovato il 25/07); * LORDO **e** netto: la lettura del 25/07 e' che sulle azioni il segno vive nel lordo e il turnover se lo mangia. Un venue a PAVIMENTO FISSO (IB) e' anche peggio di una fee proporzionale a questa taglia (lezione 25/07) -> il netto proporzionale e' gia' generoso; * null di permutazione cross-sezionale **A FEE ZERO** (lezione XSR01: permutare un segnale ne fa esplodere il turnover, quindi un null a fee piena perderebbe per COSTO invece che per assenza d'informazione, e regalerebbe un p-value trionfale e falso); * decomposizione per DECENNIO: 26 anni servono a questo, non a fare una media unica. Uso: `uv run python scripts/research/r0726_xsr_equity.py` """ from __future__ import annotations import sys from pathlib import Path import numpy as np import pandas as pd ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT / "scripts" / "research")) from eqlib import SECTORS_CLASSIC, load_eq # noqa: E402 from r0725_statarb_multi import (FEE_LEG, SGN, W, _dd, _sh, signal) # noqa: E402 RAW = ROOT / "data" / "raw" BASE = "SPY" MIN_BARS = 400 N_PERM = 300 BLOCK = 20 SEED = 20260726 ANN = np.sqrt(252.0) # ⚠️ giorni di BORSA, non 365 (lezione 25/07 sull'annualizzazione) def all_symbols() -> list[str]: out = [] for p in sorted(RAW.glob("eq_*_1d.parquet")): s = p.stem.replace("eq_", "").replace("_1d", "").upper() if s != BASE: out.append(s) return out def _px(sym: str) -> pd.Series: s = load_eq(sym)["close"].astype(float) return s[~s.index.duplicated(keep="last")].sort_index() def build(cols: list[str]) -> tuple[pd.DataFrame, pd.DataFrame]: """Posizioni e spread per ogni gamba, col meccanismo congelato vs SPY.""" base = _px(BASE) P, S = {}, {} for sym in cols: try: t = _px(sym) except FileNotFoundError: continue ix = base.index.intersection(t.index) if len(ix) < MIN_BARS: continue p, s = signal(base[ix], t[ix]) P[sym] = pd.Series(p, index=ix) S[sym] = pd.Series(s, index=ix) return (pd.concat(P, axis=1, sort=True).sort_index(), pd.concat(S, axis=1, sort=True).sort_index()) def pnl_fee(pos: np.ndarray, spread: np.ndarray, fee_leg: float) -> np.ndarray: held = np.concatenate([[0.0], pos[:-1]]) turn = np.abs(np.diff(held, prepend=0.0)) return held * spread - 2.0 * fee_leg * turn def basket(P: pd.DataFrame, S: pd.DataFrame, demean: bool, fee_leg: float) -> pd.Series: Q = P.sub(P.mean(axis=1), axis=0) if demean else P out = {c: pd.Series(pnl_fee(Q[c].to_numpy(float), S[c].to_numpy(float), fee_leg), index=Q.index) for c in Q.columns} return pd.concat(out, axis=1, sort=True).mean(axis=1, skipna=True).dropna() def eff_breadth(P: pd.DataFrame, S: pd.DataFrame, demean: bool) -> float: Q = P.sub(P.mean(axis=1), axis=0) if demean else P M = pd.concat({c: pd.Series(pnl_fee(Q[c].to_numpy(float), S[c].to_numpy(float), 0.0), index=Q.index) for c in Q.columns}, axis=1) C = M.corr().values n = C.shape[0] off = (C.sum() - np.trace(C)) / (n * (n - 1)) return float(n / (1.0 + (n - 1) * off)) def sh(r) -> float: r = np.asarray(r, float) r = r[np.isfinite(r)] return float(r.mean() / r.std() * ANN) if len(r) > 30 and r.std() > 0 else 0.0 def main() -> None: print("=" * 100) print(" T3 — XSR01 fuori dal crypto: il meccanismo congelato su 26 anni di ETF settoriali") print("=" * 100) print(f"\n meccanismo: W={W}, sgn={SGN:+d}, residuo OLS causale vs {BASE}, z-score, tanh,") print(" vol-target 20%, cap 2x, demean cross-sezionale giornaliero [CONGELATO, importato]") print(f" annualizzazione: sqrt(252) = giorni di BORSA (NON 365)") universes = { "SECT9 9 settoriali SPDR (1998+)": SECTORS_CLASSIC, "ALL28 tutti gli ETF su disco": all_symbols(), } store = {} for name, cols in universes.items(): P, S = build(cols) store[name] = (P, S) print("\n" + "-" * 100) print(f" {name} — {P.shape[1]} gambe, {len(P)} barre, " f"{P.index[0].date()} -> {P.index[-1].date()}") print("-" * 100) print(f" {'variante':>28} {'ampiezza eff.':>14} {'Sharpe LORDA':>13} " f"{'Sharpe NETTA':>13} {'maxDD':>8} {'ret/anno':>9}") for lab, dm in (("coppie (no demean)", False), ("DEMEAN (= XSR01)", True)): g = basket(P, S, dm, 0.0) n = basket(P, S, dm, FEE_LEG) eb = eff_breadth(P, S, dm) cagr = float(np.nanmean(n.values)) * 252.0 print(f" {lab:>28} {eb:>14.1f} {sh(g):>13.2f} {sh(n):>13.2f} " f"{_dd(n.values):>7.1%} {cagr:>8.1%}") # ------------------------------------------------------------------ per decennio print("\n" + "-" * 100) print(" A. PER DECENNIO — 26 anni servono a questo (variante DEMEAN, la stessa di XSR01)") print("-" * 100) print(f" {'universo':>34} {'periodo':>14} {'anni':>5} {'Sh LORDA':>9} {'Sh NETTA':>9} {'giorni':>7}") for name, (P, S) in store.items(): g = basket(P, S, True, 0.0) n = basket(P, S, True, FEE_LEG) for lo, hi in (("1998", "2005"), ("2005", "2012"), ("2012", "2019"), ("2019", "2027")): kg = (g.index >= lo) & (g.index < hi) kn = (n.index >= lo) & (n.index < hi) if kg.sum() < 250: continue print(f" {name.split()[0]:>34} {lo + '-' + hi:>14} {kg.sum() / 252:>5.1f} " f"{sh(g[kg]):>9.2f} {sh(n[kn]):>9.2f} {int(kg.sum()):>7d}") # ------------------------------------------------------------------ null a fee ZERO print("\n" + "-" * 100) print(" B. NULL DI PERMUTAZIONE CROSS-SEZIONALE — a FEE ZERO (lezione XSR01)") print("-" * 100) print(" Permutare un segnale ne fa esplodere il turnover: a fee piena il null perderebbe") print(" per COSTO invece che per assenza d'informazione -> p-value trionfale e falso.") rng = np.random.default_rng(SEED) print(f"\n {'universo':>34} {'candidato LORDO':>16} {'null medio':>11} {'null p95':>9} " f"{'null max':>9} {'p-value':>8}") for name, (P, S) in store.items(): cand = sh(basket(P, S, True, 0.0)) Pv = P.to_numpy(float) draws = [] for _ in range(N_PERM): idx = rng.permutation(Pv.shape[1]) Pp = pd.DataFrame(Pv[:, idx], index=P.index, columns=P.columns) draws.append(sh(basket(Pp, S, True, 0.0))) d = np.array(draws) pval = float((d >= cand).mean()) print(f" {name.split()[0]:>34} {cand:>16.2f} {d.mean():>11.2f} " f"{np.percentile(d, 95):>9.2f} {d.max():>9.2f} {pval:>8.3f}") # ------------------------------------------------------------------ confronto col crypto print("\n" + "-" * 100) print(" C. CONFRONTO DIRETTO col candidato crypto (numeri del 25/07, non ricalcolati)") print("-" * 100) print(" XSR01 (50 alt HL, 2.6 anni): ampiezza eff. 37.4 | LORDA 2.70 | NETTA 1.82 | DD -2.6%") print(" STATARB-EQ a COPPIE (25/07): ampiezza eff. 9.9 | LORDA -0.17 | NETTA -1.00") print("\n La domanda del test: il DEMEAN — che sul crypto ha fatto 4.5 -> 37.4 di ampiezza e") print(" ha creato l'edge — fa la stessa cosa sulle azioni, o li' il residuo resta reverting?") print("\n" + "=" * 100) if __name__ == "__main__": main()