From 9af6693e4f08f027fe3c36ff997cd69a5d5298e0 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Sat, 25 Jul 2026 09:47:47 +0000 Subject: [PATCH] =?UTF-8?q?research:=20MAT01=20e=20generalizzazione=20STAT?= =?UTF-8?q?ARB=20=E2=80=94=202=20refutazioni=20pulite=20+=20addendum=20al?= =?UTF-8?q?=20gate=2027/09?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MAT01 (r0725_mat01_multiasset_trend.py, r0725_mat01b_regime.py) — SCARTATO. Allargare il trend difensivo da 6 a 18 ETF multi-asset-class a meccanismo GTAA CONGELATO: MAT18 perde 3/4 finestre disgiunte e, a PARI VOLATILITA', il suo minor drawdown si INVERTE (-20.6% vs -15.4%) -> era solo de-levering. Terza occorrenza del null de-levering dopo VRP-DD e TP01xDVOL. Corr->crypto invariata (0.110 vs 0.116): zero guadagno di diversificazione. Risultato positivo conservato: la curva d'ampiezza e' monotona (mediana OOS 0.44 a k=1 -> 0.80 a k=18, DD -16.0% -> -6.9%, satura a k~10-12), ma GTAA6 sta gia' al 95° pctl dei 6-subset casuali — e non e' cherry-picked (contiene TLT, il peggiore dei 18) -> niente piu' ampiezza da raccogliere su quello sleeve. STATARB-MULTI (r0725_statarb_multi.py) — il meccanismo congelato (W=45, sgn=+1) su tutte le 50 coppie alt/BTC, out-of-pair-sample. REGGE: 82% Sharpe>0, 0/50 degeneri (mono 53%), perm p<0.05 nel 18% (atteso 5%). E ETH/BTC e' al rango 22/50 (58° pctl): la coppia scopritrice NON e' un outlier -> per il gate 27/09 l'ipotesi "fortuna di una coppia" e' refutata. NON e' uno sleeve: ampiezza effettiva ~4.5 (corr media 0.204, gamba BTC condivisa), paniere IC95% [-0.12, 1.72] con t 1.31. Errore di metodo corretto in-sessione: il primo null statico usava sign(mean(segnale)) sull'intero campione = look-ahead; rifatto causale + a priori. STATARB-EQ (r0725_statarb_eq.py) — SCARTATO. Stesso meccanismo su 12 coppie ETF a priori, 28.5 anni, ampiezza effettiva 9.9: paniere Sharpe -1.00 (t -5.33), negativo in 4/4 decadi. Ma la lettura sta nel LORDO (-0.17, non -1.00): due terzi sono drag di turnover -> non esiste strategia specchio. Il segno lordo dice che sulle azioni il residuo REVERTE mentre sul crypto CONTINUA: meccanismo plausibilmente crypto-specifico. Il gate 27/09 non puo' appoggiarsi all'argomento "fenomeno universale". r0724_statarb_deploy_gate.py: addendum PRE-REGISTRATO oggi (forward-day 26 di 90, 64 giorni prima della decisione) — riporta anche lo Sharpe della statica "sempre short ETH vs BTC" a pari vol-target sulla stessa finestra. Le soglie del 2026-07-24 NON sono toccate: e' diagnostica non binding. Lettura odierna (26 barre): STATARB +5.70 vs benchmark -4.98, posizione corrente LONG lo spread = opposta alla statica. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/research/r0724_statarb_deploy_gate.py | 61 ++++ .../research/r0725_mat01_multiasset_trend.py | 301 +++++++++++++++++ scripts/research/r0725_mat01b_regime.py | 179 ++++++++++ scripts/research/r0725_statarb_eq.py | 243 ++++++++++++++ scripts/research/r0725_statarb_multi.py | 317 ++++++++++++++++++ 5 files changed, 1101 insertions(+) create mode 100644 scripts/research/r0725_mat01_multiasset_trend.py create mode 100644 scripts/research/r0725_mat01b_regime.py create mode 100644 scripts/research/r0725_statarb_eq.py create mode 100644 scripts/research/r0725_statarb_multi.py diff --git a/scripts/research/r0724_statarb_deploy_gate.py b/scripts/research/r0724_statarb_deploy_gate.py index 6b324d1..ecae378 100644 --- a/scripts/research/r0724_statarb_deploy_gate.py +++ b/scripts/research/r0724_statarb_deploy_gate.py @@ -20,6 +20,23 @@ REGOLA (immutabile — ogni modifica va motivata nel diario come violazione): * config invariata (W=45, sgn=+1) — ogni ritocco azzera la finestra; * maxDD forward < 10% (coerenza col profilo di ricerca, DD backtest ~11%). +ADDENDUM PRE-REGISTRATO IL 2026-07-25 (forward-day 26 di 90, 64 giorni PRIMA della decisione — +quindi pre-registrazione vera, non selezione a posteriori). Motivo: il test out-of-pair-sample +`r0725_statarb_multi.py` ha applicato il meccanismo congelato alle 50 coppie alt/BTC di Hyperliquid +e ha trovato che, su 60% delle coppie, una posizione STATICA "sempre short alt vs BTC" a pari +vol-target batte il segnale (uplift medio -0.11). Su ETH/BTC il pareggio e' quasi esatto: +Sharpe segnale 0.46 vs statica-short 0.48 sulla finestra HL 2024-2026. Tradotto: una parte di cio' +che STATARB-RESID guadagna potrebbe essere il beta di regime "ETH sottoperforma BTC", non timing. + +Si aggiunge quindi UNA misura da riportare alla decisione: lo Sharpe, sulla STESSA finestra +forward, della statica sempre-short a pari vol-target (BENCH_SHORT). Non e' una soglia binding: +le soglie pre-registrate il 2026-07-24 (Sharpe>=0.5, DD<10%, haircut<0.5pp) restano quelle e +NON sono state toccate. E' una diagnostica obbligatoria da leggere insieme, e la decisione se +renderla binding spetta esplicitamente all'operatore, non allo script. +NB confortante gia' oggi: la posizione forward corrente e' LONG lo spread (+0.61 = long ETH / +short BTC), cioe' OPPOSTA alla scommessa statica -> la finestra forward sta testando il segnale +in un modo che la statica non spiega. + Uso: `uv run python scripts/research/r0724_statarb_deploy_gate.py` (si puo' lanciare in qualsiasi momento: ricorda la data-decisione e valuta lo stato). """ @@ -42,6 +59,38 @@ HAIRCUT_MAX_PP = 0.5 DD_MAX = 0.10 +def _bench_static_short(rows: list[dict]) -> tuple[float, int] | None: + """Addendum 2026-07-25: Sharpe di una posizione STATICA short-spread (short ETH / long BTC) + a pari vol-target, sulla STESSA finestra forward. Stessa meccanica di costo del monitor + (fee su 2 gambe per |delta pos|), ma qui il turnover viene solo dal vol-target.""" + import sys as _sys + from pathlib import Path as _P + _sys.path.insert(0, str(_P(__file__).resolve().parents[2])) + _sys.path.insert(0, str(_P(__file__).resolve().parents[1] / "research")) + try: + import pandas as pd + from orthogonal_signals import build_joint, spread_ret + except Exception: + return None + try: + j = build_joint("1d") + except Exception: + return None + ts = {int(r["ts"]) for r in rows} + s = spread_ret(j) + vol = pd.Series(s).rolling(30, min_periods=30).std().values * np.sqrt(365) + scal = np.where((vol > 0) & np.isfinite(vol), 0.20 / vol, 0.0) + pos = np.clip(-1.0 * np.nan_to_num(scal), -2.0, 2.0) # SEMPRE short lo spread + held = np.concatenate([[0.0], pos[:-1]]) + net = held * s - 2.0 * 0.0005 * np.abs(np.diff(held, prepend=0.0)) + mask = np.array([int(t) in ts for t in j["timestamp"].values]) + r = net[mask] + r = r[np.isfinite(r)] + if len(r) < 10 or r.std() == 0: + return None + return float(r.mean() / r.std() * np.sqrt(365)), int(len(r)) + + def main() -> None: rows = [json.loads(x) for x in RETURNS.read_text().splitlines() if x.strip()] rm = np.array([r["net_modeled"] for r in rows]) @@ -61,6 +110,18 @@ def main() -> None: print(f" maxDD fwd : {dd:.1%} (guardia < {DD_MAX:.0%})") print(f" fill-haircut cum : {haircut_pp:.2f} pp (guardia < {HAIRCUT_MAX_PP} pp)") print(f" data decisione : {DECISION} (proroga unica: {DECISION_EXT})") + + bench = _bench_static_short(rows) + if bench is not None: + sh_b, n_b = bench + print(f"\n [addendum 2026-07-25, diagnostico NON binding]") + print(f" benchmark statico 'sempre short ETH vs BTC' a pari vol-target, stessa finestra:") + print(f" Sharpe benchmark {sh_b:+.2f} su {n_b} barre -> STATARB {sh:+.2f}, " + f"differenza {sh - sh_b:+.2f}") + print(" se STATARB non batte questo benchmark, il suo guadagno e' beta di regime") + print(" (ETH sottoperforma BTC), non timing. Da leggere PRIMA di deliberare il deploy.") + else: + print("\n [addendum: benchmark statico non calcolabile — dati BTC/ETH non disponibili]") if today < DECISION: print(f"\n -> NESSUNA DECISIONE OGGI ({today}): mancano {(DECISION - today).days} giorni.") print(" Il numero corrente NON autorizza deploy anticipato (regola pre-registrata).") diff --git a/scripts/research/r0725_mat01_multiasset_trend.py b/scripts/research/r0725_mat01_multiasset_trend.py new file mode 100644 index 0000000..073a66a --- /dev/null +++ b/scripts/research/r0725_mat01_multiasset_trend.py @@ -0,0 +1,301 @@ +"""MAT01 — il trend difensivo GTAA su un universo MULTI-ASSET-CLASS (18 ETF) invece di 6. + +DOMANDA (una sola, pre-registrata): il valore dello sleeve GTAA01 aumenta allargando l'universo +dalle 6 gambe attuali (SPY QQQ IWM TLT GLD HYG — tutte US, 4 su 6 azionarie) a un paniere che +copre le classi d'attivo vere (azioni US / internazionali / emergenti, bond governativi / credito +IG / HY, commodity, real estate)? + +PERCHE' E' UN FILONE NUOVO E NON UN RE-TEST: il time-series momentum multi-asset-class e' +l'anomalia meglio documentata e replicata della finanza quantitativa (Moskowitz-Ooi-Pedersen 2012; +repliche su 100+ anni), e il suo motore dichiarato NON e' la previsione ma la DIVERSIFICAZIONE fra +classi con drawdown decorrelati. In questo progetto il trend e' stato testato solo dentro una +classe alla volta (crypto = TP01/trend-multiasset; equity US = GTAA01; settori = XS equity). I 12 +ETF non-US / bond / commodity sono su disco CERTIFICATI dal 2026-06-22 ma sono stati usati SOLO +per il test lead-lag crypto->X (diario 2026-06-23-crossmarket-beyond-sp500), mai per un programma +di trend. Questo e' il vuoto. + +DISCIPLINA (la parte che conta): + * Il MECCANISMO E' CONGELATO. Si importa `_exposure` da src/portfolio/gtaa.py senza toccarlo: + orizzonti (21,63,126,252), vol-target 12%, long-flat, fee 2bps/lato, EW sugli asset. Cambia + UNA SOLA COSA: l'insieme degli asset. Zero gradi di liberta' sui parametri => niente + ri-ottimizzazione mascherata da "espansione". + * L'universo ALL18 e' definito A PRIORI per classe d'attivo (vedi CLASSES), non scelto per + performance. A k=18 la liberta' di selezione e' ESATTAMENTE ZERO (esiste un solo sottoinsieme + di 18 elementi su 18) => il confronto ALL18 vs GTAA6 non e' selezionabile. + * Il rischio vero non e' l'universo scelto, e' che l'INCUMBENT sia stato cherry-picked. Quindi + si misura anche il percentile di GTAA6 fra i sottoinsiemi casuali di dimensione 6 (test D). + * Split IS/OOS = quello su cui GTAA01 fu validato (IS <=2014-12-31, OOS 2015+), indipendente + dall'hold-out crypto. + +TEST ESEGUITI + A) Standalone per asset (solo diagnostica, MAI selezione): Sharpe del trend gated asset per asset. + B) ALL18 vs GTAA6: FULL / IS / OOS su Sharpe, CAGR, volAnn, maxDD, corr fra i due. + C) CURVA DI AMPIEZZA (il test di meccanismo): per k=1..18, N sorteggi di sottoinsiemi casuali -> + distribuzione dello Sharpe OOS. Se il beneficio e' AMPIEZZA e non selezione, la mediana deve + salire con k in modo monotono, e ALL18 deve stare vicino alla mediana di k=18 (che e' se stesso). + D) L'incumbent e' cherry-picked? percentile di GTAA6 nella distribuzione dei 6-subset casuali. + E) Marginale sul BOOK: sostituire GTAA6 con MAT18 al peso invariato 20% nel book 5-sleeve, e + misurare Sharpe/DD FULL e HOLD-OUT del book. Nessun cambio di peso => `weights_tilt_null` + non si applica (non e' un tilt, e' una sostituzione a peso costante), ma il confronto e' 1:1. + F) Eseguibilita' a capitale piccolo: ticket minimo per gamba ai pesi correnti. + + uv run python scripts/research/r0725_mat01_multiasset_trend.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)) + +from src.portfolio.gtaa import _close, _exposure, EQ_UNIVERSE, FEE_SIDE # meccanismo CONGELATO + +ANN = np.sqrt(252.0) +OOS_START = pd.Timestamp("2015-01-01", tz="UTC") +SEED = 20260725 +N_DRAWS = 400 + +# --- universo A PRIORI, per classe d'attivo (nessuna scelta guidata dai risultati) --------------- +CLASSES = { + "equity_US": ["SPY", "QQQ", "IWM", "DIA"], + "equity_intl": ["EFA", "EWJ", "FXI"], + "equity_EM": ["EEM"], + "bond_gov": ["TLT", "IEF", "AGG"], + "bond_credit": ["LQD", "HYG"], + "commodity": ["GLD", "SLV", "DBC", "USO"], + "real_estate": ["VNQ"], +} +ALL18 = [s for v in CLASSES.values() for s in v] +GTAA6 = list(EQ_UNIVERSE) + + +# ---------------------------------------------------------------------------- metriche +def _sh(r: pd.Series) -> float: + r = r.dropna() + return float(r.mean() / r.std() * ANN) if len(r) > 20 and r.std() > 0 else 0.0 + + +def _dd(r: pd.Series) -> float: + eq = (1.0 + r.fillna(0.0)).cumprod() + return float((eq / eq.cummax() - 1.0).min()) + + +def _cagr(r: pd.Series) -> float: + r = r.dropna() + if len(r) < 50: + return 0.0 + eq = float((1.0 + r).prod()) + yrs = len(r) / 252.0 + return float(eq ** (1.0 / yrs) - 1.0) if eq > 0 else -1.0 + + +def stats(r: pd.Series, label: str) -> dict: + return { + "label": label, "n": int(r.dropna().shape[0]), + "sharpe": _sh(r), "cagr": _cagr(r), "vol": float(r.std() * ANN), "maxdd": _dd(r), + } + + +def fmt(s: dict) -> str: + return (f"{s['label']:<22} n={s['n']:>5} Sh {s['sharpe']:>6.2f} CAGR {s['cagr']*100:>6.2f}% " + f"vol {s['vol']*100:>5.2f}% maxDD {s['maxdd']*100:>6.2f}%") + + +# ---------------------------------------------------------------------------- motore (congelato) +_CACHE: dict[str, pd.Series] = {} + + +def gated(sym: str) -> pd.Series: + """Rendimenti netti daily del trend long-flat su `sym` — IDENTICO a gtaa._gated_returns.""" + if sym in _CACHE: + return _CACHE[sym] + close = _close(sym) + ex = _exposure(close) + ret = close.pct_change().fillna(0.0).values + held = np.zeros(len(ex)) + held[1:] = ex.values[:-1] # causale: esposizione decisa a i-1, tenuta in i + net = held * ret - FEE_SIDE * np.abs(np.diff(held, prepend=0.0)) + out = pd.Series(net, index=close.index) + _CACHE[sym] = out + return out + + +def basket(universe, end: pd.Timestamp | None = None) -> pd.Series: + """EW dei rendimenti trend-gated sugli asset disponibili (outer, skipna) — come gtaa_returns.""" + cols = {a: gated(a) for a in universe} + r = pd.concat(cols, axis=1, sort=True).sort_index().mean(axis=1, skipna=True).dropna() + return r[r.index <= end] if end is not None else r + + +def split(r: pd.Series): + return r[r.index < OOS_START], r[r.index >= OOS_START] + + +# ---------------------------------------------------------------------------- main +def main() -> None: + print("=" * 100) + print(" MAT01 — trend difensivo su universo MULTI-ASSET-CLASS (18 ETF) vs GTAA6 attuale") + print(" meccanismo CONGELATO da src/portfolio/gtaa.py (21/63/126/252, vt 12%, long-flat, EW)") + print("=" * 100) + + # end comune: i 12 ETF non-GTAA sono rinfrescati solo on-demand -> tronco al min dei last date + ends = {s: _close(s).index[-1] for s in ALL18} + end = min(ends.values()) + print(f"\n finestra comune troncata a {end.date()} " + f"(i 12 ETF fuori-GTAA sono rinfrescati on-demand, non nel cron giornaliero)") + print(f" universo a priori ({len(ALL18)}):") + for cls, syms in CLASSES.items(): + print(f" {cls:<14} {', '.join(syms)}") + + # ---------------- A) standalone per asset (DIAGNOSTICA, non selezione) + print("\n" + "-" * 100) + print(" (A) TREND STANDALONE PER ASSET — diagnostica pura, nessuna selezione su questi numeri") + print("-" * 100) + print(f" {'sym':<6}{'classe':<14}{'da':<12}{'Sh FULL':>9}{'Sh IS':>9}{'Sh OOS':>9}{'CAGR':>9}{'maxDD':>9}") + cls_of = {s: c for c, v in CLASSES.items() for s in v} + rows = [] + for s in ALL18: + r = gated(s) + r = r[r.index <= end] + ins, oos = split(r) + rows.append((s, _sh(r), _sh(ins), _sh(oos))) + print(f" {s:<6}{cls_of[s]:<14}{str(r.index[0].date()):<12}{_sh(r):>9.2f}{_sh(ins):>9.2f}" + f"{_sh(oos):>9.2f}{_cagr(r)*100:>8.1f}%{_dd(r)*100:>8.1f}%") + n_pos_oos = sum(1 for _, _, _, so in rows if so > 0) + print(f"\n asset con Sharpe OOS > 0: {n_pos_oos}/{len(rows)} " + f"(mediana OOS {np.median([so for *_ , so in rows]):.2f})") + + # ---------------- B) ALL18 vs GTAA6 + print("\n" + "-" * 100) + print(" (B) ALL18 vs GTAA6 — stesso meccanismo, stesso periodo, cambia solo l'universo") + print("-" * 100) + r6, r18 = basket(GTAA6, end), basket(ALL18, end) + common = r6.index.intersection(r18.index) + r6, r18 = r6[common], r18[common] + for nm, r in (("GTAA6 (incumbent)", r6), ("MAT18 (candidato)", r18)): + i, o = split(r) + print(f" {fmt(stats(r, nm + ' FULL'))}") + print(f" {fmt(stats(i, nm + ' IS<2015'))}") + print(f" {fmt(stats(o, nm + ' OOS2015+'))}") + print() + i6, o6 = split(r6) + i18, o18 = split(r18) + print(f" corr(GTAA6, MAT18) FULL {r6.corr(r18):>5.2f} OOS {o6.corr(o18):>5.2f}") + print(f" DELTA OOS Sharpe {_sh(o18)-_sh(o6):+.2f} maxDD {(_dd(o18)-_dd(o6))*100:+.1f}pp " + f"CAGR {(_cagr(o18)-_cagr(o6))*100:+.2f}pp") + print(f" DELTA IS Sharpe {_sh(i18)-_sh(i6):+.2f} maxDD {(_dd(i18)-_dd(i6))*100:+.1f}pp") + + # per anno OOS + print("\n Sharpe per anno (OOS 2015+):") + yrs = sorted(set(o6.index.year)) + print(" anno " + "".join(f"{y:>7}" for y in yrs)) + print(" GTAA6 " + "".join(f"{_sh(o6[o6.index.year == y]):>7.2f}" for y in yrs)) + print(" MAT18 " + "".join(f"{_sh(o18[o18.index.year == y]):>7.2f}" for y in yrs)) + wins = sum(1 for y in yrs if _sh(o18[o18.index.year == y]) > _sh(o6[o6.index.year == y])) + print(f" -> MAT18 batte GTAA6 in {wins}/{len(yrs)} anni OOS") + + # ---------------- C) curva di ampiezza + print("\n" + "-" * 100) + print(f" (C) CURVA DI AMPIEZZA — {N_DRAWS} sottoinsiemi casuali per k, Sharpe OOS 2015+") + print(" se il motore e' AMPIEZZA (diversificazione) la mediana sale con k; se e' selezione, no") + print("-" * 100) + rng = np.random.default_rng(SEED) + print(f" {'k':>3}{'mediana':>10}{'p10':>8}{'p90':>8}{'medDD':>9}{'frac Sh>0':>11}") + curve = {} + for k in range(1, len(ALL18) + 1): + shs, dds = [], [] + n = 1 if k == len(ALL18) else N_DRAWS + for _ in range(n): + sub = list(rng.choice(ALL18, size=k, replace=False)) if k < len(ALL18) else ALL18 + r = basket(sub, end) + _, o = split(r) + shs.append(_sh(o)) + dds.append(_dd(o)) + curve[k] = (float(np.median(shs)), float(np.percentile(shs, 10)), + float(np.percentile(shs, 90)), float(np.median(dds)), + float(np.mean(np.array(shs) > 0))) + m, p10, p90, mdd, fp = curve[k] + print(f" {k:>3}{m:>10.2f}{p10:>8.2f}{p90:>8.2f}{mdd*100:>8.1f}%{fp*100:>10.0f}%") + ks = sorted(curve) + med = [curve[k][0] for k in ks] + mono = int(np.sum(np.diff(med) > 0)) + print(f"\n monotonia: {mono}/{len(med)-1} passi di k con mediana in salita " + f"(k=1 {med[0]:.2f} -> k=18 {med[-1]:.2f}, delta {med[-1]-med[0]:+.2f})") + print(f" DD mediano: k=1 {curve[1][3]*100:.1f}% -> k=18 {curve[18][3]*100:.1f}%") + + # ---------------- D) l'incumbent e' cherry-picked? + print("\n" + "-" * 100) + print(" (D) GTAA6 e' un 6-subset SPECIALE? (se lo fosse, il confronto B sarebbe truccato a suo favore)") + print("-" * 100) + rng = np.random.default_rng(SEED + 1) + sh6 = [] + for _ in range(N_DRAWS): + sub = list(rng.choice(ALL18, size=6, replace=False)) + _, o = split(basket(sub, end)) + sh6.append(_sh(o)) + sh6 = np.array(sh6) + pct = float((sh6 < _sh(o6)).mean()) + print(f" Sharpe OOS GTAA6 = {_sh(o6):.2f} percentile fra {N_DRAWS} 6-subset casuali = {pct*100:.0f}°") + print(f" distribuzione 6-subset: mediana {np.median(sh6):.2f} p10 {np.percentile(sh6,10):.2f} " + f"p90 {np.percentile(sh6,90):.2f}") + print(" lettura: >85° = incumbent fortunato (il confronto B lo favorisce); ~50° = confronto equo.") + + # ---------------- E) marginale sul book + print("\n" + "-" * 100) + print(" (E) MARGINALE SUL BOOK 5-SLEEVE — sostituzione a peso INVARIATO (20%), non un tilt") + print("-" * 100) + from src.portfolio.sleeves import active_sleeves, GTAA_BOOK_ACTIVATION + from src.portfolio.portfolio import Sleeve, StrategyPortfolio + + def _book_shape(r: pd.Series) -> pd.Series: + """Stesso trattamento che sleeves._gtaa_daily_returns da' al GTAA nel book: + griglia daily piena (weekend/festivi = 0.0, capitale IB fermo) + attivazione all'era book.""" + s = r.dropna().sort_index() + if s.index.tz is None: + s.index = s.index.tz_localize("UTC") + days = pd.date_range(s.index.min().normalize(), s.index.max().normalize(), freq="1D", tz="UTC") + s = s.reindex(days).fillna(0.0) + return s[s.index >= GTAA_BOOK_ACTIVATION] + + sl = active_sleeves() + print(f" sleeve attivi: {[(s.name, round(s.weight, 3)) for s in sl]}") + swapped = [ + Sleeve(s.name + "→MAT18", s.weight, (lambda _x=_book_shape(r18): _x)) if "GTAA" in s.name.upper() + else s + for s in sl + ] + b_base = StrategyPortfolio(sl).backtest() + b_swap = StrategyPortfolio(swapped).backtest() + print(f" {'book':<26}{'Sh FULL':>9}{'Sh HOLD':>9}{'DD FULL':>10}{'CAGR FULL':>11}") + for nm, b in (("base (GTAA6)", b_base), ("con MAT18", b_swap)): + print(f" {nm:<26}{b['full']['sharpe']:>9.2f}{b['holdout']['sharpe']:>9.2f}" + f"{b['full']['maxdd']*100:>9.1f}%{b['full']['cagr']*100:>10.1f}%") + print(f" {'DELTA':<26}{b_swap['full']['sharpe']-b_base['full']['sharpe']:>+9.2f}" + f"{b_swap['holdout']['sharpe']-b_base['holdout']['sharpe']:>+9.2f}" + f"{(b_swap['full']['maxdd']-b_base['full']['maxdd'])*100:>+9.1f}pp" + f"{(b_swap['full']['cagr']-b_base['full']['cagr'])*100:>+10.1f}pp") + + # ---------------- F) eseguibilita' + print("\n" + "-" * 100) + print(" (F) ESEGUIBILITA' a capitale piccolo (IB, frazionabili)") + print("-" * 100) + ex_now = {s: float(_exposure(_close(s)).iloc[-1]) / len(ALL18) for s in ALL18} + inv = {s: w for s, w in ex_now.items() if w > 1e-6} + print(f" gambe investite ora: {len(inv)}/{len(ALL18)} cash {100*(1-sum(ex_now.values())):.1f}%") + for cap in (600, 2000, 5000, 20000): + sleeve_cap = cap * 0.20 # GTAA pesa 20% del book + tickets = [sleeve_cap * w for w in inv.values()] + if not tickets: + continue + print(f" capitale ${cap:>6} sleeve ${sleeve_cap:>7.0f} ticket min ${min(tickets):>6.2f} " + f"medio ${np.mean(tickets):>6.2f} (< $1 = non eseguibile anche con frazioni)") + + print("\n" + "=" * 100) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0725_mat01b_regime.py b/scripts/research/r0725_mat01b_regime.py new file mode 100644 index 0000000..e5fd02c --- /dev/null +++ b/scripts/research/r0725_mat01b_regime.py @@ -0,0 +1,179 @@ +"""MAT01-b — il vantaggio di GTAA6 su MAT18 e' EDGE o SCOMMESSA DI REGIME sull'equity US? + +CONTESTO (da r0725_mat01_multiasset_trend.py): + * ALL18 (universo multi-classe, ZERO liberta' di selezione) fa OOS Sharpe 0.80 / DD -6.9%. + * GTAA6 (incumbent) fa OOS Sharpe 0.89 / DD -8.2% -> l'espansione NON migliora lo Sharpe. + * MA la curva di ampiezza e' monotona (mediana OOS 0.44 a k=1 -> 0.80 a k=18, DD -16% -> -6.9%): + l'ampiezza FUNZIONA come meccanismo. + * E GTAA6 sta al 95° percentile dei 6-subset casuali PUR CONTENENDO TLT, il peggiore dei 18 + (Sh OOS 0.03) -> non e' un best-of cherry-picked: e' la sua COMPOSIZIONE a essere forte. + +DOMANDA: quella composizione (3 gambe su 6 = equity US: SPY+QQQ+IWM, piu' GLD e HYG) e' un edge +stabile, o e' beta azionario US travestito da trend, premiato da un OOS 2015-2026 che e' stato un +toro azionario US quasi ininterrotto? La distinzione decide se lo 0.89 e' una stima onesta del +forward o un massimo di regime — esattamente la domanda che il progetto si e' gia' posto sulle +ancore temporali (anchor-luck) e sui pesi (weights_tilt_null). + +TEST + 1) FINESTRE DISGIUNTE (nessun overlap, ~7 anni ciascuna): se GTAA6 vince in tutte -> edge di + composizione; se vince solo dove l'equity US corre -> regime. + 2) BETA AZIONARIO: regressione di ciascun paniere su SPY buy&hold (beta, alpha annualizzata, + corr). Un "trend difensivo multi-asset" con beta alto e' equity long travestito. + 3) COMPORTAMENTO NEI BEAR AZIONARI (dot-com, GFC, COVID, 2022): il valore dichiarato dello + sleeve e' il taglio del drawdown -> e' li' che si giudica, non nel toro. + 4) MAT18-CLS: aggregazione per CLASSE invece che per asset (7 classi equipesate). NON e' + selezione: e' la costruzione multi-asset canonica (l'EW per asset sovrappesa meccanicamente + commodity 4/18 ed equity US 4/18 contro real_estate 1/18). 1 variante in piu' -> 3 trial totali. + 5) PARI VOLATILITA': MAT18 ha vol piu' bassa; il confronto CAGR va fatto a vol uguagliata (lo + Sharpe e' scale-invariante, il CAGR no) — quanto costa/rende davvero il cambio. + 6) DIVERSIFICAZIONE DEL BOOK: correlazione di ciascun paniere alla parte CRYPTO del book. Il + compito di questo sleeve non e' fare Sharpe da solo, e' essere scorrelato dal crypto. + + uv run python scripts/research/r0725_mat01b_regime.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 r0725_mat01_multiasset_trend import (ALL18, CLASSES, GTAA6, _cagr, _dd, _sh, + basket, gated, _close) + +ANN = np.sqrt(252.0) + +# finestre disgiunte, ~7 anni ciascuna, definite sul calendario (non sui risultati) +WINDOWS = [("1998-2004", "1998-01-01", "2004-12-31"), + ("2005-2011", "2005-01-01", "2011-12-31"), + ("2012-2018", "2012-01-01", "2018-12-31"), + ("2019-2026", "2019-01-01", "2026-12-31")] + +# bear azionari US, date a priori (nessuna ottimizzazione) +BEARS = [("dot-com 2000-09/2002-10", "2000-09-01", "2002-10-31"), + ("GFC 2007-10/2009-03", "2007-10-01", "2009-03-31"), + ("COVID 2020-02/2020-03", "2020-02-15", "2020-03-31"), + ("2022 2022-01/2022-10", "2022-01-01", "2022-10-31")] + + +def _win(r: pd.Series, lo: str, hi: str) -> pd.Series: + return r[(r.index >= pd.Timestamp(lo, tz="UTC")) & (r.index <= pd.Timestamp(hi, tz="UTC"))] + + +def class_basket(end) -> pd.Series: + """MAT18-CLS: media EW delle 7 CLASSI (ogni classe = media EW dei suoi asset disponibili).""" + per_class = {} + for cls, syms in CLASSES.items(): + cols = {s: gated(s) for s in syms} + per_class[cls] = pd.concat(cols, axis=1, sort=True).sort_index().mean(axis=1, skipna=True) + r = pd.concat(per_class, axis=1, sort=True).sort_index().mean(axis=1, skipna=True).dropna() + return r[r.index <= end] + + +def spy_bh(end) -> pd.Series: + c = _close("SPY") + return c.pct_change().dropna()[lambda s: s.index <= end] + + +def main() -> None: + print("=" * 100) + print(" MAT01-b — GTAA6 vs MAT18: edge di composizione o scommessa di regime?") + print("=" * 100) + + end = min(_close(s).index[-1] for s in ALL18) + r6, r18 = basket(GTAA6, end), basket(ALL18, end) + rcls = class_basket(end) + idx = r6.index.intersection(r18.index).intersection(rcls.index) + r6, r18, rcls = r6[idx], r18[idx], rcls[idx] + books = [("GTAA6", r6), ("MAT18", r18), ("MAT18-CLS", rcls)] + + # ---------------- 1) finestre disgiunte + print("\n" + "-" * 100) + print(" (1) FINESTRE DISGIUNTE — Sharpe (e maxDD) per periodo; nessun overlap") + print("-" * 100) + print(f" {'periodo':<12}" + "".join(f"{n:>22}" for n, _ in books) + f"{'SPY B&H':>12}") + spy = spy_bh(end) + wins18 = wins_cls = 0 + for nm, lo, hi in WINDOWS: + cells = [] + for _, r in books: + w = _win(r, lo, hi) + cells.append(f"{_sh(w):>10.2f} / {_dd(w)*100:>6.1f}%" if len(w) > 60 else f"{'—':>19}") + s6, s18, scl = (_sh(_win(r, lo, hi)) for _, r in books) + wins18 += int(s18 > s6) + wins_cls += int(scl > s6) + print(f" {nm:<12}" + "".join(f"{c:>22}" for c in cells) + f"{_sh(_win(spy, lo, hi)):>12.2f}") + print(f"\n MAT18 batte GTAA6 in {wins18}/{len(WINDOWS)} finestre disgiunte; " + f"MAT18-CLS in {wins_cls}/{len(WINDOWS)}") + + # ---------------- 2) beta azionario + print("\n" + "-" * 100) + print(" (2) BETA AZIONARIO US — regressione del paniere su SPY buy&hold (giorni comuni)") + print("-" * 100) + print(f" {'paniere':<12}{'corr SPY':>10}{'beta':>8}{'alpha ann':>12}{'volAnn':>9}{'Sh FULL':>9}") + for nm, r in books: + j = pd.concat({"r": r, "m": spy}, axis=1, sort=True).dropna() + beta = float(np.cov(j["r"], j["m"])[0, 1] / np.var(j["m"])) + alpha = float((j["r"] - beta * j["m"]).mean() * 252) + print(f" {nm:<12}{j['r'].corr(j['m']):>10.2f}{beta:>8.2f}{alpha*100:>11.2f}%" + f"{r.std()*ANN*100:>8.2f}%{_sh(r):>9.2f}") + print(" lettura: beta alto + alpha bassa = equity long travestito da trend.") + + # ---------------- 3) bear azionari + print("\n" + "-" * 100) + print(" (3) BEAR AZIONARI — dove lo sleeve deve guadagnarsi lo stipendio (ritorno cumulato)") + print("-" * 100) + print(f" {'bear':<28}" + "".join(f"{n:>12}" for n, _ in books) + f"{'SPY B&H':>12}") + for nm, lo, hi in BEARS: + cells = [] + for _, r in books: + w = _win(r, lo, hi) + cells.append(f"{(np.prod(1+w.values)-1)*100:>11.1f}%" if len(w) > 5 else f"{'—':>12}") + ws = _win(spy, lo, hi) + print(f" {nm:<28}" + "".join(cells) + f"{(np.prod(1+ws.values)-1)*100:>11.1f}%") + + # ---------------- 4/5) pari volatilita' + print("\n" + "-" * 100) + print(" (4/5) CONFRONTO A PARI VOLATILITA' (scalato alla vol di GTAA6; Sharpe invariato)") + print("-" * 100) + v6 = float(r6.std()) + print(f" {'paniere':<12}{'scala':>8}{'Sh FULL':>10}{'Sh OOS':>9}{'CAGR@vol6':>12}{'maxDD@vol6':>12}") + for nm, r in books: + k = v6 / float(r.std()) + rs = r * k + o = rs[rs.index >= pd.Timestamp("2015-01-01", tz="UTC")] + print(f" {nm:<12}{k:>8.2f}{_sh(rs):>10.2f}{_sh(o):>9.2f}{_cagr(rs)*100:>11.2f}%{_dd(rs)*100:>11.1f}%") + + # ---------------- 6) diversificazione del book + print("\n" + "-" * 100) + print(" (6) IL VERO COMPITO: scorrelazione dalla parte CRYPTO del book") + print("-" * 100) + try: + from src.portfolio.portfolio import combine_outer + from src.portfolio.sleeves import active_sleeves + cry = [s for s in active_sleeves() if "GTAA" not in s.name.upper()] + wsum = sum(s.weight for s in cry) + crypto = combine_outer({s.name: s.daily() for s in cry}, + {s.name: s.weight / wsum for s in cry}) + print(f" crypto del book = {[s.name for s in cry]} (pesi rinormalizzati)") + print(f" {'paniere':<12}{'corr->crypto':>14}{'Sh 50/50':>11}{'DD 50/50':>11}") + for nm, r in books: + j = pd.concat({"e": r, "c": crypto}, axis=1, sort=True).dropna() + if len(j) < 100: + print(f" {nm:<12}{'(overlap corto)':>14}") + continue + bl = 0.5 * j["e"] + 0.5 * j["c"] + print(f" {nm:<12}{j['e'].corr(j['c']):>14.3f}{_sh(bl):>11.2f}{_dd(bl)*100:>10.1f}%") + except Exception as e: + print(f" [skip: {e.__class__.__name__}: {e}]") + + print("\n" + "=" * 100) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0725_statarb_eq.py b/scripts/research/r0725_statarb_eq.py new file mode 100644 index 0000000..2ad8d4a --- /dev/null +++ b/scripts/research/r0725_statarb_eq.py @@ -0,0 +1,243 @@ +"""STATARB-EQ — il meccanismo di STATARB-RESID su coppie ETF con 20-30 ANNI di storia. + +PERCHE'. STATARB-RESID (relative-momentum del residuo, W=45, sgn=+1) e' il miglior lead del +progetto e ha un gate di deploy al 2026-09-27, ma la sua debolezza non e' il segno dei numeri: +e' la STATISTICA. Su crypto ha ~2,6 anni di storia; il test multi-coppia su Hyperliquid +(r0725_statarb_multi.py) ha mostrato che le 50 coppie alt/BTC valgono solo ~4,5 scommesse +indipendenti (corr media 0,204), che il paniere fa Sharpe 0,82 ma con IC95% [-0,12, 1,72] — +cioe' NON distinguibile da zero — e che il t apparente 5,05 e' gonfiato dalla dipendenza. + +Le coppie ETF risolvono esattamente questo: 20-30 anni di storia certificata (IB ADJUSTED_LAST, +gia' su disco), classi d'attivo diverse fra loro (quindi ampiezza effettiva vera), e 3-4 regimi +di mercato completi (dot-com, GFC, ZIRP, 2022) invece di un solo mini-ciclo alt. + +DOMANDA: il "relative-momentum del residuo" e' un fenomeno reale e persistente, o e' un artefatto +della finestra crypto 2024-2026? + +DISCIPLINA + * Meccanismo CONGELATO, importato da r0725_statarb_multi.py: W=45, sgn=+1, beta OLS rolling + causale, z-score su W, tanh, vol-target 20%, cap 2x. ZERO rifit, zero griglia. + * Coppie definite A PRIORI dentro la stessa classe d'attivo (relazione economica, non + data-mining di cointegrazione: cercare le coppie piu' cointegrate SU QUESTI STESSI DATI + sarebbe selezione). 12 coppie, elencate sotto con la loro ragione economica. + * Costi: 2 bps/lato per gamba (ETF liquidi) PIU' un costo di prestito sullo short, testato a + 0 / 30 / 100 bps annui — sui long/short azionari il borrow non e' un dettaglio. + * Null: (a) statica CAUSALE (segno = media espandente del segnale), (b) permutazione a blocchi. + * Stabilita' per DECADE: e' il test che i 2,6 anni crypto non possono fare. + + uv run python scripts/research/r0725_statarb_eq.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 r0725_statarb_multi import (BLOCK, CAP, N_PERM, SEED, TARGET_VOL, VOL_WIN, W, _dd, + _zscore, SGN) +from eqlib import load_eq + +ANN_EQ = np.sqrt(252.0) +FEE_LEG = 0.0002 # 2 bps/lato per gamba (ETF liquidi su IB) +BORROW_GRID = (0.0, 0.0030, 0.0100) # costo prestito annuo sulla gamba corta + +# --- 12 coppie A PRIORI: stessa classe d'attivo, relazione economica dichiarata ------------------ +PAIRS = [ + ("DIA", "SPY", "US large cap: due proxy dello stesso indice"), + ("IWM", "SPY", "small vs large cap US (size)"), + ("QQQ", "SPY", "tech vs mercato ampio US"), + ("EEM", "EFA", "emergenti vs sviluppati ex-US"), + ("EWJ", "EFA", "Giappone vs sviluppati ex-US"), + ("FXI", "EEM", "Cina vs emergenti"), + ("EFA", "SPY", "ex-US vs US (home bias)"), + ("SLV", "GLD", "argento vs oro (metalli preziosi)"), + ("IEF", "TLT", "duration intermedia vs lunga (curva)"), + ("HYG", "LQD", "credito HY vs IG (rischio di credito)"), + ("LQD", "AGG", "credito IG vs aggregato (spread)"), + ("USO", "DBC", "petrolio vs paniere commodity"), +] + + +def signal_eq(base_px: pd.Series, tgt_px: pd.Series): + """Meccanismo CONGELATO, identico a r0725_statarb_multi.signal ma su calendario di borsa.""" + x, y = np.log(base_px.values), np.log(tgt_px.values) + sx, sy = pd.Series(x), pd.Series(y) + mx = sx.rolling(W, min_periods=W).mean() + my = sy.rolling(W, min_periods=W).mean() + cov = (sx * sy).rolling(W, min_periods=W).mean() - mx * my + var = (sx * sx).rolling(W, min_periods=W).mean() - mx * mx + beta = cov / var.replace(0, np.nan) + resid = (sy - (my - beta * mx) - beta * sx).values + z = _zscore(np.nan_to_num(resid), W) + d = SGN * np.tanh(np.nan_to_num(z)) + + r_b = np.concatenate([[0.0], np.diff(base_px.values) / base_px.values[:-1]]) + r_t = np.concatenate([[0.0], np.diff(tgt_px.values) / tgt_px.values[:-1]]) + spread = r_t - r_b + vol = pd.Series(spread).rolling(VOL_WIN, min_periods=VOL_WIN).std().values * ANN_EQ + scal = np.where((vol > 0) & np.isfinite(vol), TARGET_VOL / vol, 0.0) + pos = np.clip(np.nan_to_num(d) * np.nan_to_num(scal), -CAP, CAP) + pos[~np.isfinite(pos)] = 0.0 + return pos, spread, np.nan_to_num(scal) + + +def pnl_eq(pos: np.ndarray, spread: np.ndarray, borrow: float = 0.0) -> np.ndarray: + held = np.concatenate([[0.0], pos[:-1]]) + turn = np.abs(np.diff(held, prepend=0.0)) + # il prestito si paga sulla gamba corta: |posizione| di nozionale a prestito, ogni giorno + return held * spread - 2.0 * FEE_LEG * turn - borrow / 252.0 * np.abs(held) + + +def _sh(r: np.ndarray) -> float: + r = r[np.isfinite(r)] + return float(r.mean() / r.std() * ANN_EQ) if len(r) > 60 and r.std() > 0 else 0.0 + + +def main() -> None: + print("=" * 108) + print(" STATARB-EQ — meccanismo congelato (W=45, sgn=+1) su coppie ETF, 20-30 anni di storia") + print("=" * 108) + + rng = np.random.default_rng(SEED + 3) + rows = [] + for tgt, base, why in PAIRS: + try: + b_all, t_all = load_eq(base)["close"].astype(float), load_eq(tgt)["close"].astype(float) + except FileNotFoundError: + print(f" [{tgt}/{base}] parquet mancante — salto") + continue + ix = b_all.index.intersection(t_all.index) + if len(ix) < 1000: + continue + b, t = b_all[ix], t_all[ix] + pos, spread, scal = signal_eq(b, t) + r = pnl_eq(pos, spread) + + live = pos[pos != 0.0] + mono = float(max((live > 0).mean(), (live < 0).mean())) if len(live) else 0.0 + # null statica CAUSALE + cum, cnt = np.cumsum(pos), np.arange(1, len(pos) + 1) + run_mean = np.concatenate([[0.0], (cum / cnt)[:-1]]) + sc = np.where(run_mean >= 0, 1.0, -1.0) + sc[:VOL_WIN + W] = 0.0 + r_stat = pnl_eq(np.clip(sc * scal, -CAP, CAP), spread) + # null permutazione a blocchi + nb = int(np.ceil(len(pos) / BLOCK)) + perm = [] + for _ in range(N_PERM): + order = rng.permutation(nb) + pp = np.concatenate([pos[k * BLOCK:(k + 1) * BLOCK] for k in order])[:len(pos)] + perm.append(_sh(pnl_eq(pp, spread))) + perm = np.array(perm) + gross = np.concatenate([[0.0], pos[:-1]]) * spread # senza fee ne' borrow + turn = np.abs(np.diff(np.concatenate([[0.0], pos[:-1]]), prepend=0.0)) + rows.append(dict(pair=f"{tgt}/{base}", why=why, n=len(ix), idx=ix, ret=r, + sh_gross=_sh(gross), turn=float(turn.mean()), + sh=_sh(r), dd=_dd(r), mono=mono, sh_stat=_sh(r_stat), + up=_sh(r) - _sh(r_stat), pperm=float((perm >= _sh(r)).mean()), + yrs=(ix[-1] - ix[0]).days / 365.25)) + + if not rows: + print(" nessuna coppia valutabile") + return + + print("\n" + "-" * 108) + print(" (1) COPPIE A PRIORI — meccanismo congelato, netto fee (borrow 0 in questa tabella)") + print("-" * 108) + print(f" {'coppia':<11}{'anni':>6}{'Sh LORDA':>10}{'Sh netta':>10}{'turn/g':>8}{'maxDD':>9}" + f"{'stat.caus':>11}{'uplift':>8}{'p perm':>9} ragione economica") + for d in sorted(rows, key=lambda x: -x["sh_gross"]): + print(f" {d['pair']:<11}{d['yrs']:>6.1f}{d['sh_gross']:>10.2f}{d['sh']:>10.2f}" + f"{d['turn']:>8.2f}{d['dd']*100:>8.1f}%" + f"{d['sh_stat']:>11.2f}{d['up']:>8.2f}{d['pperm']:>9.3f} {d['why']}") + + sh = np.array([d["sh"] for d in rows]) + shg = np.array([d["sh_gross"] for d in rows]) + up = np.array([d["up"] for d in rows]) + pp = np.array([d["pperm"] for d in rows]) + n = len(rows) + print(f"\n coppie {n} Sharpe LORDA media {shg.mean():>5.2f} mediana {np.median(shg):>5.2f} " + f"frazione > 0: {(shg>0).mean()*100:.0f}%") + print(f" Sharpe NETTA media {sh.mean():>5.2f} mediana {np.median(sh):>5.2f} " + f"frazione > 0: {(sh>0).mean()*100:.0f}%") + print(f" turnover medio {np.mean([d['turn'] for d in rows]):.2f}/giorno -> drag di fee " + f"{np.mean(shg-sh):.2f} Sharpe. LETTURA: se la LORDA e' ~0 l'edge non esiste ed e' inutile") + print(" cercare il segno opposto (anche lo specchio sarebbe ~0 lordo e negativo netto).") + print(f" uplift vs statica causale: media {up.mean():>5.2f} frazione > 0: {(up>0).mean()*100:.0f}%") + print(f" p permutazione < 0.05: {(pp<0.05).mean()*100:.0f}% delle coppie (atteso 5%)") + + # ---------------- paniere + IC + print("\n" + "-" * 108) + print(" (2) PANIERE EW DELLE COPPIE — con l'ampiezza e la storia che al crypto mancano") + print("-" * 108) + M = pd.concat({d["pair"]: pd.Series(d["ret"], index=d["idx"]) for d in rows}, + axis=1, sort=True).sort_index() + bask = M.mean(axis=1, skipna=True).dropna() + yrs = len(bask) / 252.0 + C = M.corr().values + off = C[~np.eye(len(C), dtype=bool)] + rbar = float(np.nanmean(off)) + n_eff = len(C) / (1.0 + (len(C) - 1) * rbar) if rbar > -1 / (len(C) - 1) else float(len(C)) + print(f" paniere: n={len(bask)} ({yrs:.1f} anni) Sharpe {_sh(bask.values):>5.2f} " + f"maxDD {_dd(bask.values)*100:>5.1f}%") + print(f" corr media fra coppie {rbar:>5.3f} -> ampiezza EFFETTIVA ~{n_eff:.1f} " + f"(vs ~4.5 delle 50 coppie crypto)") + rng2 = np.random.default_rng(SEED + 11) + v = bask.values + nb2 = int(np.ceil(len(v) / BLOCK)) + boot = np.array([_sh(np.concatenate([v[i * BLOCK:(i + 1) * BLOCK] + for i in rng2.integers(0, nb2, size=nb2)])[:len(v)]) + for _ in range(2000)]) + print(f" block-bootstrap IC95% [{np.percentile(boot,2.5):>5.2f}, {np.percentile(boot,97.5):>5.2f}]" + f" P(Sh>0) {(boot>0).mean()*100:.0f}% t ~ {_sh(v)*np.sqrt(yrs):.2f}") + + # ---------------- stabilita' per decade + print("\n" + "-" * 108) + print(" (3) STABILITA' PER DECADE — il test che i 2,6 anni crypto NON possono fare") + print("-" * 108) + decs = [("1998-2004", 1998, 2004), ("2005-2011", 2005, 2011), + ("2012-2018", 2012, 2018), ("2019-2026", 2019, 2026)] + print(f" {'periodo':<12}{'Sh paniere':>12}{'maxDD':>9}{'coppie Sh>0':>14}") + for nm, y0, y1 in decs: + w = bask[(bask.index.year >= y0) & (bask.index.year <= y1)] + if len(w) < 200: + print(f" {nm:<12}{'(storia corta)':>12}") + continue + cnt = sum(1 for d in rows + if len(pd.Series(d['ret'], index=d['idx'])[ + (d['idx'].year >= y0) & (d['idx'].year <= y1)]) > 200 + and _sh(pd.Series(d['ret'], index=d['idx'])[ + (d['idx'].year >= y0) & (d['idx'].year <= y1)].values) > 0) + tot = sum(1 for d in rows if ((d['idx'].year >= y0) & (d['idx'].year <= y1)).sum() > 200) + print(f" {nm:<12}{_sh(w.values):>12.2f}{_dd(w.values)*100:>8.1f}%{f'{cnt}/{tot}':>14}") + + # ---------------- costo di prestito + print("\n" + "-" * 108) + print(" (4) SENSIBILITA' AL COSTO DI PRESTITO (gamba corta) — su un long/short non e' un dettaglio") + print("-" * 108) + print(f" {'borrow ann':>12}{'Sh paniere':>13}{'coppie Sh>0':>14}") + for bw in BORROW_GRID: + rr = {} + for tgt, base, _ in PAIRS: + d = next((x for x in rows if x["pair"] == f"{tgt}/{base}"), None) + if d is None: + continue + b_all, t_all = load_eq(base)["close"].astype(float), load_eq(tgt)["close"].astype(float) + ix = b_all.index.intersection(t_all.index) + pos, spread, _ = signal_eq(b_all[ix], t_all[ix]) + rr[d["pair"]] = pd.Series(pnl_eq(pos, spread, borrow=bw), index=ix) + bb = pd.concat(rr, axis=1, sort=True).sort_index().mean(axis=1, skipna=True).dropna() + pos_cnt = sum(1 for s in rr.values() if _sh(s.values) > 0) + print(f" {bw*100:>11.2f}%{_sh(bb.values):>13.2f}{f'{pos_cnt}/{len(rr)}':>14}") + + print("\n" + "=" * 108) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0725_statarb_multi.py b/scripts/research/r0725_statarb_multi.py new file mode 100644 index 0000000..a4dbec5 --- /dev/null +++ b/scripts/research/r0725_statarb_multi.py @@ -0,0 +1,317 @@ +"""STATARB-MULTI — il meccanismo di STATARB-RESID generalizza fuori dalla coppia ETH/BTC? + +PERCHE' QUESTA E' LA DOMANDA PIU' IMPORTANTE APERTA. STATARB-RESID e' il miglior lead del progetto +(+4,4% in ~4 settimane di forward, haircut fill ~0, ortogonale al book, ESEGUIBILE a $600 — non +STAT-MODE) e ha un gate di deploy pre-registrato al 2026-09-27. Ma e' stato scoperto su UNA coppia, +ETH/BTC, dentro uno sweep a 5 thread: Sharpe 0.84, deflated-Sharpe 0.929 (sotto 0.95). Se il +meccanismo e' reale deve funzionare anche su coppie MAI usate per scoprirlo; se ETH/BTC e' un +outlier, il gate del 27/09 va letto con un prior molto peggiore — e va saputo PRIMA, non dopo. + +Questo e' un test out-of-PAIR-sample: i 50 alt di Hyperliquid non hanno partecipato alla scoperta. + +MECCANISMO CONGELATO (nessun rifit, nessuna griglia): W=45, sgn=+1, vol-target 20%, cap 2x, +z-score su finestra W, beta OLS rolling causale, dollar-neutral, fee 0.05%/gamba per |delta pos|. +Identico a scripts/research/orthogonal_signals.f_statarb_resid, riscritto qui in forma generica +(base, target) invece che (BTC, ETH) — il test di identita' e' nel passo (0). + +IL MODO IN CUI QUESTO SEGNALE PUO' MENTIRE (pre-registrato, non scoperto dopo): +con sgn=+1 la posizione e' proporzionale al LIVELLO del residuo z-scorato, cioe' "long l'asset che +ha sovraperformato il suo fit su BTC". Se un alt sottoperforma BTC in modo persistente (2024-2026 +e' stato esattamente questo), il segnale resta quasi sempre SHORT e la strategia degenera in una +SCOMMESSA STATICA "short alt vs BTC" che ha pagato per motivi di beta, non di timing. E' la stessa +degenerazione ("predictor affine nel ritorno proprio") gia' codificata il 2026-07-24. Quindi: + + NULL 1 — STATICA, in DUE versioni (la prima stesura di questo script ne aveva una sola, col segno + preso da `sign(mean(segnale))` su TUTTO il campione: era look-ahead, cioe' un null che + conosce gia' la direzione giusta. Corretta qui): + (1a) CAUSALE: segno = sign(media espandente del segnale fino a i-1) — usa solo il passato; + (1b) A PRIORI: sempre SHORT alt vs BTC, la scommessa beta nota del 2024-2026 (gli alt + hanno sottoperformato BTC). Nessun parametro stimato, nessun look-ahead. + Se il segnale non batte entrambe, non c'e' timing: c'e' solo direzione media. + NULL 2 — PERMUTAZIONE: 200 permutazioni a blocchi (20 barre) del segnale contro lo stesso spread, + per un p-value che non assume normalita'. + CHECK 3 — DEGENERAZIONE: frazione di barre a segno costante; una coppia >85% mono-segno e' una + scommessa statica travestita, e va marcata. + CHECK 4 — RIDONDANZA vs XS01: correlazione del paniere di coppie ai rendimenti dello sleeve XS01 + (long-i-forti/short-i-deboli): se corr e' alta, non e' uno sleeve nuovo, e' XS01. + CHECK 5 — ETH/BTC e' un outlier? rango e percentile della coppia scopritrice fra tutte. + + uv run python scripts/research/r0725_statarb_multi.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)) + +RAW = ROOT / "data" / "raw" +ANN = np.sqrt(365.0) + +# --- CONFIG CONGELATA (dallo sweep 2026-06-29; NON toccare) -------------------------------------- +W = 45 +SGN = +1 +TARGET_VOL = 0.20 +VOL_WIN = 30 +CAP = 2.0 +FEE_LEG = 0.0005 # 0.05% per gamba per unita' di |delta posizione| (2 gambe) +BASE = "BTC" +MIN_BARS = 400 # ~13 mesi: sotto questa soglia la coppia non e' valutabile +N_PERM = 200 +BLOCK = 20 +SEED = 20260725 + + +def load_hl(sym: str) -> pd.Series: + p = RAW / f"hl_{sym.lower()}_1d.parquet" + if not p.exists(): + raise FileNotFoundError(p) + d = pd.read_parquet(p) + s = pd.Series(d["close"].values.astype(float), + index=pd.to_datetime(d["timestamp"], unit="ms", utc=True)).sort_index() + return s[~s.index.duplicated(keep="last")] + + +def universe() -> list[str]: + out = [] + for p in sorted(RAW.glob("hl_*_1d.parquet")): + sym = p.stem.replace("hl_", "").replace("_1d", "").upper() + if sym != BASE: + out.append(sym) + return out + + +def _zscore(v: np.ndarray, w: int) -> np.ndarray: + s = pd.Series(v) + m = s.rolling(w, min_periods=w).mean() + sd = s.rolling(w, min_periods=w).std() + return ((s - m) / sd.replace(0, np.nan)).values + + +def signal(base_px: pd.Series, tgt_px: pd.Series) -> tuple[np.ndarray, np.ndarray]: + """Ritorna (posizione dollar-neutral causale, ritorno spread) — meccanismo congelato.""" + x = np.log(base_px.values) + y = np.log(tgt_px.values) + sx, sy = pd.Series(x), pd.Series(y) + mx = sx.rolling(W, min_periods=W).mean() + my = sy.rolling(W, min_periods=W).mean() + cov = (sx * sy).rolling(W, min_periods=W).mean() - mx * my + var = (sx * sx).rolling(W, min_periods=W).mean() - mx * mx + beta = cov / var.replace(0, np.nan) + resid = (sy - (my - beta * mx) - beta * sx).values + z = _zscore(np.nan_to_num(resid), W) + d = SGN * np.tanh(np.nan_to_num(z)) + + r_b = np.concatenate([[0.0], np.diff(base_px.values) / base_px.values[:-1]]) + r_t = np.concatenate([[0.0], np.diff(tgt_px.values) / tgt_px.values[:-1]]) + spread = r_t - r_b + vol = pd.Series(spread).rolling(VOL_WIN, min_periods=VOL_WIN).std().values * ANN + scal = np.where((vol > 0) & np.isfinite(vol), TARGET_VOL / vol, 0.0) + pos = np.clip(np.nan_to_num(d) * scal, -CAP, CAP) + pos[~np.isfinite(pos)] = 0.0 + return pos, spread + + +def pnl(pos: np.ndarray, spread: np.ndarray) -> np.ndarray: + """Ritorno netto: posizione decisa a i-1 tenuta in i, fee su 2 gambe per |delta pos|.""" + 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 _sh(r: np.ndarray) -> 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 _dd(r: np.ndarray) -> float: + eq = np.cumprod(1.0 + np.nan_to_num(r)) + return float(np.min(eq / np.maximum.accumulate(eq) - 1.0)) + + +def main() -> None: + print("=" * 104) + print(" STATARB-MULTI — il meccanismo congelato (W=45, sgn=+1) fuori dalla coppia scopritrice") + print("=" * 104) + + # ---------------- (0) identita' col segnale dello sweep, sulla coppia originale + print("\n" + "-" * 104) + print(" (0) CONTROLLO DI IDENTITA' — ETH/BTC ricalcolato qui vs implementazione dello sweep") + print("-" * 104) + try: + sys.path.insert(0, str(ROOT / "scripts" / "research")) + from orthogonal_signals import build_joint, f_statarb_resid, spread_ret + j = build_joint("1d") + pos_ref = f_statarb_resid(tf="1d", W=W, sgn=SGN, tv=TARGET_VOL, vw=VOL_WIN, cap=CAP)(j) + r_ref = np.concatenate([[0.0], pos_ref[:-1]]) * spread_ret(j) + print(f" sweep (feed Deribit certificato) n={len(j):>5} Sharpe LORDA {_sh(r_ref):>5.2f}") + except Exception as e: + print(f" [riferimento sweep non disponibile: {e.__class__.__name__}: {e}]") + + eth_hl, btc_hl = load_hl("ETH"), load_hl(BASE) + idx = eth_hl.index.intersection(btc_hl.index) + p, s = signal(btc_hl[idx], eth_hl[idx]) + print(f" qui (feed Hyperliquid) n={len(idx):>5} Sharpe NETTA {_sh(pnl(p, s)):>5.2f}") + print(" (venue e finestra diversi -> i due numeri non devono coincidere; serve a escludere") + print(" un errore di implementazione, non a replicare il backtest)") + + # ---------------- (1) tutte le coppie alt/BTC + print("\n" + "-" * 104) + print(f" (1) MECCANISMO CONGELATO SU TUTTE LE COPPIE alt/{BASE} — out-of-pair-sample") + print("-" * 104) + base_px = load_hl(BASE) + rng = np.random.default_rng(SEED) + rows = [] + for sym in universe(): + try: + tgt = load_hl(sym) + except FileNotFoundError: + continue + ix = base_px.index.intersection(tgt.index) + if len(ix) < MIN_BARS: + continue + b, t = base_px[ix], tgt[ix] + pos, spread = signal(b, t) + r = pnl(pos, spread) + live = pos[np.isfinite(pos)] + live = live[live != 0.0] + if len(live) < MIN_BARS // 2: + continue + mono = float(max((live > 0).mean(), (live < 0).mean())) # degenerazione + vol = pd.Series(spread).rolling(VOL_WIN, min_periods=VOL_WIN).std().values * ANN + scal = np.where((vol > 0) & np.isfinite(vol), TARGET_VOL / vol, 0.0) + scal = np.nan_to_num(scal) + + def _static(sign_vec: np.ndarray) -> np.ndarray: + ps = np.clip(sign_vec * scal, -CAP, CAP) + ps[~np.isfinite(ps)] = 0.0 + return pnl(ps, spread) + + # (1a) CAUSALE: segno = media espandente del segnale fino a i-1 (nessun look-ahead) + cum = np.cumsum(np.nan_to_num(pos)) + cnt = np.arange(1, len(pos) + 1) + run_mean = np.concatenate([[0.0], (cum / cnt)[:-1]]) + sign_causal = np.where(run_mean >= 0, 1.0, -1.0) + sign_causal[:VOL_WIN + W] = 0.0 # prima che il segnale esista + r_stat = _static(sign_causal) + # (1b) A PRIORI: sempre short alt vs BTC (beta noto del regime 2024-2026) + r_short = _static(np.full(len(pos), -1.0)) + # NULL 2: permutazione a blocchi del segnale + nb = int(np.ceil(len(pos) / BLOCK)) + perm = [] + for _ in range(N_PERM): + order = rng.permutation(nb) + pp = np.concatenate([pos[k * BLOCK:(k + 1) * BLOCK] for k in order])[:len(pos)] + perm.append(_sh(pnl(pp, spread))) + perm = np.array(perm) + rows.append(dict(sym=sym, n=len(ix), sh=_sh(r), dd=_dd(r), mono=mono, + sh_stat=_sh(r_stat), uplift=_sh(r) - _sh(r_stat), + sh_short=_sh(r_short), uplift_s=_sh(r) - _sh(r_short), + pperm=float((perm >= _sh(r)).mean()), ret=r, pos=pos, idx=ix)) + + if not rows: + print(" nessuna coppia valutabile — controlla data/raw/hl_*_1d.parquet") + return + + rows.sort(key=lambda d: -d["sh"]) + print(f" {'coppia':<10}{'n':>6}{'Sh netta':>10}{'maxDD':>9}{'mono%':>7}" + f"{'stat.caus':>11}{'uplift':>8}{'sempre-sh':>11}{'uplift':>8}{'p perm':>9}") + for d in rows: + flag = " <-- DEGENERE" if d["mono"] > 0.85 else "" + print(f" {d['sym']+'/'+BASE:<10}{d['n']:>6}{d['sh']:>10.2f}{d['dd']*100:>8.1f}%" + f"{d['mono']*100:>6.0f}%{d['sh_stat']:>11.2f}{d['uplift']:>8.2f}" + f"{d['sh_short']:>11.2f}{d['uplift_s']:>8.2f}{d['pperm']:>9.3f}{flag}") + + sh = np.array([d["sh"] for d in rows]) + up = np.array([d["uplift"] for d in rows]) + ups = np.array([d["uplift_s"] for d in rows]) + pp = np.array([d["pperm"] for d in rows]) + mono = np.array([d["mono"] for d in rows]) + n = len(rows) + + print("\n" + "-" * 104) + print(" (2) VERDETTO STATISTICO SULL'INSIEME") + print("-" * 104) + tstat = float(sh.mean() / (sh.std(ddof=1) / np.sqrt(n))) if n > 2 else 0.0 + print(f" coppie valutate : {n}") + print(f" Sharpe netta media {sh.mean():>6.2f} mediana {np.median(sh):>6.2f} " + f"p10 {np.percentile(sh,10):>5.2f} p90 {np.percentile(sh,90):>5.2f}") + print(f" frazione con Sharpe > 0 : {(sh>0).mean()*100:>5.0f}% " + f"(t della media cross-coppie = {tstat:>5.2f})") + print(f" UPLIFT vs statica CAUSALE media {up.mean():>6.2f} mediana {np.median(up):>6.2f} " + f"frazione > 0: {(up>0).mean()*100:>3.0f}%") + print(f" UPLIFT vs SEMPRE-SHORT media {ups.mean():>6.2f} mediana {np.median(ups):>6.2f} " + f"frazione > 0: {(ups>0).mean()*100:>3.0f}%") + print(f" p-value permutazione < 0.05 : {(pp<0.05).mean()*100:>5.0f}% delle coppie " + f"(atteso per caso: 5%)") + print(f" coppie DEGENERI (mono > 85%) : {(mono>0.85).sum()}/{n} " + f"mono medio {mono.mean()*100:.0f}%") + print("\n ⚠ il t cross-coppie SOVRASTIMA: le 50 coppie condividono la gamba BTC e gli alt") + print(" co-muovono -> non sono 50 scommesse indipendenti. Il test onesto e' sul PANIERE (3).") + + # ---------------- (3) paniere EW + print("\n" + "-" * 104) + print(" (3) PANIERE EW DELLE COPPIE (sleeve ipotetica) + ridondanza vs XS01") + print("-" * 104) + ser = {d["sym"]: pd.Series(d["ret"], index=d["idx"]) for d in rows} + M = pd.concat(ser, axis=1, sort=True).sort_index() + bask = M.mean(axis=1, skipna=True).dropna() + sh_b = _sh(bask.values) + yrs = len(bask) / 365.0 + print(f" paniere EW {len(ser)} coppie: n={len(bask)} ({yrs:.1f} anni) Sharpe {sh_b:>5.2f} " + f"maxDD {_dd(bask.values)*100:>5.1f}% ret tot {(np.prod(1+bask.values)-1)*100:>6.1f}%") + + # ampiezza EFFETTIVA: quante scommesse indipendenti ci sono davvero + C = M.corr().values + off = C[~np.eye(len(C), dtype=bool)] + rbar = float(np.nanmean(off)) + n_eff = len(C) / (1.0 + (len(C) - 1) * rbar) if rbar > -1 / (len(C) - 1) else float(len(C)) + print(f" correlazione media fra coppie {rbar:>5.3f} -> ampiezza EFFETTIVA ~{n_eff:.1f} " + f"scommesse indipendenti (non {len(C)})") + + # intervallo di confidenza onesto sul paniere: block bootstrap (blocchi 20g) + rng2 = np.random.default_rng(SEED + 7) + v = bask.values + nb2 = int(np.ceil(len(v) / BLOCK)) + boot = [] + for _ in range(2000): + k = rng2.integers(0, nb2, size=nb2) + x = np.concatenate([v[i * BLOCK:(i + 1) * BLOCK] for i in k])[:len(v)] + boot.append(_sh(x)) + boot = np.array(boot) + lo, hi = np.percentile(boot, [2.5, 97.5]) + print(f" block-bootstrap Sharpe paniere: IC95% [{lo:>5.2f}, {hi:>5.2f}] " + f"P(Sharpe>0) = {(boot>0).mean()*100:.0f}% P(Sharpe>0.5) = {(boot>0.5).mean()*100:.0f}%") + print(f" errore standard atteso a {yrs:.1f} anni ~ {1/np.sqrt(yrs):.2f} Sharpe -> " + f"t del paniere ~ {sh_b*np.sqrt(yrs):.2f}") + try: + from src.portfolio.sleeves import _xsec_returns + xs = _xsec_returns().dropna() + xs.index = pd.to_datetime(xs.index, utc=True) if xs.index.tz is None else xs.index + jj = pd.concat({"b": bask, "x": xs}, axis=1, sort=True).dropna() + print(f" corr(paniere, XS01) = {jj['b'].corr(jj['x']):>6.3f} su {len(jj)} giorni comuni" + f" -> {'RIDONDANTE con XS01' if abs(jj['b'].corr(jj['x'])) > 0.5 else 'non ridondante'}") + except Exception as e: + print(f" [corr XS01 non calcolabile: {e.__class__.__name__}: {e}]") + + # ---------------- (4) ETH/BTC e' un outlier? + print("\n" + "-" * 104) + print(" (4) LA COPPIA SCOPRITRICE E' UN OUTLIER?") + print("-" * 104) + eth = next((d for d in rows if d["sym"] == "ETH"), None) + if eth is None: + print(" ETH/BTC non fra le coppie valutate (storia insufficiente?)") + else: + rank = 1 + sum(1 for d in rows if d["sh"] > eth["sh"]) + print(f" ETH/{BASE}: Sharpe {eth['sh']:.2f} -> rango {rank}/{n} " + f"(percentile {100*(1-(rank-1)/n):.0f}°) mono {eth['mono']*100:.0f}% " + f"uplift vs statica {eth['uplift']:+.2f} p perm {eth['pperm']:.3f}") + print("\n" + "=" * 104) + + +if __name__ == "__main__": + main()