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:
@@ -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()
|
||||
Reference in New Issue
Block a user