Files
PythagorasGoal/scripts/research/r0725_statarb_demean_skeptic.py
T
Adriano Dal Pastro b8c9430bd7 feat(research): XSR01 "Cross-Sectional Residual" — candidato nuovo in forward-monitor
Primo candidato da molte ondate a superare marginale-ADDS, deflated-Sharpe 0.95,
tre null avversariali e il test di lag, con corr ~0 a TUTTI e 5 gli sleeve attivi.

COS'E'. Il meccanismo congelato di STATARB-RESID (W=45, sgn=+1, residuo OLS
causale su BTC, z-score, tanh, vol-target 20%) applicato ai 50 alt HL, con
posizioni DEMEANATE cross-sezionalmente ogni giorno. Il demeaning annulla
ALGEBRICAMENTE la gamba BTC comune — sum(p_i-p̄)(r_i-r_btc) = sum(p_i-p̄)r_i —
quindi non e' un paniere di coppie ma una strategia cross-sectional
dollar-neutral, e l'ampiezza effettiva passa da 4.5 a 37.4.
NB: non e' nato da un'idea nuova ma dalla DIAGNOSI di un fallimento (l'ampiezza
effettiva 4.5 indicava un fattore comune da togliere).

NUMERI (2.6 anni): Sharpe netta 1.82 (lorda 2.70), maxDD -2.6%, vol 2.3%.
GATE: marginale ADDS (robust_oos + beats_noise_null + non-hedge +
has_insample_edge); deflated-Sharpe 0.985 PASS; corr TP01 0.060 / XS01 -0.019 /
VRP01 -0.001 / SKH01 0.001 / GTAA01 0.071; book a w=15% HOLD 2.36 -> 2.51,
DD invariato.
SCETTICO (r0725_statarb_demean_skeptic.py): 3 null A FEE-NEUTRALE (permutazione
cross-sezionale / casuale / temporale a blocchi) centrati a ~0.01, candidato
lordo 2.70 sopra il MASSIMO di 300 estrazioni (p<0.004); lag 1.82/1.19/0.81/0.51
a +0/1/2/3g = decadimento dolce di segnale lento, non firma di look-ahead; non
ridondante con XS-momentum semplice (corr 0.17-0.29).
Nota di metodo: i null vanno confrontati A FEE ZERO — permutare un segnale ne fa
esplodere il turnover, quindi a fee piene il null perderebbe per COSTO invece che
per assenza di informazione (p-value trionfale e falso).

