14522262e6
Reset del progetto su fondamenta verificate dopo la scoperta che l'intera libreria "validata OOS" era artefatto di feed contaminato (print fantasma del feed Cerbero TESTNET + storico Binance/USDT). - Storico ricostruito da Deribit MAINNET (ccxt pubblico, tokenless) e CERTIFICATO (certify_feed.py): BTC/ETH puliti su TUTTA la storia (mediana 2-6 bps vs Coinbase USD), integrita' OHLC + coerenza resample (maxΔ 0.00) + cross-venue OK. Alt esclusi (illiquidi/divergenti: LTC/DOGE 50-82% barre flat; XRP/BNB non certificabili). - Verdetto sul feed pulito: FADE / PAIRS / XS01 / TSM01 morti (ogni portafoglio Sharpe -2.3..-3.0, DD ~40%); solo SH01 e frammenti HONEST con segnale residuo, da ri-validare in isolamento. - Cleanup "restart pulito": strategie, stack live (src/live, src/portfolio, runner/executor, yml, docker), ~100 script ricerca/gate, waste/games/ portfolios, dati non certificati + cache e 60+ diari -> archiviati in Old/ (preservati, non cancellati). Diario consolidato in un unico documento. - Skeleton ricerca tenuto: Strategy ABC + indicatori + src/fractal + src/backtest/engine + load_data; tool dati certificati (rebuild_history, certify_feed, audit_feed, multi_source_check). - Universo dati ATTIVO: solo BTC/ETH (5m/15m/1h); guardrail fisico (load_data su alt -> FileNotFoundError). Esecuzione DISABILITATA, conto flat. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
190 lines
7.7 KiB
Python
190 lines
7.7 KiB
Python
"""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()
|