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
@@ -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).")
@@ -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()
+179
View File
@@ -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()
+243
View File
@@ -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()
+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()