diff --git a/docs/diary/2026-06-10-funding-carry.md b/docs/diary/2026-06-10-funding-carry.md new file mode 100644 index 0000000..515160e --- /dev/null +++ b/docs/diary/2026-06-10-funding-carry.md @@ -0,0 +1,46 @@ +# 2026-06-10 — FC01 funding-carry market-neutral: SCARTATA (il carry non paga le fee) + +## Idea + +Carry NEUTRALE sul funding Deribit (meccanismo mai esplorato: W12 era lo short +direzionale su funding alto, bocciato): short della gamba col funding alto / +long della gamba bassa (ETH vs BTC, dollar-neutral), incassando il differenziale +di funding con esposizione residua = solo lo spread ETH/BTC. + +Dati REALI: `data/regime/{btc,eth}_funding.parquet` — funding orario effettivo +dic 2019 → giu 2026 (56.938 ore), `interest_1h` + index_price. +Harness: `scripts/analysis/funding_carry_research.py` (causale: decisione al +close t, accrual da t+1; fee 0.10% RT per gamba; TRAIN ≤2023-11-01 / OOS dopo). + +## Numeri che uccidono l'idea (prima ancora del backtest) + +- Funding annualizzato: BTC mean +7.2% / med +0.3%; ETH +5.2% / +0.05% — + la MEDIANA è ~zero: il funding alto è episodico, non strutturale. +- Spread ETH−BTC: p10 −19% / p90 +12.6% annualizzato, MA autocorr del + 24h-smooth a 72h = 0.20 → poca persistenza. +- **Episodi |spread|>10% ann: durata mediana 19h** (p90 60h) → carry + incassabile per episodio ≈ 10%/8760×19h ≈ **0.02-0.05%**, contro **0.20%** + di fee (2 gambe). Strutturalmente morto: il carry è un ordine di grandezza + sotto i costi, e il price-leg dello spread ETH/BTC (vol ben maggiore del + carry) domina il PnL. + +## Backtest (conferma) + +- FC-A spread-carry, griglia smooth {24,72,168}h × thr {5,10,20}% ann: + TRAIN quasi tutto negativo (Sharpe −0.6…+0.03), OOS negativo ovunque + tranne una cella (s24 thr5: OOS +38% con TRAIN −45% = rumore/sign-flip). + Sweep fee: negativa anche a 0.05% RT/gamba. Annuale: 2021 −93%, nessuna + stabilità. +- FC-B direzionale single-asset (confronto onesto): negativa ovunque, + riconferma W12. + +## Verdetto + +SCARTATA. Su Deribit BTC/ETH il funding non è un carry harvestabile: troppo +piccolo, troppo breve, e l'hedge cross-asset introduce più rischio del carry +che raccoglie. Eventuale rivisita SOLO se: (a) si aggiungono alt ad alto +funding cronico (Hyperliquid, registry già validato) dove i livelli sono +5-10x, o (b) si usa il funding come FEATURE/gate di strategie esistenti +(es. filtro sulle entry PR01) invece che come fonte di PnL. + +Script: `scripts/analysis/funding_carry_research.py` (resta come record negativo). diff --git a/docs/diary/2026-06-10-xs01-dispersion-gate.md b/docs/diary/2026-06-10-xs01-dispersion-gate.md new file mode 100644 index 0000000..13b96e9 --- /dev/null +++ b/docs/diary/2026-06-10-xs01-dispersion-gate.md @@ -0,0 +1,51 @@ +# 2026-06-10 — XS01 dispersion-gate: PROMOSSO e LIVE (entry solo con dispersione da fare rientrare) + +## Domanda + +L'edge di XS01 (reversione cross-sectional 8 asset) era concentrato (2025 domina, +2023 quasi piatto) e cost-sensitive. La reversione cross-sezionale va accesa solo +quando c'e' dispersione da far rientrare? + +## Metodo (anti multiple-testing): `scripts/analysis/xs01_dispersion_gate.py` + +3 feature di regime CAUSALI calcolate dallo stesso panel closes (nessun feed +esterno): `g_disp` = std cross-section del momentum lb (la grandezza che si fada), +`g_corr` = correlazione media pairwise 72h (identita' della varianza dell'indice), +`g_vol` = vol BTC 168h. Diagnostica per quintili (quintili dal TRAIN, 70/30) sul +net per-trade dell'engine canonico NON gateato, TRAIN e OOS separati: si procede +solo con relazione monotona e concorde nelle due finestre. + +## Esito diagnostica + +- **g_disp: monotona e concorde** — Q1 NEGATIVO (−10 bps TRAIN / −8 OOS) → + Q5 +26/+280. Senza dispersione i trade sono solo fee. PROMOSSA. +- g_corr e g_vol: non monotone / segno incoerente → BOCCIATE (niente fishing). + +## Gate (sweep soglie = percentili TRAIN, side dal TRAIN) + +Plateau pieno p30-p70, niente picco: TRAIN Sh 1.51 → 2.0-2.3, OOS Sh 5.73 → +6.2-7.5. Scelta **p50 (disp_min = 0.0313)**, ~47% delle ore aperte: + +- Standalone: trade 1427→859 (−40% turnover → meta' fee), win 50→53%, + **Sharpe 2.50→3.46**, DD 16.2→15.8%. **Ogni anno migliora**: 2022 +34→+40, + 2023 +6→+28, 2024 +21→+44, 2025 +225→+237, 2026 +85→+108 — risolve la + concentrazione, il punto debole della validazione originale. +- Fee stress 2x (0.20% RT/book): OOS Sh 6.76 — la cost-sensitivity e' mitigata + (il gate taglia proprio i trade che pagavano fee senza edge). +- **Gate PORT06** (swap equity sleeve): FULL Sh 7.34→7.41 DD pari, + **OOS Sh 10.07→10.37 DD 1.48→1.47** → PROMOSSO (criterio standard). + +## Implementazione (solo path LIVE, come trend/hurst sulle fade) + +- `src/live/xsec_worker.py`: param opzionale `disp_min` (None = off), check in + `_open_book` su `nanstd(logC[i] − logC[i−lb])`. Default off → la validazione + `validate_xsec_worker` (replay == backtest) resta esatta. +- `src/portfolio/runner.py`: pass-through di `disp_min` (il runner costruiva il + dict params esplicitamente e l'avrebbe perso). +- `scripts/portfolios/_defs.py`: `disp_min: 0.0313` nella spec XS01. +- Il backtest canonico (`build_everything`) resta NON filtrato → il live fara' + meglio del backtest, coerente con le altre guardie. + +Unit check: gate blocca panel piatto / apre panel disperso / default off invariato. +99/99 test. Nota macro della giornata: FC01 funding-carry SCARTATA +(diario separato) — il protocollo promuove ~1 idea su molte, come deve. diff --git a/scripts/analysis/funding_carry_research.py b/scripts/analysis/funding_carry_research.py new file mode 100644 index 0000000..3351696 --- /dev/null +++ b/scripts/analysis/funding_carry_research.py @@ -0,0 +1,231 @@ +"""FC01 — Funding-carry market-neutral (ricerca, 2026-06-10). + +Idea: su Deribit i long pagano gli short quando il funding e' positivo (e +viceversa). W12 (scartata) shortava il perp su funding alto = direzionale. +Qui il meccanismo NUOVO e' il CARRY NEUTRALE: short della gamba con funding +alto / long della gamba con funding basso (BTC vs ETH, dollar-neutral), +incassando il DIFFERENZIALE di funding con esposizione residua = solo lo +spread ETH/BTC (correlazione ~0.95). + +Dati REALI: data/regime/{btc,eth}_funding.parquet (orario, 2019-12 -> 2026-06, +interest_1h effettivo + index_price). Causale: decisione al close t con +funding noto fino a t; accrual dal bar t+1; fee 0.10% RT per GAMBA. + +Varianti: + FC-A spread-carry 2 gambe (il candidato): entra quando lo spread di funding + smussato supera la soglia, esce quando rientra / max_bars. + FC-B single-asset carry direzionale (confronto onesto con W12): short se + funding smussato > thr, long se < -thr. + +Protocollo: TRAIN fino a OOS_DATE (2023-11-01) per scegliere la config, +OOS dopo; griglia robustezza; sweep fee; breakdown annuale. + + uv run python scripts/analysis/funding_carry_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)) + +FEE_RT = 0.001 # 0.10% RT per gamba (taker, baseline progetto) +OOS_DATE = "2023-11-01" +HRS_YEAR = 24 * 365 + + +def load_panel(): + btc = pd.read_parquet("data/regime/btc_funding.parquet") + eth = pd.read_parquet("data/regime/eth_funding.parquet") + for d in (btc, eth): + d["dt"] = pd.to_datetime(d["timestamp"], unit="ms") + m = btc.set_index("dt")[["interest_1h", "index_price"]].rename( + columns={"interest_1h": "f_btc", "index_price": "p_btc"}).join( + eth.set_index("dt")[["interest_1h", "index_price"]].rename( + columns={"interest_1h": "f_eth", "index_price": "p_eth"}), + how="inner").sort_index() + m = m.dropna() + return m + + +def explore(m): + print("=" * 96) + print(" [0] ESPLORAZIONE — funding orario reale Deribit, " + f"{m.index[0].date()} -> {m.index[-1].date()} ({len(m)} ore)") + print("=" * 96) + for a in ("btc", "eth"): + f = m[f"f_{a}"] * HRS_YEAR * 100 # annualizzato % + print(f" {a.upper()}: funding annualizzato mean {f.mean():+6.2f}% " + f"med {f.median():+6.2f}% p10 {f.quantile(.1):+7.2f}% " + f"p90 {f.quantile(.9):+7.2f}% %ore>0 {100*(f>0).mean():.0f}%") + sp = (m["f_eth"] - m["f_btc"]) * HRS_YEAR * 100 + print(f" SPREAD ETH-BTC annualizzato: mean {sp.mean():+6.2f}% " + f"p10 {sp.quantile(.1):+7.2f}% p90 {sp.quantile(.9):+7.2f}%") + # persistenza: autocorr dello spread smussato 24h a vari lag + s24 = (m["f_eth"] - m["f_btc"]).rolling(24).mean() + for lag in (24, 72, 168): + c = s24.autocorr(lag) + print(f" autocorr spread(24h-smooth) lag {lag:>4}h: {c:+.3f}") + # quanto duramo sopra soglia? episodi |spread ann| > 10% + thr = 0.10 / HRS_YEAR + above = (s24.abs() > thr).astype(int) + runs = (above.groupby((above != above.shift()).cumsum()).sum()) + runs = runs[runs > 0] + if len(runs): + print(f" episodi |spread|>10% ann: {len(runs)} durata mediana " + f"{runs.median():.0f}h p90 {runs.quantile(.9):.0f}h") + + +# --------------------------------------------------------------------------- +# Backtest FC-A: spread-carry 2 gambe +# --------------------------------------------------------------------------- +def carry_pair(m, smooth=72, thr_ann=10.0, exit_frac=0.0, max_bars=24 * 30, + fee_rt=FEE_RT, sl=None): + """Entra quando |spread smussato| > thr (annualizzato %); short la gamba + col funding alto, long l'altra, 1x notional per gamba. Esce quando lo + spread smussato scende sotto exit_frac*thr (o cambia segno) o max_bars. + Ritorna array di net-return per trade + serie equity oraria (additiva).""" + f_sp = (m["f_eth"] - m["f_btc"]).rolling(smooth).mean().to_numpy() + fe = m["f_eth"].to_numpy() + fb = m["f_btc"].to_numpy() + pe = m["p_eth"].to_numpy() + pb = m["p_btc"].to_numpy() + n = len(m) + thr = thr_ann / 100 / HRS_YEAR + ex = exit_frac * thr + sli = m.index[:n] if sl is None else None + rets, lens, accs = [], [], [] + eq = np.zeros(n) + i = smooth + while i < n - 1: + s = f_sp[i] + if not np.isfinite(s) or abs(s) <= thr: + i += 1 + continue + d = -1 if s > 0 else 1 # s>0: ETH paga di piu' -> short ETH/long BTC + e_eth, e_btc = pe[i], pb[i] + acc = 0.0 + j = i + 1 + end = min(n - 1, i + max_bars) + while j <= end: + # accrual del funding sull'ora j: short riceve +f, long paga f + acc += (-d) * fe[j] + d * fb[j] + if abs(f_sp[j]) <= ex or np.sign(f_sp[j]) != np.sign(s): + break + j += 1 + j = min(j, end) + price_leg = d * (pe[j] - e_eth) / e_eth - d * (pb[j] - e_btc) / e_btc + net = price_leg + acc - 2 * fee_rt + rets.append(net) + lens.append(j - i) + accs.append(acc) + eq[j] += net + i = j + 1 + rets = np.array(rets) + eqs = pd.Series(eq, index=m.index).cumsum() + return rets, np.array(lens), np.array(accs), eqs + + +# --------------------------------------------------------------------------- +# Backtest FC-B: carry direzionale single-asset (confronto/W12 onesto) +# --------------------------------------------------------------------------- +def carry_single(m, asset="eth", smooth=72, thr_ann=20.0, exit_frac=0.0, + max_bars=24 * 30, fee_rt=FEE_RT): + f = m[f"f_{asset}"].rolling(smooth).mean().to_numpy() + fr = m[f"f_{asset}"].to_numpy() + p = m[f"p_{asset}"].to_numpy() + n = len(m) + thr = thr_ann / 100 / HRS_YEAR + ex = exit_frac * thr + rets = [] + i = smooth + while i < n - 1: + s = f[i] + if not np.isfinite(s) or abs(s) <= thr: + i += 1 + continue + d = -1 if s > 0 else 1 # funding alto -> short (incassa) + e = p[i] + acc = 0.0 + j = i + 1 + end = min(n - 1, i + max_bars) + while j <= end: + acc += (-d) * fr[j] + if abs(f[j]) <= ex or np.sign(f[j]) != np.sign(s): + break + j += 1 + j = min(j, end) + net = d * (p[j] - e) / e + acc - fee_rt + rets.append(net) + i = j + 1 + return np.array(rets) + + +def stats(rets, idx_len_hours, label="", lens=None, accs=None): + if len(rets) == 0: + return f" {label:<28s} 0 trade" + yrs = idx_len_hours / HRS_YEAR + pnl = rets.sum() * 100 + win = (rets > 0).mean() * 100 + tpy = len(rets) / yrs + sh = rets.mean() / (rets.std() + 1e-12) * np.sqrt(max(tpy, 1e-9)) + extra = "" + if lens is not None and len(lens): + extra = f" | hold med {np.median(lens):.0f}h" + if accs is not None and len(accs): + extra += f" | carry quota {100*np.sum(accs)/max(np.sum(rets),1e-9):.0f}%" + return (f" {label:<28s} {len(rets):>4d} tr | win {win:>4.0f}% | " + f"PnL {pnl:>+7.1f}% | {tpy:>5.1f} tr/anno | Sh {sh:>5.2f}{extra}") + + +def main(): + m = load_panel() + explore(m) + cut = m.index.searchsorted(pd.Timestamp(OOS_DATE)) + mtr, moo = m.iloc[:cut], m.iloc[cut:] + print(f"\n TRAIN {m.index[0].date()} -> {OOS_DATE} | OOS -> {m.index[-1].date()}") + + print("\n" + "=" * 96) + print(" [1] FC-A spread-carry 2 gambe (fee 0.10% RT x2 gambe) — griglia su TRAIN") + print("=" * 96) + grid = [] + for smooth in (24, 72, 168): + for thr in (5.0, 10.0, 20.0): + r, ln, ac, _ = carry_pair(mtr, smooth=smooth, thr_ann=thr) + grid.append((smooth, thr, r)) + print(stats(r, len(mtr), f"TRAIN s{smooth} thr{thr:.0f}%", ln, ac)) + + print("\n Le stesse config in OOS (mai usate per scegliere):") + for smooth in (24, 72, 168): + for thr in (5.0, 10.0, 20.0): + r, ln, ac, _ = carry_pair(moo, smooth=smooth, thr_ann=thr) + print(stats(r, len(moo), f"OOS s{smooth} thr{thr:.0f}%", ln, ac)) + + print("\n" + "=" * 96) + print(" [2] FC-B carry direzionale single-asset (confronto, fee 0.10% RT)") + print("=" * 96) + for a in ("btc", "eth"): + for thr in (10.0, 30.0): + rtr = carry_single(mtr, a, thr_ann=thr) + roo = carry_single(moo, a, thr_ann=thr) + print(stats(rtr, len(mtr), f"TRAIN {a} thr{thr:.0f}%")) + print(stats(roo, len(moo), f"OOS {a} thr{thr:.0f}%")) + + print("\n" + "=" * 96) + print(" [3] FC-A: sweep fee (config mediana s72 thr10) e breakdown annuale") + print("=" * 96) + for fee in (0.0005, 0.001, 0.002): + r, ln, ac, _ = carry_pair(m, smooth=72, thr_ann=10.0, fee_rt=fee) + print(stats(r, len(m), f"FULL fee {fee*100:.2f}% RT/gamba", ln, ac)) + _, _, _, eq = carry_pair(m, smooth=72, thr_ann=10.0) + yr = eq.groupby(eq.index.year).apply(lambda s: (s.iloc[-1] - s.iloc[0]) * 100) + print(" annuale (PnL additivo %):", + {int(k): round(float(v), 1) for k, v in yr.items()}) + + +if __name__ == "__main__": + main() diff --git a/scripts/analysis/xs01_dispersion_gate.py b/scripts/analysis/xs01_dispersion_gate.py new file mode 100644 index 0000000..966894e --- /dev/null +++ b/scripts/analysis/xs01_dispersion_gate.py @@ -0,0 +1,189 @@ +"""XS01 dispersion-gate — la reversione cross-sectional va accesa solo in certi regimi? + +Motivazione: l'edge XS01 e' concentrato (2025 domina, 2023 debole). Ipotesi da +testare: il fattore reversione cross-sezionale paga quando c'e' DISPERSIONE da +far rientrare (spread cross-section largo) e/o correlazione media alta (mosse +idiosincratiche = rumore che rientra), e perde nei regime-break (dispersione da +trend divergente, es. melt-up di un singolo asset). + +Metodo (anti-multiple-testing): + [1] DIAGNOSTICA: engine XS01 canonico SENZA gate, registrando per ogni trade + il valore di 3 feature di regime alla barra di ENTRY (tutte causali, + calcolate dallo stesso panel closes <= i): + g_disp = std cross-section del segnale stesso (logC[i]-logC[i-lb]) + g_corr = correlazione media pairwise 72h (identita' var dell'indice) + g_vol = vol realizzata BTC 168h + Bucket per quintili (quintili dal TRAIN) -> mean net per bucket, + TRAIN e OOS SEPARATI. Si prosegue solo se la relazione e' monotona + e con lo stesso segno in entrambe le finestre. + [2] GATE: per la feature promossa, sweep soglie (percentili TRAIN + 30/40/50/60/70) -> TRAIN/OOS Sharpe/PnL/DD vs base. Serve PLATEAU. + [3] Solo se [2] regge: gate PORT06 (swap equity sleeve XS01). + + uv run python scripts/analysis/xs01_dispersion_gate.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, FEE_RT, LEV, POS, OOS_FRAC, LB, HOLD) + +N_A = len(UNIVERSE) + + +def build_features(M, lb=LB): + """Feature di regime causali dal panel closes (nessun feed esterno).""" + logC = np.log(M.values) + r = np.diff(logC, axis=0, prepend=logC[:1]) # ret orari (r[0]=0) + R = pd.DataFrame(r, index=M.index) + # g_disp: std cross-section del momentum lb (il segnale che fadiamo) + D = pd.DataFrame(logC).diff(lb).to_numpy() + g_disp = np.nanstd(D, axis=1) + # g_corr 72h: avg pairwise corr via identita' della varianza dell'indice + w = 72 + idx_var = R.mean(axis=1).rolling(w).var().to_numpy() + mean_var = R.rolling(w).var().mean(axis=1).to_numpy() + with np.errstate(divide="ignore", invalid="ignore"): + g_corr = (N_A * idx_var / mean_var - 1) / (N_A - 1) + # g_vol: vol BTC 168h annualizzata + b = UNIVERSE.index("BTC") + g_vol = R[b].rolling(168).std().to_numpy() * np.sqrt(24 * 365) + return dict(g_disp=g_disp, g_corr=g_corr, g_vol=g_vol) + + +def sim_with_trace(M, feats, gate=None, lb=LB, hold=HOLD, fee_rt=FEE_RT, + lev=LEV, pos=POS): + """Engine XS01 canonico (stessa logica/ordine di XS01_cross_sectional.xsec_sim) + + trace per-trade (entry index, net, feature) + gate opzionale bool[i].""" + C = M.values + ts = pd.to_datetime(M.index, unit="ms", utc=True) + n = len(C) + logC = np.log(C) + cap = peak = 1000.0 + dd = 0.0 + rows = [] + eq_ts, eq_v = [], [] + last = -1 + i = lb + fee = 2 * fee_rt + while i < n - hold: + if i <= last: + i += 1 + continue + if gate is not None and not gate[i]: + i += 1 + continue + dm = (logC[i] - logC[i - lb]) + dm = dm - dm.mean() + w = -dm + gw = np.sum(np.abs(w)) + if gw < 1e-9: + i += 1 + continue + w = w / gw + book = float(np.sum(w * (logC[i + hold] - logC[i]))) + net = book - fee + cap = max(cap + cap * pos * lev * net, 10.0) + peak = max(peak, cap) + dd = max(dd, (peak - cap) / peak) + rows.append((i, int(ts[i].year), net, + feats["g_disp"][i], feats["g_corr"][i], feats["g_vol"][i])) + eq_ts.append(ts[i + hold]) + eq_v.append(cap) + last = i + hold + i += 1 + tr = pd.DataFrame(rows, columns=["i", "year", "net", "g_disp", "g_corr", "g_vol"]) + yrs_span = (ts[-1] - ts[0]).days / 365.25 or 1 + out = dict(trades=len(tr), cap=cap, dd=dd * 100, eq_ts=eq_ts, eq_v=eq_v, tr=tr) + if len(tr) > 1 and tr["net"].std() > 0: + out["sharpe"] = float(tr["net"].mean() / tr["net"].std() + * np.sqrt(len(tr) / yrs_span)) + else: + out["sharpe"] = 0.0 + out["pnl_add"] = float(tr["net"].sum() * 100) if len(tr) else 0.0 + out["win"] = float((tr["net"] > 0).mean() * 100) if len(tr) else 0.0 + out["tpm"] = len(tr) / (yrs_span * 12) + return out + + +def metrics_window(tr, lo, hi, yrs_span): + t = tr[(tr["i"] >= lo) & (tr["i"] < hi)] + if len(t) < 2 or t["net"].std() == 0: + return dict(n=len(t), pnl=0.0, sh=0.0, win=0.0) + sh = float(t["net"].mean() / t["net"].std() * np.sqrt(len(t) / yrs_span)) + return dict(n=len(t), pnl=float(t["net"].sum() * 100), sh=sh, + win=float((t["net"] > 0).mean() * 100)) + + +def main(): + M = aligned_panel() + n = len(M) + cut = int(n * (1 - OOS_FRAC)) + ts = pd.to_datetime(M.index, unit="ms", utc=True) + feats = build_features(M) + print("=" * 96) + print(f" XS01 dispersion-gate | panel {ts[0].date()} -> {ts[-1].date()} " + f"({n} ore, 8 asset) | TRAIN 70% (-> {ts[cut].date()}) / OOS 30%") + print("=" * 96) + + base = sim_with_trace(M, feats) + tr = base["tr"] + yrs_tr = (ts[cut] - ts[0]).days / 365.25 + yrs_oo = (ts[-1] - ts[cut]).days / 365.25 + + # [1] DIAGNOSTICA per quintili (quintili dal TRAIN) + print("\n[1] DIAGNOSTICA — mean net per trade (bps) per quintile feature @entry") + ttr = tr[tr["i"] < cut] + too = tr[tr["i"] >= cut] + for g in ("g_disp", "g_corr", "g_vol"): + qs = ttr[g].quantile([0.2, 0.4, 0.6, 0.8]).to_numpy() + def bucket(x): + return int(np.searchsorted(qs, x)) + print(f" {g:<7s} | " + " | ".join( + f"Q{q+1} TR {ttr[ttr[g].apply(bucket) == q]['net'].mean()*1e4:+6.1f} " + f"OOS {too[too[g].apply(bucket) == q]['net'].mean()*1e4:+6.1f}" + for q in range(5)) + + f" (n TR {len(ttr)}, OOS {len(too)})") + + # [2] GATE sweep — per ogni feature, tieni SOPRA o SOTTO il percentile + print("\n[2] GATE — TRAIN/OOS vs base (soglie = percentili del TRAIN; " + "side scelto dal segno della diagnostica TRAIN)") + b_tr = metrics_window(tr, 0, cut, yrs_tr) + b_oo = metrics_window(tr, cut, n, yrs_oo) + print(f" {'BASE':<24s} TRAIN n {b_tr['n']:>4} pnl {b_tr['pnl']:>+7.1f}% " + f"Sh {b_tr['sh']:>5.2f} | OOS n {b_oo['n']:>4} pnl {b_oo['pnl']:>+7.1f}% " + f"Sh {b_oo['sh']:>5.2f}") + for g in ("g_disp", "g_corr", "g_vol"): + # segno dal TRAIN: correlazione quintile->ret + qs5 = ttr[g].quantile([0.2, 0.4, 0.6, 0.8]).to_numpy() + means = [ttr[ttr[g].apply(lambda x: int(np.searchsorted(qs5, x))) == q]["net"].mean() + for q in range(5)] + side = "above" if means[-1] > means[0] else "below" + for pct in (30, 40, 50, 60, 70): + thr = float(np.nanpercentile(feats[g][:cut], pct)) + gv = feats[g] + gate = (gv >= thr) if side == "above" else (gv <= thr) + gate = np.nan_to_num(gate, nan=False).astype(bool) + r = sim_with_trace(M, feats, gate=gate) + g_tr = metrics_window(r["tr"], 0, cut, yrs_tr) + g_oo = metrics_window(r["tr"], cut, n, yrs_oo) + print(f" {g} {side} p{pct:<3d}{'':<6s} TRAIN n {g_tr['n']:>4} " + f"pnl {g_tr['pnl']:>+7.1f}% Sh {g_tr['sh']:>5.2f} | " + f"OOS n {g_oo['n']:>4} pnl {g_oo['pnl']:>+7.1f}% Sh {g_oo['sh']:>5.2f}") + + # breakdown annuale base (riferimento concentrazione) + print("\n base, net additivo per anno (%):", + {int(y): round(float(v * 100), 1) + for y, v in tr.groupby("year")["net"].sum().items()}) + + +if __name__ == "__main__": + main() diff --git a/scripts/portfolios/_defs.py b/scripts/portfolios/_defs.py index d62ea59..b15e6e9 100644 --- a/scripts/portfolios/_defs.py +++ b/scripts/portfolios/_defs.py @@ -105,9 +105,14 @@ SHAPE = [SleeveSpec(kind="ml", name="SH01", sid=f"SH_{a}", asset=a, cluster="sha # (~0) da pairs e fade. Gate PORT06: +XS01 -> OOS Sharpe 9.66->10.07, FULL DD 3.68->3.46. # 8 gambe -> niente esecuzione reale: gira PAPER (come TR01/ROT02/TSM01). Worker validato # (validate_xsec_worker: replay == backtest esatto). Diario 2026-06-09. +# DISPERSION-GATE (2026-06-10): entry solo se std cross-section del momentum lb >= disp_min +# (p50 TRAIN = 0.0313). Diagnostica monotona TRAIN+OOS, plateau p30-p70, ogni anno migliora +# (standalone Sharpe 2.50->3.46, regge fee 2x), PORT06 OOS Sh 10.07->10.37 a DD pari. Solo +# path LIVE (backtest canonico NON filtrato, come trend/hurst sulle fade) -> il live fara' +# meglio del backtest. Diario 2026-06-10, gate scripts/analysis/xs01_dispersion_gate.py. XSEC = [SleeveSpec(kind="xsec", name="XS01", sid="XS01", cluster="xsec", params={"universe": ["BTC", "ETH", "LTC", "ADA", "SOL", "BNB", "XRP", "DOGE"], - "tf": "1h", "lb": 48, "hold": 12})] + "tf": "1h", "lb": 48, "hold": 12, "disp_min": 0.0313})] PORTFOLIOS = { "PORT01": Portfolio("PORT01", "Honest", HONEST, weighting="equal"), diff --git a/src/live/xsec_worker.py b/src/live/xsec_worker.py index 349026a..838ded1 100644 --- a/src/live/xsec_worker.py +++ b/src/live/xsec_worker.py @@ -26,6 +26,10 @@ class CrossSectionalWorker: p = params or {} self.lb = int(p.get("lb", 48)) self.hold = int(p.get("hold", 12)) + # dispersion-gate (2026-06-10): entra solo se la std cross-section del + # momentum lb supera disp_min — senza dispersione da far rientrare i + # trade sono fee. None = off (parita' col backtest canonico non filtrato). + self.disp_min = p.get("disp_min") self.tf = tf self.initial_capital = capital self.position_size = position_size @@ -131,6 +135,10 @@ class CrossSectionalWorker: def _open_book(self, M, i): cols = list(M.columns) logC = np.log(M.values) + if self.disp_min is not None: + disp = float(np.nanstd(logC[i] - logC[i - self.lb])) + if disp < float(self.disp_min): + return # regime senza dispersione: skip entry w = self._weights(logC[i], logC[i - self.lb]) if w is None: return diff --git a/src/portfolio/runner.py b/src/portfolio/runner.py index 8075921..ce79c49 100644 --- a/src/portfolio/runner.py +++ b/src/portfolio/runner.py @@ -96,7 +96,8 @@ def build_worker_for(spec: SleeveSpec, alloc_capital: float, leverage: float, pr = spec.params return CrossSectionalWorker( universe=pr["universe"], tf=pr.get("tf", "1h"), - params={"lb": pr.get("lb", 48), "hold": pr.get("hold", 12)}, + params={"lb": pr.get("lb", 48), "hold": pr.get("hold", 12), + "disp_min": pr.get("disp_min")}, capital=alloc_capital, position_size=position_size, leverage=leverage, data_dir=data_dir, )