2c3882b60c
Tre questioni che il progetto aveva lasciato aperte e decidibili oggi. T1 — ESECUZIONE SKH01. Testato l'unico meccanismo che non e' un cron: ordini resting on-book (TP=limit al livello per costruzione + fee maker, SL=stop-market). A offset 0 recupera il 94% del degrado, ma l'audit 02/07 aveva de-luckato i numeri headline di SKH01 e NON il degrado. Sulla banda appaiata dei 23 offset il degrado recuperabile e' +0.054 Sh di book, non -0.35 di sleeve. Errore di metodo corretto in sessione: mediana(A)-mediana(B) fra offset confronta offset diversi; serve la mediana delle DIFFERENZE appaiate. Col fix il verdetto si ribalta: solo TP a limite = +0.054 FULL / +0.061 HOLD, positivo in 19/23 e 21/23 offset; lo SL on-book PEGGIORA (mediana -0.010, positivo in 11/23) perche' cristallizza la perdita mentre l'exit ritardata incassa il rimbalzo. Raccomandazione NON eseguita: TP a limite si, SL strategico on-book no. T2 — DVOLSPREAD esce dal limbo (fermo dal 21/06). Passato ai due gate che allora non esistevano. La griglia dichiarata "72 celle" ne contiene 729: valutate tutte. Plateau reale (729/729 hold-out positivo). Selection-on-holdout confermata ma mite: cella pubblicata 83a/729 sull'hold-out, 471a/729 in-sample. Cella onesta FULL 0.68 / HOLD 0.69 (non 0.93) e DSR 0.953 PASS; la pubblicata fallisce 0.947. Promosso a forward-monitor coi parametri onesti, NON nel book (hold-out attivo 1.6 anni, DSR sul filo, weights_tilt_null mai affrontato). T3 — XSR01 non generalizza. Meccanismo congelato su 9 settoriali (1998+) e 28 ETF (30 anni), versione DEMEANATA (il test del 25/07 era a coppie). Lordo +0.24 p=0.193 e -0.14 p=0.747 vs null a fee zero. Il risultato che conta e' l'ampiezza: il demean fa 4.5->37.4 (8x) sul crypto ma 5.3->6.2 (1.2x) sulle azioni, perche' sul crypto il residuo-vs-BTC lascia un enorme fattore comune e sulle azioni il residuo-vs-SPY e' gia' indipendente. XSR01 e' crypto-specifico; NON e' falso. Soglie del gate 23/10 non toccate. Book/pesi/cron INVARIATI. Suite 245 verdi. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
215 lines
9.9 KiB
Python
215 lines
9.9 KiB
Python
#!/usr/bin/env python
|
|
"""r0726_xsr_equity — T3 dell'ondata 2026-07-26: XSR01 regge fuori dal crypto e fuori dai
|
|
2.6 anni monoregime? Test di FALSIFICAZIONE su 26+ anni di ETF settoriali.
|
|
|
|
PERCHE'. XSR01 (candidato in forward-monitor dal 25/07, gate pre-registrato al 2026-10-23) ha
|
|
superato tutto quello che gli e' stato chiesto — marginale ADDS, deflated-Sharpe 0.985, corr ~0
|
|
a tutti e 5 gli sleeve, tre null a fee-neutrale — MA la sua debolezza numero uno e' dichiarata:
|
|
|
|
"storia 2.6 anni MONOREGIME e CRESCENTE (Sharpe 2024 1.03 / 2025 1.98 / 2026 3.11):
|
|
l'edge e' recente."
|
|
|
|
Con 2.6 anni non si distingue un meccanismo reale da un regime fortunato. L'unico modo di
|
|
guadagnare potenza statistica senza aspettare anni e' portare il meccanismo CONGELATO su un
|
|
pannello **diverso per asset class e lungo decenni**, e vedere se sopravvive. Se regge su 26 anni
|
|
di settoriali US, l'ipotesi "e' un artefatto del bear alt 2024-2026" e' molto piu' difficile da
|
|
sostenere. Se non regge, il gate del 23/10 deve saperlo.
|
|
|
|
NON E' IL TEST DEL 25/07. `r0725_statarb_eq.py` ha gia' portato il meccanismo sulle azioni ed e'
|
|
stato SCARTATO (paniere Sharpe -1.00 netta, -0.17 lorda) — ma quello era il meccanismo a **COPPIE**
|
|
(12 coppie a priori, ampiezza effettiva 9.9). XSR01 non e' un paniere di coppie: e' la versione
|
|
**DEMEANATA cross-sezionalmente**, ed e' proprio il demeaning che ha cambiato tutto (ampiezza
|
|
effettiva 4.5 -> 37.4) annullando algebricamente la gamba comune. Testare il demean su equity e'
|
|
quindi una domanda DIVERSA, mai posta. Il risultato lordo del 25/07 (-0.17, cioe' il residuo
|
|
REVERTE debolmente sulle azioni mentre CONTINUA sul crypto) e' l'ipotesi nulla di questo test.
|
|
|
|
DISEGNO
|
|
* meccanismo CONGELATO importato da r0725_statarb_multi (W=45, sgn=+1, residuo OLS causale su
|
|
un fattore comune, z-score, tanh, vol-target 20%, cap 2x) — nessuna reimplementazione;
|
|
* demeaning cross-sezionale giornaliero, identico a r0725_statarb_basket_gate;
|
|
* fattore comune = **SPY** (analogo di BTC: il fattore che il demeaning deve annullare);
|
|
* due universi, entrambi a priori:
|
|
SECT9 = i 9 settoriali SPDR classici (1998+, ~26 anni) — cross-section OMOGENEA, il
|
|
parallelo piu' stretto degli alt (un solo fattore comune forte);
|
|
ALL28 = tutti gli ETF su disco tranne SPY — parallelo del "ALL50" di XSR01;
|
|
* split NON aggiustati riparati in lettura (eqlib.load_eq -> src/data/eq_splits) — senza questo
|
|
IWM/EFA avvelenerebbero il pannello (difetto trovato il 25/07);
|
|
* LORDO **e** netto: la lettura del 25/07 e' che sulle azioni il segno vive nel lordo e il
|
|
turnover se lo mangia. Un venue a PAVIMENTO FISSO (IB) e' anche peggio di una fee
|
|
proporzionale a questa taglia (lezione 25/07) -> il netto proporzionale e' gia' generoso;
|
|
* null di permutazione cross-sezionale **A FEE ZERO** (lezione XSR01: permutare un segnale ne
|
|
fa esplodere il turnover, quindi un null a fee piena perderebbe per COSTO invece che per
|
|
assenza d'informazione, e regalerebbe un p-value trionfale e falso);
|
|
* decomposizione per DECENNIO: 26 anni servono a questo, non a fare una media unica.
|
|
|
|
Uso: `uv run python scripts/research/r0726_xsr_equity.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 eqlib import SECTORS_CLASSIC, load_eq # noqa: E402
|
|
from r0725_statarb_multi import (FEE_LEG, SGN, W, _dd, _sh, signal) # noqa: E402
|
|
|
|
RAW = ROOT / "data" / "raw"
|
|
BASE = "SPY"
|
|
MIN_BARS = 400
|
|
N_PERM = 300
|
|
BLOCK = 20
|
|
SEED = 20260726
|
|
ANN = np.sqrt(252.0) # ⚠️ giorni di BORSA, non 365 (lezione 25/07 sull'annualizzazione)
|
|
|
|
|
|
def all_symbols() -> list[str]:
|
|
out = []
|
|
for p in sorted(RAW.glob("eq_*_1d.parquet")):
|
|
s = p.stem.replace("eq_", "").replace("_1d", "").upper()
|
|
if s != BASE:
|
|
out.append(s)
|
|
return out
|
|
|
|
|
|
def _px(sym: str) -> pd.Series:
|
|
s = load_eq(sym)["close"].astype(float)
|
|
return s[~s.index.duplicated(keep="last")].sort_index()
|
|
|
|
|
|
def build(cols: list[str]) -> tuple[pd.DataFrame, pd.DataFrame]:
|
|
"""Posizioni e spread per ogni gamba, col meccanismo congelato vs SPY."""
|
|
base = _px(BASE)
|
|
P, S = {}, {}
|
|
for sym in cols:
|
|
try:
|
|
t = _px(sym)
|
|
except FileNotFoundError:
|
|
continue
|
|
ix = base.index.intersection(t.index)
|
|
if len(ix) < MIN_BARS:
|
|
continue
|
|
p, s = signal(base[ix], t[ix])
|
|
P[sym] = pd.Series(p, index=ix)
|
|
S[sym] = pd.Series(s, index=ix)
|
|
return (pd.concat(P, axis=1, sort=True).sort_index(),
|
|
pd.concat(S, axis=1, sort=True).sort_index())
|
|
|
|
|
|
def pnl_fee(pos: np.ndarray, spread: np.ndarray, fee_leg: float) -> np.ndarray:
|
|
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 basket(P: pd.DataFrame, S: pd.DataFrame, demean: bool, fee_leg: float) -> pd.Series:
|
|
Q = P.sub(P.mean(axis=1), axis=0) if demean else P
|
|
out = {c: pd.Series(pnl_fee(Q[c].to_numpy(float), S[c].to_numpy(float), fee_leg), index=Q.index)
|
|
for c in Q.columns}
|
|
return pd.concat(out, axis=1, sort=True).mean(axis=1, skipna=True).dropna()
|
|
|
|
|
|
def eff_breadth(P: pd.DataFrame, S: pd.DataFrame, demean: bool) -> float:
|
|
Q = P.sub(P.mean(axis=1), axis=0) if demean else P
|
|
M = pd.concat({c: pd.Series(pnl_fee(Q[c].to_numpy(float), S[c].to_numpy(float), 0.0),
|
|
index=Q.index) for c in Q.columns}, axis=1)
|
|
C = M.corr().values
|
|
n = C.shape[0]
|
|
off = (C.sum() - np.trace(C)) / (n * (n - 1))
|
|
return float(n / (1.0 + (n - 1) * off))
|
|
|
|
|
|
def sh(r) -> float:
|
|
r = np.asarray(r, 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 main() -> None:
|
|
print("=" * 100)
|
|
print(" T3 — XSR01 fuori dal crypto: il meccanismo congelato su 26 anni di ETF settoriali")
|
|
print("=" * 100)
|
|
print(f"\n meccanismo: W={W}, sgn={SGN:+d}, residuo OLS causale vs {BASE}, z-score, tanh,")
|
|
print(" vol-target 20%, cap 2x, demean cross-sezionale giornaliero [CONGELATO, importato]")
|
|
print(f" annualizzazione: sqrt(252) = giorni di BORSA (NON 365)")
|
|
|
|
universes = {
|
|
"SECT9 9 settoriali SPDR (1998+)": SECTORS_CLASSIC,
|
|
"ALL28 tutti gli ETF su disco": all_symbols(),
|
|
}
|
|
|
|
store = {}
|
|
for name, cols in universes.items():
|
|
P, S = build(cols)
|
|
store[name] = (P, S)
|
|
print("\n" + "-" * 100)
|
|
print(f" {name} — {P.shape[1]} gambe, {len(P)} barre, "
|
|
f"{P.index[0].date()} -> {P.index[-1].date()}")
|
|
print("-" * 100)
|
|
print(f" {'variante':>28} {'ampiezza eff.':>14} {'Sharpe LORDA':>13} "
|
|
f"{'Sharpe NETTA':>13} {'maxDD':>8} {'ret/anno':>9}")
|
|
for lab, dm in (("coppie (no demean)", False), ("DEMEAN (= XSR01)", True)):
|
|
g = basket(P, S, dm, 0.0)
|
|
n = basket(P, S, dm, FEE_LEG)
|
|
eb = eff_breadth(P, S, dm)
|
|
cagr = float(np.nanmean(n.values)) * 252.0
|
|
print(f" {lab:>28} {eb:>14.1f} {sh(g):>13.2f} {sh(n):>13.2f} "
|
|
f"{_dd(n.values):>7.1%} {cagr:>8.1%}")
|
|
|
|
# ------------------------------------------------------------------ per decennio
|
|
print("\n" + "-" * 100)
|
|
print(" A. PER DECENNIO — 26 anni servono a questo (variante DEMEAN, la stessa di XSR01)")
|
|
print("-" * 100)
|
|
print(f" {'universo':>34} {'periodo':>14} {'anni':>5} {'Sh LORDA':>9} {'Sh NETTA':>9} {'giorni':>7}")
|
|
for name, (P, S) in store.items():
|
|
g = basket(P, S, True, 0.0)
|
|
n = basket(P, S, True, FEE_LEG)
|
|
for lo, hi in (("1998", "2005"), ("2005", "2012"), ("2012", "2019"), ("2019", "2027")):
|
|
kg = (g.index >= lo) & (g.index < hi)
|
|
kn = (n.index >= lo) & (n.index < hi)
|
|
if kg.sum() < 250:
|
|
continue
|
|
print(f" {name.split()[0]:>34} {lo + '-' + hi:>14} {kg.sum() / 252:>5.1f} "
|
|
f"{sh(g[kg]):>9.2f} {sh(n[kn]):>9.2f} {int(kg.sum()):>7d}")
|
|
|
|
# ------------------------------------------------------------------ null a fee ZERO
|
|
print("\n" + "-" * 100)
|
|
print(" B. NULL DI PERMUTAZIONE CROSS-SEZIONALE — a FEE ZERO (lezione XSR01)")
|
|
print("-" * 100)
|
|
print(" Permutare un segnale ne fa esplodere il turnover: a fee piena il null perderebbe")
|
|
print(" per COSTO invece che per assenza d'informazione -> p-value trionfale e falso.")
|
|
rng = np.random.default_rng(SEED)
|
|
print(f"\n {'universo':>34} {'candidato LORDO':>16} {'null medio':>11} {'null p95':>9} "
|
|
f"{'null max':>9} {'p-value':>8}")
|
|
for name, (P, S) in store.items():
|
|
cand = sh(basket(P, S, True, 0.0))
|
|
Pv = P.to_numpy(float)
|
|
draws = []
|
|
for _ in range(N_PERM):
|
|
idx = rng.permutation(Pv.shape[1])
|
|
Pp = pd.DataFrame(Pv[:, idx], index=P.index, columns=P.columns)
|
|
draws.append(sh(basket(Pp, S, True, 0.0)))
|
|
d = np.array(draws)
|
|
pval = float((d >= cand).mean())
|
|
print(f" {name.split()[0]:>34} {cand:>16.2f} {d.mean():>11.2f} "
|
|
f"{np.percentile(d, 95):>9.2f} {d.max():>9.2f} {pval:>8.3f}")
|
|
|
|
# ------------------------------------------------------------------ confronto col crypto
|
|
print("\n" + "-" * 100)
|
|
print(" C. CONFRONTO DIRETTO col candidato crypto (numeri del 25/07, non ricalcolati)")
|
|
print("-" * 100)
|
|
print(" XSR01 (50 alt HL, 2.6 anni): ampiezza eff. 37.4 | LORDA 2.70 | NETTA 1.82 | DD -2.6%")
|
|
print(" STATARB-EQ a COPPIE (25/07): ampiezza eff. 9.9 | LORDA -0.17 | NETTA -1.00")
|
|
print("\n La domanda del test: il DEMEAN — che sul crypto ha fatto 4.5 -> 37.4 di ampiezza e")
|
|
print(" ha creato l'edge — fa la stessa cosa sulle azioni, o li' il residuo resta reverting?")
|
|
print("\n" + "=" * 100)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|