NON NEL BOOK, 3 motivi dichiarati: (a) storia 2.6a monoregime e CRESCENTE
(Sh 2024 1.03 / 2025 1.98 / 2026 3.11) -> edge recente; (b) weights_tilt_null
FALLITO (gate_pass=False a 10% e 15%); (c) margine di costo sottile — 1.82 a
0.05%/gamba, 0.93 a 0.10%, NEGATIVA a 0.20%, turnover 38.5% del lordo/g su 50 alt
anche illiquidi con slippage NON modellato (rischio #1).

ESEGUIBILITA': ticket/gamba $1.73 a $600 (sotto min-order $5 -> STAT-MODE oggi),
$5.76 a $2000, $14.41 a $5000 -> diventa reale a ~$5k, non ai ~$20k di XS01.

CABLATO: scripts/live/paper_xsr.py (config CONGELATA, 3 libri MODELED $2000 /
REAL $600 / REAL $5000 con min-order per gamba, stato append-only) in
cron_daily.sh; gate PRE-REGISTRATO a forward-day 0 (r0725_xsr_deploy_gate.py):
decisione 2026-10-23, deploy solo se Sharpe>=1.0 E haircut di eseguibilita' a
$5000 <=40% (se sfonda -> RITIRO a prescindere dallo Sharpe).
tests/test_paper_xsr.py: 7 casi che bloccano config, dollar-neutralita',
causalita' dello step, min-order e la trappola dei timestamp tz-aware
(astype int64 -> epoche 1970, gemella di quella dell'ondata 2026-07-01).

Book e pesi INVARIATI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 09:48:11 +00:00

228 lines
11 KiB
Python

"""SCETTICO su STATARB-DEMEAN (V3) — prima di crederci.
IL CANDIDATO. `r0725_statarb_basket_gate.py` ha prodotto la prima cosa che somiglia a uno sleeve
nuovo da molte ondate: posizioni del meccanismo congelato (W=45, sgn=+1) sulle 50 coppie alt/BTC,
DEMEANATE cross-sezionalmente ogni giorno. Sharpe 1.79, maxDD -2.5%, ampiezza effettiva 37.4,
marginale ADDS, deflated-Sharpe 0.985 (PASS), corr ~0 a tutti e 5 gli sleeve attivi.
Numeri cosi' puliti sono, nella storia di questo progetto, il momento in cui si e' quasi sempre
scoperto un artefatto (feed testnet, look-ahead ffill, CC01 Sharpe 11, celle 0-perdite). Quindi
prima di scrivere "trovata", si prova a UCCIDERLA.
⚠ OSSERVAZIONE STRUTTURALE CHE MOTIVA IL TEST PRINCIPALE. Demeanare annulla algebricamente la
gamba BTC: sum_i (p_i - p̄)(r_i - r_BTC) = sum_i (p_i - p̄) r_i , perche' sum_i (p_i - p̄) = 0.
Quindi V3 NON e' piu' un paniere di coppie: e' una strategia CROSS-SECTIONAL sui 50 alt con pesi
(p_i - p̄). Va quindi testata come tale, e in particolare contro il sospetto che il merito sia
della COSTRUZIONE (demean + vol-target + 50 asset) e non del SEGNALE.
I TEST (un candidato deve sopravvivere a tutti):
T1 NULL DI PERMUTAZIONE CROSS-SEZIONALE — ogni giorno si permutano le etichette-asset del vettore
di posizione. Distribuzione di posizioni, demeaning, vol-target, costi: tutto identico; sparisce
SOLO l'abbinamento segnale<->asset. Se il candidato non batte questo null, l'edge e' costruzione.
T2 NULL CASUALE — posizioni casuali tanh(N(0,1)) con la stessa scalatura e lo stesso demeaning.
T3 NULL DI PERMUTAZIONE TEMPORALE a blocchi (20g) — rompe l'allineamento nel tempo.
T4 LAG — ritardare la posizione di 1 e 2 giorni. Un segnale lento degrada dolcemente; un
look-ahead crolla a zero immediatamente.
T5 SOTTOPERIODI anno per anno — 2.6 anni sono un regime solo (bear alt): dove vive il rendimento?
T6 COSTI — la fee attuale addebita 2 gambe per coppia, ma dopo il demeaning la gamba BTC si
annulla: e' quindi SOVRASTIMATA. Si verifica comunque a 0.05 / 0.10 / 0.20% per gamba.
T7 RIDONDANZA con un momentum cross-sectional semplice sugli stessi 50 (e' XS01 travestito?).
T8 STRUTTURA — esposizione lorda/netta, bilanciamento long/short, turnover, gambe che si muovono.
uv run python scripts/research/r0725_statarb_demean_skeptic.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 FEE_LEG, SEED, _dd, _sh
from r0725_statarb_basket_gate import pair_frames
ANN = np.sqrt(365.0)
N_NULL = 300
BLOCK = 20
def ret_from_pos(P: pd.DataFrame, S: pd.DataFrame, fee_leg: float = FEE_LEG,
lag: int = 0, demean: bool = True) -> pd.Series:
"""Rendimento EW del paniere da una matrice di posizioni [data x asset]. `lag` giorni in piu'."""
Q = P.sub(P.mean(axis=1), axis=0) if demean else P
q = Q.to_numpy(float)
s = S.to_numpy(float)
held = np.zeros_like(q)
k = 1 + lag
if k < len(q):
held[k:] = q[:-k]
turn = np.abs(np.diff(held, axis=0, prepend=np.zeros((1, q.shape[1]))))
net = held * s - 2.0 * fee_leg * turn
return pd.Series(np.nanmean(net, axis=1), index=P.index).dropna()
def main() -> None:
print("=" * 100)
print(" SCETTICO — STATARB-DEMEAN (V3): si riesce a ucciderla?")
print("=" * 100)
P, S = pair_frames()
P = P.fillna(0.0)
S = S.reindex(P.index)[P.columns]
base = ret_from_pos(P, S)
sh0 = _sh(base.values)
print(f"\n candidato: {P.shape[1]} asset, {len(P)} barre "
f"({P.index[0].date()} -> {P.index[-1].date()})")
print(f" Sharpe {sh0:.2f} maxDD {_dd(base.values)*100:.1f}% "
f"vol ann {base.std()*ANN*100:.2f}% ret ann {base.mean()*365*100:.2f}%")
rng = np.random.default_rng(SEED)
q = P.to_numpy(float)
# ⚠ I NULL SI CONFRONTANO A FEE ZERO. Permutare le posizioni ne fa esplodere il turnover
# (un asset riceve ogni giorno una posizione scorrelata dalla precedente), quindi un null a
# fee piene sarebbe battuto per motivi di COSTO e non di segnale: darebbe un p-value
# artificialmente trionfale. A fee zero il confronto isola l'informazione.
sh0_gross = _sh(ret_from_pos(P, S, fee_leg=0.0).values)
print(f" Sharpe LORDA (fee 0) {sh0_gross:.2f} <- e' questa che i null devono battere")
# ---------------- T1 permutazione cross-sezionale
print("\n" + "-" * 100)
print(" T1 NULL DI PERMUTAZIONE CROSS-SEZIONALE — il test decisivo")
print(" (stessa distribuzione di posizioni, stesso demeaning, stesso vol-target e costi;")
print(" si rompe SOLO l'abbinamento segnale<->asset)")
print("-" * 100)
nulls = []
for _ in range(N_NULL):
qq = np.take_along_axis(q, rng.permuted(np.tile(np.arange(q.shape[1]), (q.shape[0], 1)),
axis=1), axis=1)
nulls.append(_sh(ret_from_pos(pd.DataFrame(qq, index=P.index, columns=P.columns), S, fee_leg=0.0).values))
nulls = np.array(nulls)
p1 = float((nulls >= sh0_gross).mean())
print(f" null: media {nulls.mean():>6.2f} mediana {np.median(nulls):>6.2f} "
f"p95 {np.percentile(nulls,95):>6.2f} max {nulls.max():>6.2f}")
print(f" candidato LORDO {sh0_gross:.2f} -> p = {p1:.4f} "
f"{'SOPRAVVIVE' if p1 < 0.05 else '*** UCCISO: l edge e la COSTRUZIONE, non il segnale ***'}")
# ---------------- T2 posizioni casuali
print("\n" + "-" * 100)
print(" T2 NULL CASUALE — posizioni tanh(N(0,1)) con stessa scala, demeaning e costi")
print("-" * 100)
scale = np.abs(q).mean()
r2 = []
for _ in range(N_NULL):
qq = np.tanh(rng.normal(0, 1, size=q.shape)) * scale / max(np.abs(np.tanh(1.0)), 1e-9)
qq[q == 0.0] = 0.0 # stessa maschera di "non ancora attivo"
r2.append(_sh(ret_from_pos(pd.DataFrame(qq, index=P.index, columns=P.columns), S, fee_leg=0.0).values))
r2 = np.array(r2)
p2 = float((r2 >= sh0_gross).mean())
print(f" null: media {r2.mean():>6.2f} p95 {np.percentile(r2,95):>6.2f} max {r2.max():>6.2f}"
f" -> p = {p2:.4f} {'SOPRAVVIVE' if p2 < 0.05 else 'UCCISO'}")
# ---------------- T3 permutazione temporale a blocchi
print("\n" + "-" * 100)
print(" T3 NULL DI PERMUTAZIONE TEMPORALE (blocchi 20g)")
print("-" * 100)
nb = int(np.ceil(len(q) / BLOCK))
r3 = []
for _ in range(N_NULL):
order = rng.permutation(nb)
qq = np.concatenate([q[i * BLOCK:(i + 1) * BLOCK] for i in order])[:len(q)]
r3.append(_sh(ret_from_pos(pd.DataFrame(qq, index=P.index, columns=P.columns), S, fee_leg=0.0).values))
r3 = np.array(r3)
p3 = float((r3 >= sh0_gross).mean())
print(f" null: media {r3.mean():>6.2f} p95 {np.percentile(r3,95):>6.2f} -> p = {p3:.4f}"
f" {'SOPRAVVIVE' if p3 < 0.05 else 'UCCISO'}")
# ---------------- T4 lag
print("\n" + "-" * 100)
print(" T4 LAG — un segnale lento degrada dolcemente, un look-ahead crolla")
print("-" * 100)
for lag in (0, 1, 2, 3):
r = ret_from_pos(P, S, lag=lag)
print(f" lag +{lag}g: Sharpe {_sh(r.values):>5.2f} maxDD {_dd(r.values)*100:>5.1f}%")
# ---------------- T5 sottoperiodi
print("\n" + "-" * 100)
print(" T5 SOTTOPERIODI — 2.6 anni sono UN regime; dove vive il rendimento?")
print("-" * 100)
for y in sorted(set(base.index.year)):
w = base[base.index.year == y]
if len(w) < 40:
continue
print(f" {y}: n={len(w):>4} Sharpe {_sh(w.values):>5.2f} "
f"ret {(np.prod(1+w.values)-1)*100:>6.2f}% maxDD {_dd(w.values)*100:>5.1f}%")
half = len(base) // 2
print(f" prima meta' Sharpe {_sh(base.values[:half]):>5.2f} "
f"seconda meta' {_sh(base.values[half:]):>5.2f}")
# ---------------- T6 costi
print("\n" + "-" * 100)
print(" T6 COSTI — nota: dopo il demeaning la gamba BTC si annulla, quindi addebitare")
print(" 2 gambe per coppia SOVRASTIMA i costi. Si verifica comunque al rialzo.")
print("-" * 100)
for f in (0.0, 0.0005, 0.0010, 0.0020, 0.0040):
r = ret_from_pos(P, S, fee_leg=f)
print(f" fee {f*100:>5.2f}%/gamba: Sharpe {_sh(r.values):>5.2f} "
f"ret ann {r.mean()*365*100:>6.2f}%")
# ---------------- T7 ridondanza con XS momentum semplice
print("\n" + "-" * 100)
print(" T7 RIDONDANZA — e' un momentum cross-sectional travestito?")
print("-" * 100)
from r0725_statarb_multi import load_hl, universe
px = {}
for s_ in universe():
try:
px[s_] = load_hl(s_)
except FileNotFoundError:
pass
PX = pd.concat(px, axis=1, sort=True).reindex(P.index).ffill()
for L in (30, 45, 90):
mom = np.log(PX / PX.shift(L))
z = mom.sub(mom.mean(axis=1), axis=0).div(mom.std(axis=1).replace(0, np.nan), axis=0)
w = np.tanh(z.fillna(0.0))
w = w.sub(w.mean(axis=1), axis=0)
rx = ret_from_pos(w[P.columns].fillna(0.0), S, demean=False)
j = pd.concat({"a": base, "b": rx}, axis=1, sort=True).dropna()
print(f" XS-mom L={L:>3}: Sharpe {_sh(rx.values):>5.2f} "
f"corr al candidato {j['a'].corr(j['b']):>6.3f}")
# ---------------- T8 struttura
print("\n" + "-" * 100)
print(" T8 STRUTTURA — esposizione, bilanciamento, turnover, gambe operative")
print("-" * 100)
Q = P.sub(P.mean(axis=1), axis=0)
gross = Q.abs().sum(axis=1)
net = Q.sum(axis=1)
nlong = (Q > 0).sum(axis=1)
turn = Q.diff().abs().sum(axis=1)
print(f" esposizione LORDA media {gross.mean():>6.2f} (mediana {gross.median():.2f})")
print(f" esposizione NETTA media {net.mean():>8.2e} (deve essere ~0 per costruzione)")
print(f" gambe long/giorno media {nlong.mean():>5.1f} su {P.shape[1]} "
f"(bilanciamento atteso ~50%)")
print(f" turnover lordo/giorno {turn.mean():>6.2f} = {turn.mean()/max(gross.mean(),1e-9)*100:>5.1f}% del lordo")
n_ass = max(P.shape[1], 1)
# il rendimento e' la MEDIA sugli asset -> il peso di portafoglio della gamba i e' held_i/N.
# Quindi il lordo del paniere e' gross/N, e il nozionale per gamba e' cap*|held_i|/N.
gross_frac = gross.mean() / n_ass
print(f" lordo del paniere {gross_frac*100:>5.1f}% del capitale (leva {gross_frac:.2f}x)")
for cap in (600, 2000, 5000, 20000):
tk = cap * gross_frac / n_ass
print(f" cap ${cap:>6}: ticket medio per gamba ${tk:>7.2f} "
f"{'-> sotto il min-order $5: NON eseguibile' if tk < 5 else '-> sopra il min-order'}")
print("\n" + "=" * 100)
verdict = (p1 < 0.05 and p2 < 0.05 and p3 < 0.05)
print(f" ESITO SCETTICO: {'i tre null NON la uccidono' if verdict else 'UCCISA da almeno un null'}")
print("=" * 100)
if __name__ == "__main__":
main()