feat(XS01): + gate di dispersione (p30) — portafoglio FULL 1.48->1.55, HOLD 1.06->1.55

Momentum cross-sectional vive nella dispersione; gate: entra solo se la dispersione cross-section
del momentum supera il percentile ESPANDENTE causale (altrimenti flat). Plateau robusto p15-p35
(non knife-edge: il crollo a p40+ e' over-gating); scelto p30. XS01 standalone FULL 1.10->1.50,
HOLD 1.03->1.71, DD 14%->10.8%. Portafoglio TP01 70+XS 30: FULL 1.48->1.55, HOLD 1.06->1.55, DD
4.6%->4.4%. Il gate alza SIA FULL SIA hold-out (tiene XS attivo nei regimi dispersi, flat nei bull
compatti; causale). E' il concetto del vecchio XS01.

sleeves.XS_CFG disp_pct=30; engine _xsec_returns gatea su dispersione. 12 test ok.
Diario 2026-06-19-xsec-dispgate.md. Affinamenti del segnale (blend+gate) > espansione universo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-19 22:24:04 +00:00
parent fd5a0bd3cf
commit 87dd56a9ce
3 changed files with 172 additions and 15 deletions
+44
View File
@@ -0,0 +1,44 @@
# 2026-06-19 — Affinamento XS01: gate di dispersione (p30)
Il momentum cross-sectional vive nella DISPERSIONE (winners/losers distanti). In regime compatto
(tutti gli asset insieme) e' rumore. Gate: entra solo se la dispersione cross-section del momentum
supera il percentile ESPANDENTE causale `disp_pct`; altrimenti flat. Sul blend [30,90] dei 19 major.
`scripts/portfolio/xsec_dispgate.py`. (È il concetto del vecchio XS01 pre-reset, disp_min=p50.)
## Sweep soglia (19 major, 899g) — XS01 standalone + contributo portafoglio
| soglia | XS FULL | XS OOS | PORT FULL | PORT HOLD | %flat |
|---|---|---|---|---|---|
| no gate | 1.10 | 1.03 | 1.50 | 1.06 | 0% |
| p15 | 1.32 | 1.39 | 1.64 | 1.36 | 28% |
| p20 | 1.46 | 1.63 | 1.72 | 1.52 | 31% |
| p25 | 1.46 | 1.63 | 1.72 | 1.52 | 31% |
| **p30** | **1.50** | **1.71** | **1.74** | **1.56** | 35% |
| p35 | 1.60 | 1.90 | 1.81 | 1.69 | 37% |
| p40-p50 | 1.0 | 0.8 | 1.36-1.38 | 0.77-0.93 | 42-49% |
**PLATEAU robusto p15-p35** (cinque punti, tutti molto > no-gate); il crollo a p40+ e' OVER-gating
(salta troppo). Scelto **p30** (centro sicuro del plateau, lontano dal cliff p40). Non un knife-edge.
## Effetto sul portafoglio (TP01 70% + XS01 30%)
| XS01 | PORT FULL | PORT HOLD | PORT DD |
|---|---|---|---|
| [30] originale | 1.41 | 1.15 | 5.2% |
| + blend [30,90] | 1.48 | 1.06 | 4.6% |
| + dispersion gate p30 | **1.55** | **1.55** | **4.4%** |
XS01 standalone: FULL 1.10→1.50, HOLD 1.03→1.71, DD 14%→10.8%, ~€/g 1.64→2.36. Il gate alza SIA
FULL SIA hold-out (a differenza del solo blend, che barattava un po' di hold-out).
## Meccanismo + caveat onesti
- **Causale**: soglia = percentile espandente della dispersione PASSATA; nessun look-ahead.
- **Perche' funziona**: tiene XS attivo nei regimi DISPERSI (2025-26, dove gli alt divergono) e flat
nei bull compatti (2024). L'hold-out 2025-26 e' ad alta dispersione -> il gate concentra
l'attivita' di XS proprio li' -> hold-out forte. E' il comportamento voluto, ma NB che il salto
del hold-out riflette anche che il 2025-26 e' stato un regime ad alta dispersione.
- Caveat XS01 invariati: storia ~2.5 anni; STAT-MODE (book 19 gambe non eseguibile a 2k).
## Azione
`src/portfolio/sleeves.XS_CFG`: aggiunto `disp_pct=30`; engine `_xsec_returns` gatea su dispersione.
**Portafoglio attivo: TP01 70% + XS01 (blend [30,90] + gate disp p30) 30% — FULL Sh 1.55 / HOLD 1.55
/ DD 4.4%.** 12 test ok. Affinamenti del SEGNALE (blend + gate) hanno funzionato dove l'espansione
universo no: i margini su XS sono nella struttura del segnale, non nel numero di asset.
+101
View File
@@ -0,0 +1,101 @@
"""AFFINAMENTO XS01 — GATE DI DISPERSIONE.
Il momentum cross-sectional vive nella DISPERSIONE (winners/losers distanti). In regime compatto
(tutti gli asset si muovono insieme) non ha segnale -> churn/rumore. Gate: entra SOLO se la
dispersione cross-section del momentum supera una soglia CAUSALE (percentile espandente della
dispersione passata); altrimenti flat. Sul blend [30,90] dei 19 major. Sweep soglia + contributo.
uv run python scripts/portfolio/xsec_dispgate.py
"""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
import numpy as np, pandas as pd
from src.portfolio.portfolio import to_daily, metrics, HOLDOUT
from src.portfolio.sleeves import tp01_sleeve, XS_UNIVERSE
RAW = PROJECT_ROOT / "data" / "raw"
FEE = 0.001
LOOKBACKS = (30, 90); H = 10; K = 5; TV = 0.20
def load_majors():
cols = {}
for sym in XS_UNIVERSE:
p = RAW / f"hl_{sym.lower()}_1d.parquet"
if p.exists():
d = pd.read_parquet(p)
cols[sym] = pd.Series(d["close"].values.astype(float), index=pd.to_datetime(d["timestamp"], unit="ms", utc=True))
return pd.concat(cols, axis=1, join="inner").sort_index().dropna()
def xs_gated(C, disp_pct=0, min_hist=20):
px = C.values; n, A = px.shape
dret = np.vstack([np.zeros(A), px[1:] / px[:-1] - 1.0])
mlb = max(LOOKBACKS)
# dispersione del momentum a ogni barra: media (su lookback) della std cross-section di ret_L
disp = np.full(n, np.nan)
for i in range(mlb, n):
acc = 0.0; c = 0
for L in LOOKBACKS:
acc += (px[i] / px[i - L] - 1.0).std(); c += 1
disp[i] = acc / c
W = np.zeros((n, A)); w = np.zeros(A)
hist = []
gated_flat = 0; total = 0
for i in range(n):
if i >= mlb and i % H == 0:
thr = np.percentile(hist, disp_pct) if (disp_pct > 0 and len(hist) >= min_hist) else -np.inf
total += 1
if disp[i] >= thr:
score = np.zeros(A)
for L in LOOKBACKS:
rL = px[i] / px[i - L] - 1.0; sd = rL.std()
if sd > 0:
score += (rL - rL.mean()) / sd
order = np.argsort(score); w = np.zeros(A); lo, hi = order[:K], order[-K:]
w[hi] = 0.5 / K; w[lo] = -0.5 / K
else:
w = np.zeros(A); gated_flat += 1
hist.append(disp[i])
W[i] = w
gross = np.zeros(n); gross[1:] = np.sum(W[:-1] * dret[1:], axis=1)
turn = np.zeros(n); turn[0] = np.abs(W[0]).sum(); turn[1:] = np.abs(np.diff(W, axis=0)).sum(axis=1)
s = pd.Series(gross - turn * (FEE / 2.0), index=C.index)
rv = s.rolling(30, min_periods=15).std().shift(1) * np.sqrt(365.25)
scale = np.clip(np.nan_to_num(TV / rv.replace(0, np.nan).values, nan=0.0), 0, 3.0)
return to_daily(pd.Series(s.values * scale, index=C.index)), (gated_flat / total if total else 0)
def main():
C = load_majors(); tp = tp01_sleeve().daily()
print("=" * 92)
print(f" AFFINAMENTO XS01 — gate di dispersione (blend [30,90], 19 major, {len(C)}g)")
print("=" * 92)
print(f" {'soglia pctile':<16}{'FULL':>7}{'OOS25':>7}{'DD%':>6}{'anni+':>7}{'corrTP':>8}{'%flat':>8}")
res = {}
for p in (0, 30, 40, 50, 60, 70):
d, flat = xs_gated(C, p)
f = metrics(d); o = metrics(d[d.index >= HOLDOUT])
yr = [float((1 + g).prod() - 1) for _, g in d.groupby(d.index.year)]
pct = sum(v > 0 for v in yr) / len(yr) if yr else 0
corr = float(pd.concat({"a": tp, "b": d}, axis=1, join="inner").dropna().corr().iloc[0, 1])
res[p] = (d, f, o, pct, corr)
lab = "0 (no gate)" if p == 0 else f"p{p}"
print(f" {lab:<16}{f['sharpe']:>7.2f}{o['sharpe']:>7.2f}{f['maxdd']*100:>6.0f}{pct*100:>6.0f}%{corr:>+8.2f}{flat*100:>7.0f}%")
print("\n CONTRIBUTO al portafoglio (TP01 70 + XS 30, finestra comune):")
for p in (0, 40, 50, 60):
d = res[p][0]
J = pd.concat({"tp": tp, "xs": d}, axis=1, join="inner").dropna()
comb = 0.7 * J["tp"] + 0.3 * J["xs"]
cf, ch = metrics(comb), metrics(comb[comb.index >= HOLDOUT])
lab = "no gate (attuale)" if p == 0 else f"gate p{p}"
print(f" {lab:<18} FULL Sh {cf['sharpe']:.2f} DD {cf['maxdd']*100:.0f}% | HOLD Sh {ch['sharpe']:.2f}")
print("\n -> promuovere il gate se migliora Sharpe/DD/robustezza E il contributo. Sennò no-gate resta.")
if __name__ == "__main__":
main()
+18 -6
View File
@@ -55,7 +55,11 @@ from pathlib import Path as _Path
# BLEND di lookback (2026-06-19): fonde 30g+90g del momentum cross-sectional (z-score per
# lookback, mediato) come TP01 fonde gli orizzonti -> piu' robusto del singolo L=30: FULL Sh
# 0.80->1.10, DD 21%->14%, corr a TP01 -0.06->-0.12, 100% anni+. Diario 2026-06-19-xsec-blend.md.
XS_CFG = dict(lookbacks=(30, 90), H=10, k=5, mode="mom", target_vol=0.20)
# + GATE DI DISPERSIONE (2026-06-19): entra solo se la dispersione cross-section del momentum
# supera il percentile ESPANDENTE causale disp_pct (altrimenti flat: in regime compatto XS e'
# rumore). Plateau robusto p15-p35; a p30: portafoglio FULL 1.50->1.74, HOLD 1.06->1.56.
# Diario 2026-06-19-xsec-dispgate.md.
XS_CFG = dict(lookbacks=(30, 90), H=10, k=5, mode="mom", target_vol=0.20, disp_pct=30, disp_minhist=20)
_HL_DIR = _Path(__file__).resolve().parents[2] / "data" / "raw"
# UNIVERSO ESPLICITO = 19 ALT LIQUIDI MAJOR. NB (2026-06-19): allargare a 52 asset (incluso
# small-cap WIF/JUP/ORDI/PYTH/TAO...) DILUISCE l'edge -> momentum cross-section NEGATIVO sui 52.
@@ -79,13 +83,18 @@ def _xsec_returns() -> pd.Series:
C = pd.concat(cols, axis=1, join="inner").sort_index().dropna()
px = C.values; n, A = px.shape
lookbacks, H, k, mode, tv = XS_CFG["lookbacks"], XS_CFG["H"], XS_CFG["k"], XS_CFG["mode"], XS_CFG["target_vol"]
disp_pct = XS_CFG.get("disp_pct", 0); minhist = XS_CFG.get("disp_minhist", 20)
mlb = max(lookbacks)
dret = np.vstack([np.zeros(A), px[1:] / px[:-1] - 1.0])
W = np.zeros((n, A)); w = np.zeros(A)
W = np.zeros((n, A)); w = np.zeros(A); disp_hist = []
for i in range(n):
if i >= max(lookbacks) and i % H == 0:
score = np.zeros(A); cnt = 0 # blend: media z-score cross-sectional per lookback
for L in lookbacks:
rL = px[i] / px[i - L] - 1.0
if i >= mlb and i % H == 0:
rLs = [px[i] / px[i - L] - 1.0 for L in lookbacks]
disp_i = float(np.mean([r.std() for r in rLs])) # dispersione cross-section del momentum
thr = np.percentile(disp_hist, disp_pct) if (disp_pct > 0 and len(disp_hist) >= minhist) else -np.inf
if disp_i >= thr: # gate: entra solo in regime disperso
score = np.zeros(A); cnt = 0 # blend: media z-score cross-sectional
for rL in rLs:
sd = rL.std()
if sd > 0:
score += (rL - rL.mean()) / sd; cnt += 1
@@ -95,6 +104,9 @@ def _xsec_returns() -> pd.Series:
w = np.zeros(A); lo, hi = order[:k], order[-k:]
if mode == "mom": w[hi] = 0.5 / k; w[lo] = -0.5 / k
else: w[lo] = 0.5 / k; w[hi] = -0.5 / k
else:
w = np.zeros(A) # regime compatto -> flat
disp_hist.append(disp_i)
W[i] = w
gross = np.zeros(n); gross[1:] = np.sum(W[:-1] * dret[1:], axis=1)
turn = np.zeros(n); turn[0] = np.abs(W[0]).sum(); turn[1:] = np.abs(np.diff(W, axis=0)).sum(axis=1)