feat(stability): sweep stabilità — fix TR01 mean(rets), XS01 phase-tranching K=3, z-stop pairs bocciato

Audit anti-overfit su tutte le 19 sleeve (diario 2026-06-11-stability-sweep.md):

- FIX BasketTrendWorker: mean(rets) sui soli asset in posizione sovrappesava N/k
  a paniere parziale (1 long = 0.45 del capitale invece di 0.09) -> replay -44%
  vs ref +42%. Ora sum(rets)/N (convenzione canonica 1/N): replay +32% vs +42%
  (residuo = convenzione dichiarata). Solo statistica PAPER.
- XS01 PHASE-TRANCHING (gate xs01_tranche_gate: plateau K=2 E K=3 promossi,
  PORT06 OOS Sh 10.07->10.15 DD 1.48->1.38, FULL pari): la fase del roll e'
  timing-luck (Sharpe daily 1.52-2.33, DD 13.8-33% sulle 12 fasi). Worker con
  param tranches (default 1), 3 sub-book sfasati hold/3 su capitale comune,
  migrazione status legacy, last_bar_ts solo-avanti; runner forward del param;
  _defs tranches=3; hourly_report aggrega i sub-book; validatore esteso e
  PASSATO (K=1 == xsec_sim esatto, K=3 == unione fasi esatto).
- Disaster-cap z sui pairs: pre-registrato e BOCCIATO su tutti i criteri (coda
  OOS peggiora 4/6 coppie, Sharpe -10..-49%, plateau solo del danno; 5a conferma
  stop-su-MR). Record pairs_zstop_research.py; pairs restano senza stop.
- Audit drift: regression-lock trendmax OK (parita' 1.00000, plateau 2.5/3.0/3.5
  confermato), correlazioni cross-famiglia ~0 invariate; PORT06 rolling al
  19-28mo pct (normale) ma FADE 120g al 2o percentile storico -> monitor in TODO
  (nessun ritocco parametri).
- TODO: forming-bar ROT02/TSM01 era gia' fixato (v1.1.10), item chiuso.

