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>
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
"""STATARB-BASKET — il paniere multi-coppia e' uno SLEEVE? Giudizio coi gate veri del progetto.
|
||||
|
||||
DA DOVE VIENE. `r0725_statarb_multi.py` ha applicato il meccanismo CONGELATO (W=45, sgn=+1, preso
|
||||
da un altro studio: qui non e' stato cercato nulla) alle 50 coppie alt/BTC di Hyperliquid e ha
|
||||
trovato un paniere EW con Sharpe 0.82, maxDD -6.0%, corr a XS01 solo 0.207, 0/50 coppie degeneri.
|
||||
Quello script pero' si fermava alla statistica descrittiva. Questo lo porta davanti ai gate che il
|
||||
progetto usa per ammettere uno sleeve:
|
||||
|
||||
study/marginal_vs_tp01 -> ADDS / HEDGE / NOISE / REDUNDANT / DILUTES / NEUTRAL
|
||||
(include multi-cut, noise-null a corr-zero, hedge-vs-alpha)
|
||||
deflated_sharpe -> PASS >= 0.95, coi trial DAVVERO fatti
|
||||
weights_tilt_null -> ogni proposta di peso vs il null dei tilt casuali
|
||||
corr per-sleeve -> ridondanza contro i 5 sleeve attivi
|
||||
|
||||
PRECEDENTE CHE RENDE LA DOMANDA LEGITTIMA: XS01 e' nel book al 15% pur essendo STAT-MODE (19 gambe,
|
||||
non eseguibili a $600). Un paniere di coppie a 51 gambe sarebbe ammissibile alle STESSE condizioni,
|
||||
se supera i gate. Non e' quindi l'eseguibilita' a decidere qui, e' l'edge.
|
||||
|
||||
VARIANTI PRE-REGISTRATE (3, contate nel deflated-Sharpe; nessuna scelta guardando i risultati):
|
||||
V1 ALL50 — tutte le coppie alt/BTC valutabili. Zero liberta' di selezione.
|
||||
V2 MAJ19 — solo i 19 major liquidi, cioe' l'universo XS_UNIVERSE definito il 2026-06-19 per
|
||||
ragioni di LIQUIDITA' in un altro studio: sottoinsieme a priori, non scelto oggi.
|
||||
V3 DEMEAN — ALL50 con le posizioni demeanate cross-sezionalmente ogni giorno. Motivo strutturale
|
||||
dichiarato prima di guardare: le 50 coppie condividono la gamba BTC, per questo
|
||||
l'ampiezza effettiva era 4.5 invece di 50; togliere la componente comune dovrebbe
|
||||
alzarla. E' una costruzione standard (neutralizzare il fattore comune), non un fit.
|
||||
|
||||
uv run python scripts/research/r0725_statarb_basket_gate.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"))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
|
||||
|
||||
from r0725_statarb_multi import (BASE, BLOCK, MIN_BARS, SEED, load_hl, pnl, signal, universe,
|
||||
_dd, _sh)
|
||||
|
||||
ANN = np.sqrt(365.0)
|
||||
|
||||
|
||||
def pair_frames() -> tuple[pd.DataFrame, pd.DataFrame]:
|
||||
"""Matrici [data x coppia] di posizione e ritorno-spread, per tutte le coppie valutabili."""
|
||||
base_px = load_hl(BASE)
|
||||
pos_cols, spr_cols = {}, {}
|
||||
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
|
||||
p, s = signal(base_px[ix], tgt[ix])
|
||||
pos_cols[sym] = pd.Series(p, index=ix)
|
||||
spr_cols[sym] = pd.Series(s, index=ix)
|
||||
P = pd.concat(pos_cols, axis=1, sort=True).sort_index()
|
||||
S = pd.concat(spr_cols, axis=1, sort=True).sort_index()
|
||||
return P, S
|
||||
|
||||
|
||||
def basket_from_positions(P: pd.DataFrame, S: pd.DataFrame, cols=None,
|
||||
demean: bool = False) -> pd.Series:
|
||||
"""Rendimento EW del paniere, ricalcolando fee su ogni gamba (2 per coppia)."""
|
||||
if cols is not None:
|
||||
cols = [c for c in cols if c in P.columns]
|
||||
P, S = P[cols], S[cols]
|
||||
if demean:
|
||||
P = P.sub(P.mean(axis=1), axis=0)
|
||||
out = {}
|
||||
for c in P.columns:
|
||||
p = P[c].to_numpy(float)
|
||||
s = S[c].to_numpy(float)
|
||||
out[c] = pd.Series(pnl(p, s), index=P.index)
|
||||
return pd.concat(out, axis=1, sort=True).mean(axis=1, skipna=True).dropna()
|
||||
|
||||
|
||||
def eff_breadth(P: pd.DataFrame, S: pd.DataFrame, cols=None, demean=False) -> float:
|
||||
if cols is not None:
|
||||
cols = [c for c in cols if c in P.columns]
|
||||
P, S = P[cols], S[cols]
|
||||
if demean:
|
||||
P = P.sub(P.mean(axis=1), axis=0)
|
||||
R = {}
|
||||
for c in P.columns:
|
||||
R[c] = pd.Series(pnl(P[c].to_numpy(float), S[c].to_numpy(float)), index=P.index)
|
||||
M = pd.concat(R, axis=1, sort=True)
|
||||
C = M.corr().values
|
||||
off = C[~np.eye(len(C), dtype=bool)]
|
||||
rbar = float(np.nanmean(off))
|
||||
return len(C) / (1.0 + (len(C) - 1) * rbar) if rbar > -1 / (len(C) - 1) else float(len(C))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("=" * 100)
|
||||
print(" STATARB-BASKET — il paniere multi-coppia supera i gate di ammissione a sleeve?")
|
||||
print("=" * 100)
|
||||
|
||||
from src.portfolio.sleeves import XS_UNIVERSE
|
||||
|
||||
P, S = pair_frames()
|
||||
print(f"\n coppie valutabili: {P.shape[1]} barre: {len(P)} "
|
||||
f"da {P.index[0].date()} a {P.index[-1].date()}")
|
||||
|
||||
maj = [s for s in XS_UNIVERSE if s != BASE]
|
||||
variants = {
|
||||
"V1 ALL50": dict(cols=None, demean=False),
|
||||
"V2 MAJ19": dict(cols=maj, demean=False),
|
||||
"V3 DEMEAN": dict(cols=None, demean=True),
|
||||
}
|
||||
series, srs = {}, []
|
||||
print("\n" + "-" * 100)
|
||||
print(" (1) LE TRE VARIANTI PRE-REGISTRATE")
|
||||
print("-" * 100)
|
||||
print(f" {'variante':<12}{'gambe':>7}{'Sharpe':>9}{'maxDD':>9}{'ret tot':>10}{'ampiezza eff':>15}")
|
||||
for nm, kw in variants.items():
|
||||
r = basket_from_positions(P, S, **kw)
|
||||
series[nm] = r
|
||||
srs.append(_sh(r.values))
|
||||
nlegs = (P.shape[1] if kw["cols"] is None else len([c for c in kw["cols"] if c in P.columns]))
|
||||
print(f" {nm:<12}{nlegs:>7}{_sh(r.values):>9.2f}{_dd(r.values)*100:>8.1f}%"
|
||||
f"{(np.prod(1+r.values)-1)*100:>9.1f}%{eff_breadth(P, S, **kw):>15.1f}")
|
||||
|
||||
# ---------------- gate del progetto su ciascuna variante
|
||||
from altlib import deflated_sharpe, marginal_vs_tp01
|
||||
|
||||
print("\n" + "-" * 100)
|
||||
print(" (2) GATE MARGINALE vs TP01 (il gate che decide, non lo Sharpe assoluto)")
|
||||
print("-" * 100)
|
||||
reports = {}
|
||||
for nm, r in series.items():
|
||||
rr = r.copy()
|
||||
rr.index = pd.to_datetime(rr.index, utc=True)
|
||||
m = marginal_vs_tp01(rr)
|
||||
reports[nm] = m
|
||||
print(f"\n --- {nm} ---")
|
||||
print(f" verdetto : {m.get('marginal_verdict')}")
|
||||
for k in ("corr", "robust_oos", "beats_noise_null", "is_hedge", "has_insample_edge",
|
||||
"tp01_beta", "alpha_ann"):
|
||||
if k in m:
|
||||
v = m[k]
|
||||
print(f" {k:<15}: {v if not isinstance(v, float) else round(v, 4)}")
|
||||
bl = m.get("blends", {})
|
||||
for w, d in (bl.items() if isinstance(bl, dict) else []):
|
||||
if isinstance(d, dict):
|
||||
print(f" blend w={w}: " + " ".join(
|
||||
f"{k}={round(v,3) if isinstance(v,(int,float)) else v}" for k, v in d.items()))
|
||||
|
||||
# ---------------- deflated Sharpe coi trial veri
|
||||
print("\n" + "-" * 100)
|
||||
print(" (3) DEFLATED SHARPE — trial reali = 3 varianti (la config W=45/sgn=+1 viene da un")
|
||||
print(" altro studio: su QUESTI dati non e' stata cercata)")
|
||||
print("-" * 100)
|
||||
for nm, r in series.items():
|
||||
dsr, null_max = deflated_sharpe(_sh(r.values), srs, r.values, dpy=365.0)
|
||||
print(f" {nm:<12} Sharpe {_sh(r.values):>5.2f} DSR {dsr:>6.3f} "
|
||||
f"(max atteso sotto il null {null_max:>5.2f}) {'PASS' if dsr >= 0.95 else 'sotto 0.95'}")
|
||||
|
||||
# ---------------- correlazione ai 5 sleeve attivi + tilt-null
|
||||
print("\n" + "-" * 100)
|
||||
print(" (4) RIDONDANZA vs I 5 SLEEVE ATTIVI + weights_tilt_null sul peso proposto")
|
||||
print("-" * 100)
|
||||
from src.portfolio.portfolio import Sleeve, StrategyPortfolio, weights_tilt_null
|
||||
from src.portfolio.sleeves import active_sleeves
|
||||
|
||||
sl = active_sleeves()
|
||||
best = max(series.items(), key=lambda kv: _sh(kv[1].values))
|
||||
nm_best, r_best = best
|
||||
rb = r_best.copy()
|
||||
rb.index = pd.to_datetime(rb.index, utc=True)
|
||||
print(f" variante col miglior Sharpe: {nm_best}")
|
||||
for s in sl:
|
||||
j = pd.concat({"a": s.daily(), "b": rb}, axis=1, sort=True).dropna()
|
||||
if len(j) > 60:
|
||||
print(f" corr -> {s.name:<20} {j['a'].corr(j['b']):>6.3f} ({len(j)} giorni comuni)")
|
||||
|
||||
daily_cols = {s.name: s.daily() for s in sl}
|
||||
daily_cols["STATARB_BASKET"] = rb
|
||||
w_cur = {s.name: s.weight for s in sl}
|
||||
w_cur["STATARB_BASKET"] = 0.0
|
||||
for w_new in (0.10, 0.15):
|
||||
k = 1.0 - w_new
|
||||
w_prop = {s.name: s.weight * k for s in sl}
|
||||
w_prop["STATARB_BASKET"] = w_new
|
||||
try:
|
||||
res = weights_tilt_null(daily_cols, w_cur, w_prop)
|
||||
print(f"\n peso {w_new:.0%} (i 5 scalati x{k:.2f}):")
|
||||
for kk, vv in res.items():
|
||||
if isinstance(vv, (int, float, bool, str)):
|
||||
print(f" {kk:<22} {round(vv,4) if isinstance(vv,float) else vv}")
|
||||
except Exception as e:
|
||||
print(f" [tilt-null non calcolabile a w={w_new}: {e.__class__.__name__}: {e}]")
|
||||
|
||||
# ---------------- book con/senza
|
||||
print("\n" + "-" * 100)
|
||||
print(" (5) BOOK CON E SENZA — Sharpe/DD FULL e HOLD-OUT")
|
||||
print("-" * 100)
|
||||
b0 = StrategyPortfolio(sl).backtest()
|
||||
for w_new in (0.10, 0.15):
|
||||
k = 1.0 - w_new
|
||||
sl2 = [Sleeve(s.name, s.weight * k, s.daily_fn, s.pos_fn) for s in sl]
|
||||
sl2.append(Sleeve("STATARB_BASKET", w_new, lambda _r=rb: _r))
|
||||
b1 = StrategyPortfolio(sl2).backtest()
|
||||
print(f" w={w_new:.0%} FULL {b0['full']['sharpe']:.2f} -> {b1['full']['sharpe']:.2f} "
|
||||
f"HOLD {b0['holdout']['sharpe']:.2f} -> {b1['holdout']['sharpe']:.2f} "
|
||||
f"DD {b0['full']['maxdd']*100:.1f}% -> {b1['full']['maxdd']*100:.1f}%")
|
||||
|
||||
print("\n" + "=" * 100)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,227 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,100 @@
|
||||
"""r0725_xsr_deploy_gate — gate di decisione PRE-REGISTRATO per XSR01 (fissato 2026-07-25).
|
||||
|
||||
PERCHE' PRE-REGISTRATO. La regola si fissa oggi, forward-day 0, PRIMA di vedere qualunque barra
|
||||
out-of-sample. Decidere "a occhio" dopo aver visto i numeri e' selection-on-forward — il bias che
|
||||
questo progetto ha gia' pagato due volte (gate SELECTION-ON-HOLDOUT 2026-06-29; EW-STR refutato
|
||||
come selezione di 2° ordine 2026-07-01). Stesso trattamento gia' dato a STATARB-RESID.
|
||||
|
||||
CONTESTO. XSR01 e' il candidato del 2026-07-25: cross-sectional residual dollar-neutral sui 50 alt
|
||||
HL. In-sample (2.6 anni) fa Sharpe netta 1.82 / lorda 2.70, maxDD -2.6%, marginale ADDS,
|
||||
deflated-Sharpe 0.985, corr ~0 a tutti e 5 gli sleeve. Sopravvive a tre null fee-neutrali e al
|
||||
test di lag. NON e' nel book per tre motivi dichiarati: storia corta e monotona crescente,
|
||||
`weights_tilt_null` fallito, e margine di costo sottile con slippage NON modellato.
|
||||
|
||||
REGOLA (immutabile — ogni modifica va motivata nel diario come violazione):
|
||||
- Data decisione: 2026-10-23 (90 giorni forward dal 2026-07-25). Non prima.
|
||||
- Metrica primaria: Sharpe annualizzato di `net_modeled` su TUTTA la finestra forward.
|
||||
- GUARDIA DI COSTO, la piu' importante per questa strategia (turnover 38.5% del lordo/giorno su
|
||||
50 alt anche illiquidi): il rapporto Sharpe_forward / Sharpe_in_sample_netta(1.82) va letto
|
||||
insieme allo scarto MODELED-vs-REAL$5000. Se l'haircut di eseguibilita' a $5000 supera il 40%
|
||||
del rendimento modellato, la strategia e' un artefatto di costo e va RITIRATA a prescindere
|
||||
dallo Sharpe.
|
||||
- Esiti:
|
||||
Sharpe >= 1.0 AND haircut$5000 <= 40% -> CANDIDATO SLEEVE: proporre peso {5,10}% e
|
||||
giudicarlo con `weights_tilt_null` (che oggi NON passa) + maxDD combinato.
|
||||
Deploy solo se il tilt-null passa E il capitale e' >= $5000.
|
||||
0.3 <= Sh < 1.0 -> ESTENDI una sola volta di 90g (2027-01-21).
|
||||
Sharpe < 0.3 OR haircut > 40% -> RITIRO dal forward-monitor.
|
||||
- Guardie accessorie: config invariata (il test `tests/test_paper_xsr.py` la blocca);
|
||||
universo costante a 50 gambe (un cambio impone --reset e azzera la finestra).
|
||||
|
||||
Uso: `uv run python scripts/research/r0725_xsr_deploy_gate.py`
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
RETURNS = ROOT / "data" / "paper_xsr" / "returns.jsonl"
|
||||
|
||||
START = date(2026, 7, 25)
|
||||
DECISION = date(2026, 10, 23)
|
||||
DECISION_EXT = date(2027, 1, 21)
|
||||
SH_DEPLOY = 1.0
|
||||
SH_RETIRE = 0.3
|
||||
HAIRCUT_MAX = 0.40
|
||||
IS_SHARPE_NET = 1.82 # riferimento in-sample, per contesto
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("=" * 92)
|
||||
print(" XSR01 — gate di decisione PRE-REGISTRATO (fissato 2026-07-25, forward-day 0)")
|
||||
print("=" * 92)
|
||||
if not RETURNS.exists():
|
||||
print(f" nessun dato forward ancora: {RETURNS} assente.")
|
||||
print(f" data decisione: {DECISION} (mancano {(DECISION - date.today()).days} giorni)")
|
||||
return
|
||||
rows = [json.loads(x) for x in RETURNS.read_text().splitlines() if x.strip()]
|
||||
if len(rows) < 5:
|
||||
print(f" finestra forward troppo corta ({len(rows)} barre).")
|
||||
print(f" data decisione: {DECISION} (mancano {(DECISION - date.today()).days} giorni)")
|
||||
return
|
||||
rm = np.array([r["net_modeled"] for r in rows])
|
||||
key5 = "net_REAL-$5000"
|
||||
r5 = np.array([r.get(key5, np.nan) for r in rows])
|
||||
sh = float(rm.mean() / rm.std() * np.sqrt(365)) if rm.std() > 0 else 0.0
|
||||
eq = np.cumprod(1 + rm)
|
||||
dd = float(np.max((np.maximum.accumulate(eq) - eq) / np.maximum.accumulate(eq)))
|
||||
tot_m = float(np.prod(1 + rm) - 1)
|
||||
tot_5 = float(np.prod(1 + r5[np.isfinite(r5)]) - 1) if np.isfinite(r5).any() else float("nan")
|
||||
haircut = (tot_m - tot_5) / abs(tot_m) if tot_m not in (0.0,) and np.isfinite(tot_5) else float("nan")
|
||||
today = date.today()
|
||||
|
||||
print(f" finestra forward : {rows[0]['dt'][:10]} -> {rows[-1]['dt'][:10]} ({len(rows)} barre)")
|
||||
print(f" Sharpe fwd (mod) : {sh:+.2f} (in-sample netta era {IS_SHARPE_NET:.2f})")
|
||||
print(f" maxDD fwd : {dd:.1%}")
|
||||
print(f" ritorno MODELED : {tot_m*100:+.2f}% REAL-$5000: {tot_5*100:+.2f}%")
|
||||
print(f" haircut eseguib. : {haircut*100:.1f}% (guardia <= {HAIRCUT_MAX*100:.0f}%)")
|
||||
print(f" data decisione : {DECISION} (proroga unica: {DECISION_EXT})")
|
||||
|
||||
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).")
|
||||
return
|
||||
if np.isfinite(haircut) and haircut > HAIRCUT_MAX:
|
||||
print("\n -> RITIRO: l'haircut di eseguibilita' supera la guardia. E' costo, non edge.")
|
||||
elif sh >= SH_DEPLOY:
|
||||
print("\n -> CANDIDATO SLEEVE: proporre peso {5,10}% e passarlo a weights_tilt_null "
|
||||
"(oggi NON passa) + maxDD combinato. Deploy solo con capitale >= $5000.")
|
||||
elif sh >= SH_RETIRE:
|
||||
print(f"\n -> SOTTO SOGLIA ma >= {SH_RETIRE}: estensione unica fino al {DECISION_EXT}.")
|
||||
else:
|
||||
print("\n -> RITIRO dal forward-monitor.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user