research: MAT01 e generalizzazione STATARB — 2 refutazioni pulite + addendum al gate 27/09

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) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-07-25 09:47:47 +00:00
parent 741bbc2c09
commit 9af6693e4f
5 changed files with 1101 additions and 0 deletions
+317
View File
@@ -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()