Test: pytest 99 passed; validate_honest_workers OK; validate_xsec_worker OK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-11 13:29:14 +00:00
parent ba5ec7bd6b
commit 9d15506b05
12 changed files with 693 additions and 72 deletions
+136
View File
@@ -0,0 +1,136 @@
"""XS01 phase-tranching — PRE-REGISTRATO: il roll non-sovrapposto di XS01 (entry a i,
exit a i+hold, re-entry) ha una FASE arbitraria (dipende dal primo indice valido).
Se l'esito dipende dalla fase, c'e' timing-luck: dividere il book in K sub-book
sfasati di hold/K barre (capitale 1/K ciascuno) e' un ensemble di fase che riduce
la varianza SENZA parametri fittati (K=2 e K=3 riportati entrambi, nessun best-pick).
Test (griglia fissata qui, completa):
[1] SENSIBILITA' DI FASE: xsec_sim base con offset di partenza 0..hold-1
-> dispersione di Sharpe/ret/DD (FULL e OOS). Se la dispersione e' piccola,
il tranching non serve (verdetto onesto, fine).
[2] TRANCHING: K in {2, 3} sub-book sfasati equal-capital -> Sharpe/DD/ret
FULL e OOS vs la MEDIA e il WORST delle fasi singole.
Criterio (tutti necessari): il tranched riduce la dispersione (per costruzione) e
il suo Sharpe OOS >= worst-fase OOS con DD <= mediana fasi; fee identiche (il
tranching NON cambia il turnover per unita' di capitale).
uv run python scripts/analysis/xs01_tranche_research.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from scripts.strategies.XS01_cross_sectional import (
aligned_panel, UNIVERSE, LB, HOLD, FEE_RT, LEV, POS, OOS_FRAC)
def xsec_trades(phase: int = 0, lb: int = LB, hold: int = HOLD, fee_rt: float = FEE_RT,
split_frac: float = 0.0, M=None):
"""Replica ESATTA della logica di xsec_sim ma parte da max(lb, split)+phase e
ritorna la lista trade (i, j, net) — stessa matematica, fee = 2*fee_rt."""
C = M[UNIVERSE].values
n = len(C)
logC = np.log(C)
split = int(n * split_frac)
fee = 2 * fee_rt
out = []
last = -1
i = max(lb, split) + phase
while i < n - hold:
if i <= last:
i += 1
continue
dm = logC[i] - logC[i - lb]
dm = dm - dm.mean()
gw = np.sum(np.abs(dm))
if gw < 1e-9:
i += 1
continue
w = -dm / gw
book = float(np.sum(w * (logC[i + hold] - logC[i])))
out.append((i, i + hold, book - fee))
last = i + hold
i += 1
return out
def equity_from(trades, ts, pos=POS, lev=LEV, weight=1.0):
"""Equity compounding a punti-exit (convenzione xsec_sim), peso per tranche."""
cap = 1000.0
eq_ts, eq_v = [], []
for i, j, net in sorted(trades, key=lambda t: t[1]):
cap = max(cap + cap * pos * lev * net * weight, 10.0)
eq_ts.append(ts[j])
eq_v.append(cap)
return pd.Series(eq_v, index=pd.DatetimeIndex(eq_ts))
def metrics(trades, ts, yrs_span):
rets = [t[2] * POS for t in trades]
n = len(rets)
sharpe = float(np.mean(rets) / np.std(rets) * np.sqrt(n / yrs_span)) if n > 1 and np.std(rets) > 0 else 0.0
eq = equity_from(trades, ts)
pk = eq.cummax()
dd = float(((pk - eq) / pk).max() * 100) if len(eq) else 0.0
ret = float((eq.iloc[-1] / 1000 - 1) * 100) if len(eq) else 0.0
return dict(n=n, ret=ret, sharpe=sharpe, dd=dd)
def combined_metrics(branches, ts, yrs_span):
"""K tranche -> un'unica equity: pesa ogni trade 1/K sul capitale comune."""
K = len(branches)
allt = sorted([t for b in branches for t in b], key=lambda t: t[1])
cap = 1000.0
eq_ts, eq_v, rets = [], [], []
for i, j, net in allt:
rets.append(net * POS / K)
cap = max(cap + cap * POS * LEV * net / K, 10.0)
eq_ts.append(ts[j])
eq_v.append(cap)
eq = pd.Series(eq_v, index=pd.DatetimeIndex(eq_ts))
pk = eq.cummax()
n = len(rets)
sharpe = float(np.mean(rets) / np.std(rets) * np.sqrt(n / yrs_span)) if n > 1 and np.std(rets) > 0 else 0.0
return dict(n=n, ret=float((eq.iloc[-1] / 1000 - 1) * 100),
sharpe=sharpe, dd=float(((pk - eq) / pk).max() * 100))
def run():
M = aligned_panel()
ts = pd.to_datetime(M.index, unit="ms", utc=True)
n = len(M)
print("=" * 96)
print(" XS01 phase-tranching — sensibilita' di fase + ensemble K=2/3 | griglia pre-registrata")
print("=" * 96)
for tag, split in (("FULL", 0.0), ("OOS", 1 - OOS_FRAC)):
yrs_span = (ts[-1] - ts[max(int(n * split), LB)]).days / 365.25 or 1
rows = []
for ph in range(HOLD):
tr = xsec_trades(phase=ph, split_frac=split, M=M)
rows.append(metrics(tr, ts, yrs_span))
sh = np.array([r["sharpe"] for r in rows])
dd = np.array([r["dd"] for r in rows])
rt = np.array([r["ret"] for r in rows])
print(f"\n [{tag}] sensibilita' di fase (12 fasi):")
print(f" Sharpe: min {sh.min():.2f} | med {np.median(sh):.2f} | max {sh.max():.2f} | std {sh.std():.2f}")
print(f" ret%: min {rt.min():+.0f} | med {np.median(rt):+.0f} | max {rt.max():+.0f}")
print(f" DD%: min {dd.min():.1f} | med {np.median(dd):.1f} | max {dd.max():.1f}")
for K in (2, 3):
branches = [xsec_trades(phase=int(p), split_frac=split, M=M)
for p in np.linspace(0, HOLD, K, endpoint=False)]
m = combined_metrics(branches, ts, yrs_span)
print(f" K={K} tranched: Sharpe {m['sharpe']:.2f} | ret {m['ret']:+.0f}% | "
f"DD {m['dd']:.1f}% | trade {m['n']}")
print("\n criterio: tranched-Sharpe OOS >= worst-fase OOS e DD <= mediana fasi; "
"se la dispersione di fase e' gia' piccola -> NON SERVE (verdetto onesto).")
if __name__ == "__main__":
run()