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