research(wave-0701): 6 filoni multi-agente — 0 nuovi sleeve, pesi confermati, gate weights_tilt_null
Ondata onesta su angoli non coperti: funding-TS (chiude il filone funding su 3 lati), breadth alt (non-ridondante ma DSR 0.43, rivisitabile con storia), XS-residmom (REDUNDANT), pesi+guardia-DD (EW-STR refutato dallo scettico come selezione-sull'hold-out di 2° ordine, firma best-of-15), VRP-refine (filone esaurito), stagionalità-XS (morta allo step statistico). Lezione codificata: weights_tilt_null + combine_outer in src/portfolio (ogni cambio-pesi vs null di tilt casuali cap-respecting + delta in-sample>=0); 5 test nuovi, suite 165/165. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,439 @@
|
||||
"""r0701_xs — RESIDUAL (IDIOSYNCRATIC) MOMENTUM cross-sectional sui 19 major Hyperliquid.
|
||||
|
||||
TESI (2026-07-01). STATARB-RESID (thread 4, 2026-06-29) ha mostrato che il MOMENTUM del residuo
|
||||
ETH−β·BTC (β OLS rolling, sgn=+1: le dislocazioni CONTINUANO a 1d) passa quasi tutti i gate su
|
||||
2 gambe, fallendo SOLO il deflated-Sharpe (0.929<0.95). Angolo nuovo: lo stesso meccanismo
|
||||
CROSS-SECTIONAL sui 19 major di XS01 — per ogni alt, residuo vs β·BTC (β OLS rolling B giorni),
|
||||
momentum del residuo su lookback (blend z-score [30,90] come XS01, o singolo), rank cross-section,
|
||||
long top-k / short bottom-k, vol-target 20%. Ipotesi: la breadth (18 stream invece di 1) alza il
|
||||
DSR dove il 2-gambe falliva.
|
||||
|
||||
DISTINZIONE da quanto gia' testato:
|
||||
* IREV (xsec_v2_nonmom, idio-REVERSAL, sgn=-1): FALLITO. Qui sgn=+1 (idio-MOMENTUM).
|
||||
* IMOM (xsec_v2_nonmom): residuo vs mercato EQUAL-WEIGHT, B=60 fisso, no blend, era solo
|
||||
"riferimento momentum". Qui: fattore = BTC (come STATARB-RESID), B in griglia, blend z-score
|
||||
[30,90] + probe con gate di dispersione (parita' strutturale con XS01), selezione IN-SAMPLE.
|
||||
|
||||
IL BAR (fondamentale): una variante di XS01 e' utile SOLO se (a) SOSTITUISCE XS01 (meglio
|
||||
standalone E nel portafoglio 4-sleeve) oppure (b) AGGIUNGE come 5o sleeve (corr bassa a XS01 E
|
||||
TP01, uplift del PORTAFOGLIO). corr>0.6 a XS01 senza batterlo -> REDUNDANT/SCARTATO.
|
||||
Baseline XS01: standalone FULL Sh ~1.50 / HOLD ~1.71 / DD ~11%.
|
||||
|
||||
GATE (CLAUDE.md, metodologia obbligatoria):
|
||||
1. CAUSALE: score a close[i], peso tenuto in i+1 (engine shifta W[i-1]*dret[i]); barre vol=0
|
||||
escluse; prefix-check di causalita' sulla cella scelta.
|
||||
2. NETTO fee 0.10% RT per gamba per ribilancio + sweep {0.05, 0.10, 0.20, 0.30}% RT/gamba.
|
||||
3. Selezione cella IN-SAMPLE-ONLY (pre-2025, anti selection-on-holdout), poi hold-out bloccato.
|
||||
4. DEFLATED Sharpe su TUTTI i trial della griglia (serve >=0.95).
|
||||
5. Confronto PORTAFOGLIO: sostituzione di XS01 a parita' di peso + aggiunta 5o sleeve @10/15%
|
||||
(riusa StrategyPortfolio/active_sleeves senza modificarli) + marginal vs il BOOK a 4 sleeve.
|
||||
6. CAVEAT IMMUTABILI: storia HL ~2.5 anni; book L/S a ~2k gambe -> STAT-MODE a $600 (dichiaro
|
||||
comunque l'haircut small-cap $600/min$5).
|
||||
|
||||
uv run python scripts/research/r0701_xs_residmom.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 / "scripts" / "research"))
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
import xsec_v2_nonmom as xv # harness collaudato (load_matrix, deflated_sharpe, portfolio, ...)
|
||||
from src.portfolio.sleeves import XS_UNIVERSE
|
||||
|
||||
DPY, TV, FEE, HOLDOUT = xv.DPY, xv.TV, xv.FEE, xv.HOLDOUT
|
||||
FACTOR = "BTC" # il fattore del residuo (come STATARB-RESID), NON tradato
|
||||
BLEND = (30, 90) # blend z-score come XS01
|
||||
|
||||
# griglia (modesta, da mandato): beta-window x lookback x k x H (gate=off)
|
||||
BETAS = (60, 90, 120)
|
||||
LOOKS = ("blend", 30, 90)
|
||||
KS = (3, 5)
|
||||
HS = (5, 10, 20)
|
||||
# probe a parita' STRUTTURALE con XS01 (blend + gate dispersione p30, H10 k5) — contano nei trial
|
||||
GATED_PROBES = [dict(B=B, L="blend", k=5, H=10, gate=30) for B in BETAS]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# SCORE BUILDER — residual momentum vs beta*BTC. CAUSALE (dati <= i).
|
||||
# Ritorna score_at(i) -> (score_blend_z[A], valid[A], disp_raw_i) + warmup.
|
||||
# disp_raw_i = dispersione cross-section del momentum RESIDUO grezzo (per il gate: lo z-score
|
||||
# blended ha std ~1 per costruzione, quindi la dispersione va misurata sul grezzo).
|
||||
# ===========================================================================
|
||||
def make_residmom(PX: pd.DataFrame, B: int, L):
|
||||
lookbacks = BLEND if L == "blend" else (int(L),)
|
||||
px = PX.values
|
||||
n, A = px.shape
|
||||
fi = list(PX.columns).index(FACTOR)
|
||||
DR = PX.pct_change()
|
||||
m = DR[FACTOR]
|
||||
beta, _ = xv._rolling_beta(DR, m, B) # beta_j vs BTC su finestra B (<= i)
|
||||
SDR = {Lk: DR.rolling(Lk, min_periods=int(0.8 * Lk)).sum().values for Lk in lookbacks}
|
||||
SM = {Lk: m.rolling(Lk, min_periods=int(0.8 * Lk)).sum().values for Lk in lookbacks}
|
||||
CNT = {Lk: DR.rolling(Lk, min_periods=1).count().values for Lk in lookbacks}
|
||||
|
||||
def score_at(i):
|
||||
b = beta[i]
|
||||
valid = np.isfinite(px[i]) & np.isfinite(b)
|
||||
valid[fi] = False # BTC = fattore, fuori dal cross-section
|
||||
resids = []
|
||||
for Lk in lookbacks:
|
||||
resid = SDR[Lk][i] - b * SM[Lk][i] # momentum del residuo r_j - beta_j*r_btc
|
||||
valid = valid & np.isfinite(resid) & (CNT[Lk][i] >= 0.8 * Lk)
|
||||
resids.append(resid)
|
||||
score = np.full(A, np.nan)
|
||||
disp = np.nan
|
||||
nv = int(valid.sum())
|
||||
if nv >= 2:
|
||||
acc = np.zeros(nv)
|
||||
cnt = 0
|
||||
stds = []
|
||||
for resid in resids:
|
||||
r = resid[valid]
|
||||
sd = float(r.std())
|
||||
stds.append(sd)
|
||||
if sd > 0:
|
||||
acc += (r - r.mean()) / sd
|
||||
cnt += 1
|
||||
if cnt:
|
||||
score[valid] = acc / cnt # blend: media z-score cross-sectional
|
||||
disp = float(np.mean(stds)) # dispersione del momentum residuo GREZZO
|
||||
return score, valid, disp
|
||||
|
||||
return score_at, max(max(lookbacks), B) + 1
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# ENGINE locale (= xv.xs_engine + ritorno di W/scale + gate di dispersione opzionale).
|
||||
# L'uguaglianza con xv.xs_engine sulle celle non-gated e' VERIFICATA in main().
|
||||
# ===========================================================================
|
||||
def xs_engine_w(PX, VOL, score_at, H, k, target_vol=TV, fee=FEE, min_assets=10, warmup=0,
|
||||
disp_pct=0, disp_minhist=20):
|
||||
px = PX.values
|
||||
vol = VOL.values
|
||||
n, A = px.shape
|
||||
dret = np.full((n, A), np.nan)
|
||||
dret[1:] = px[1:] / px[:-1] - 1.0
|
||||
W = np.zeros((n, A))
|
||||
w = np.zeros(A)
|
||||
disp_hist = []
|
||||
for i in range(n):
|
||||
if i >= warmup and i % H == 0:
|
||||
score, valid, disp = score_at(i)
|
||||
valid = valid & np.isfinite(score) & (vol[i] > 0)
|
||||
idxv = np.where(valid)[0]
|
||||
if len(idxv) >= min_assets:
|
||||
thr = (np.percentile(disp_hist, disp_pct)
|
||||
if (disp_pct > 0 and len(disp_hist) >= disp_minhist) else -np.inf)
|
||||
if not (disp_pct > 0) or (np.isfinite(disp) and disp >= thr):
|
||||
kk = min(k, len(idxv) // 2)
|
||||
order = idxv[np.argsort(score[idxv])]
|
||||
lo, hi = order[:kk], order[-kk:]
|
||||
w = np.zeros(A)
|
||||
w[hi] = 0.5 / kk # long alto residual-momentum (sgn=+1)
|
||||
w[lo] = -0.5 / kk # short basso
|
||||
else:
|
||||
w = np.zeros(A) # regime compatto -> flat (gate)
|
||||
if disp_pct > 0 and np.isfinite(disp):
|
||||
disp_hist.append(disp)
|
||||
else:
|
||||
w = np.zeros(A)
|
||||
W[i] = w
|
||||
gross = np.zeros(n)
|
||||
gross[1:] = np.nansum(W[:-1] * np.nan_to_num(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)
|
||||
net = gross - turn * (fee / 2.0)
|
||||
s = pd.Series(net, index=PX.index)
|
||||
rv = s.rolling(30, min_periods=15).std().shift(1) * np.sqrt(DPY)
|
||||
scale = np.clip(np.nan_to_num(target_vol / rv.replace(0, np.nan).values, nan=0.0), 0, 3.0)
|
||||
turn_py = float(turn.sum() / (n / DPY)) if n else 0.0
|
||||
return pd.Series(s.values * scale, index=PX.index), turn_py, W, scale
|
||||
|
||||
|
||||
def run_cell(PX, VOL, cfg, fee=FEE):
|
||||
score_at, warm = make_residmom(PX, cfg["B"], cfg["L"])
|
||||
daily, turn, W, scale = xs_engine_w(PX, VOL, score_at, cfg["H"], cfg["k"], fee=fee,
|
||||
warmup=warm, disp_pct=cfg.get("gate", 0))
|
||||
return xv.to_daily(daily), turn, W, scale
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# CAUSALITA' (prefix-check, pattern di xv.causality_prefix_check sul nostro engine)
|
||||
# ===========================================================================
|
||||
def causality_prefix_check(PX, VOL, cfg, frac=0.85, tail=60, tol=1e-9):
|
||||
score_full, warm = make_residmom(PX, cfg["B"], cfg["L"])
|
||||
full, *_ = xs_engine_w(PX, VOL, score_full, cfg["H"], cfg["k"], warmup=warm,
|
||||
disp_pct=cfg.get("gate", 0))
|
||||
cut = int(len(PX) * frac)
|
||||
PXc, VOLc = PX.iloc[:cut], VOL.iloc[:cut]
|
||||
score_pre, warm2 = make_residmom(PXc, cfg["B"], cfg["L"])
|
||||
pre, *_ = xs_engine_w(PXc, VOLc, score_pre, cfg["H"], cfg["k"], warmup=warm2,
|
||||
disp_pct=cfg.get("gate", 0))
|
||||
lo = max(0, cut - tail)
|
||||
a, b = full.values[lo:cut], pre.values[lo:cut]
|
||||
worst = float(np.max(np.abs(a - b))) if len(a) else float("nan")
|
||||
return dict(ok=bool(worst <= tol), max_tail_diff=worst, cut=cut, tail=len(a))
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# SMALL-CAP $600 (dichiarativo: il book resta STAT-MODE comunque, come XS01).
|
||||
# Pesi EFFETTIVI = W[i] * scale[i+1] (scale[i+1] usa net fino a i via shift(1) -> noto a close i).
|
||||
# Un cambio-gamba con |dw|*capital < min_order NON si esegue. Confronto realistico vs modeled
|
||||
# sulla STESSA simulazione a pesi (coerente internamente).
|
||||
# ===========================================================================
|
||||
def smallcap_check(PX, W, scale, fee=FEE, capital=600.0, min_order=5.0):
|
||||
px = PX.values
|
||||
n, A = px.shape
|
||||
dret = np.nan_to_num(np.vstack([np.zeros(A), px[1:] / px[:-1] - 1.0]))
|
||||
sc = np.roll(scale, -1)
|
||||
sc[-1] = scale[-1]
|
||||
T = W * sc[:, None] # target effettivo deciso a close i
|
||||
|
||||
def sim(min_ord):
|
||||
held = np.zeros((n, A))
|
||||
cur = np.zeros(A)
|
||||
n_tr = 0
|
||||
for i in range(n):
|
||||
d = np.abs(T[i] - cur) * capital
|
||||
ex = d >= min_ord
|
||||
n_tr += int(ex.sum())
|
||||
cur = np.where(ex, T[i], cur)
|
||||
held[i] = cur
|
||||
pos = np.zeros((n, A))
|
||||
pos[1:] = held[:-1]
|
||||
turn = np.abs(np.diff(held, axis=0, prepend=np.zeros((1, A)))).sum(axis=1)
|
||||
net = (pos * dret).sum(axis=1) - turn * (fee / 2.0)
|
||||
r = net[np.isfinite(net)]
|
||||
sh = float(r.mean() / r.std() * np.sqrt(DPY)) if r.std() > 0 else 0.0
|
||||
return sh, n_tr, float(turn.sum() / (n / DPY))
|
||||
|
||||
sh_real, ntr_real, turn_real = sim(min_order)
|
||||
sh_mod, ntr_mod, turn_mod = sim(0.0)
|
||||
return dict(sharpe_modeled=round(sh_mod, 3), sharpe_realistic=round(sh_real, 3),
|
||||
haircut=round(sh_mod - sh_real, 3), n_executed=ntr_real, n_modeled=ntr_mod,
|
||||
turnover_real=round(turn_real, 1), turnover_modeled=round(turn_mod, 1))
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# PORTAFOGLIO — sostituzione XS01 + aggiunta 5o sleeve (pattern di xsec_v3_momstruct)
|
||||
# ===========================================================================
|
||||
_BASE = None
|
||||
_BASE_M = None
|
||||
|
||||
|
||||
def _base():
|
||||
global _BASE, _BASE_M
|
||||
if _BASE is None:
|
||||
_BASE = xv.active_sleeves()
|
||||
pf = xv.StrategyPortfolio(_BASE)
|
||||
pf.backtest()
|
||||
_BASE_M = (xv.metrics(pf.combined_daily()), xv.metrics(pf.combined_daily(lo=HOLDOUT)))
|
||||
return _BASE, _BASE_M
|
||||
|
||||
|
||||
def add_uplift(daily, fr):
|
||||
base, _ = _base()
|
||||
wraw = fr / (1.0 - fr)
|
||||
cand = xv.Sleeve("R0701_cand", wraw, lambda d=daily: d)
|
||||
pf = xv.StrategyPortfolio(base + [cand])
|
||||
return (xv.metrics(pf.combined_daily()), xv.metrics(pf.combined_daily(lo=HOLDOUT)),
|
||||
pf.weights().get("R0701_cand", 0.0))
|
||||
|
||||
|
||||
def substitute_xs01(daily):
|
||||
base, _ = _base()
|
||||
sub = [xv.Sleeve("R0701_sub", s.weight, lambda d=daily: d) if s.name == "XS01_xsec_hl" else s
|
||||
for s in base]
|
||||
pf = xv.StrategyPortfolio(sub)
|
||||
return xv.metrics(pf.combined_daily()), xv.metrics(pf.combined_daily(lo=HOLDOUT))
|
||||
|
||||
|
||||
def marginal_vs_book(daily):
|
||||
"""Corr + uplift del blend 0.9*BOOK+0.1*cand vs il BOOK a 4 sleeve (full/hold + multi-cut)."""
|
||||
base, _ = _base()
|
||||
book = xv.StrategyPortfolio(base).combined_daily()
|
||||
J = pd.concat({"B": book, "C": daily}, axis=1, join="inner").dropna()
|
||||
|
||||
def _sh(s):
|
||||
r = np.asarray(s.dropna().values, float)
|
||||
return float(r.mean() / r.std() * np.sqrt(DPY)) if len(r) > 2 and r.std() > 0 else 0.0
|
||||
|
||||
def _up(sub):
|
||||
return _sh(0.9 * sub["B"] + 0.1 * sub["C"]) - _sh(sub["B"])
|
||||
|
||||
JH = J[J.index >= HOLDOUT]
|
||||
cuts = {}
|
||||
for y in sorted(set(J.index.year))[1:]:
|
||||
sub = J[J.index >= pd.Timestamp(f"{y}-01-01", tz="UTC")]
|
||||
if len(sub) >= 120:
|
||||
cuts[int(y)] = round(_up(sub), 3)
|
||||
return dict(corr_book=round(float(J["B"].corr(J["C"])), 3),
|
||||
uplift_full=round(_up(J), 3),
|
||||
uplift_hold=round(_up(JH), 3) if len(JH) > 30 else None,
|
||||
multicut=cuts)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
def per_year(daily):
|
||||
return [(int(y), round(float((1 + g).prod() - 1), 3)) for y, g in daily.groupby(daily.index.year)]
|
||||
|
||||
|
||||
def tag(cfg):
|
||||
g = f" gate{cfg['gate']}" if cfg.get("gate") else ""
|
||||
return f"B{cfg['B']} L{cfg['L']} k{cfg['k']} H{cfg['H']}{g}"
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 104)
|
||||
print(" r0701_xs — RESIDUAL MOMENTUM cross-sectional (residuo vs beta*BTC) sui 19 major HL — STAT-MODE")
|
||||
print("=" * 104)
|
||||
|
||||
PX, VOL = xv.load_matrix(XS_UNIVERSE)
|
||||
print(f" universo 19-major: {PX.shape[1]} asset, {PX.shape[0]} giorni "
|
||||
f"[{PX.index[0].date()} -> {PX.index[-1].date()}] fattore={FACTOR} (escluso dal cross-section)")
|
||||
|
||||
tp_daily = xv.tp01_sleeve().daily()
|
||||
xs_daily = xv.xsec_sleeve().daily()
|
||||
xs_f = xv.metrics(xs_daily)
|
||||
xs_h = xv.metrics(xs_daily[xs_daily.index >= HOLDOUT])
|
||||
print(f" baseline XS01 (sleeve attivo): FULL Sh {xs_f['sharpe']:.2f} DD {xs_f['maxdd']*100:.0f}%"
|
||||
f" | HOLD Sh {xs_h['sharpe']:.2f}")
|
||||
|
||||
# --- sanity: engine locale == xv.xs_engine sulle celle non-gated -----------------
|
||||
chk_cfg = dict(B=90, L=30, k=5, H=10)
|
||||
score_at, warm = make_residmom(PX, chk_cfg["B"], chk_cfg["L"])
|
||||
mine, *_ = xs_engine_w(PX, VOL, score_at, chk_cfg["H"], chk_cfg["k"], warmup=warm)
|
||||
ref, _ = xv.xs_engine(PX, VOL, lambda i: score_at(i)[:2], chk_cfg["H"], chk_cfg["k"], warmup=warm)
|
||||
dmax = float(np.nanmax(np.abs(mine.values - ref.values)))
|
||||
assert dmax < 1e-12, f"engine locale diverge da xv.xs_engine: {dmax}"
|
||||
print(f" [sanity] engine locale == xv.xs_engine (maxdiff {dmax:.1e})")
|
||||
|
||||
# --- griglia -----------------------------------------------------------------------
|
||||
grid = [dict(B=B, L=L, k=k, H=H) for B in BETAS for L in LOOKS for k in KS for H in HS]
|
||||
grid += GATED_PROBES
|
||||
rows = []
|
||||
for cfg in grid:
|
||||
daily, turn, W, scale = run_cell(PX, VOL, cfg)
|
||||
if daily.std() == 0 or len(daily) < 60:
|
||||
continue
|
||||
f, h, pct = xv.evalcfg(daily)
|
||||
ins = daily[daily.index < HOLDOUT]
|
||||
is_sh = xv.metrics(ins)["sharpe"] if len(ins) > 60 else float("nan")
|
||||
rows.append(dict(cfg=cfg, daily=daily, W=W, scale=scale, turn=turn,
|
||||
full=f["sharpe"], hold=h["sharpe"], dd=f["maxdd"], ret=f["ret"],
|
||||
pct=pct, is_sh=is_sh,
|
||||
corrXS=xv._corr(daily, xs_daily), corrTP=xv._corr(daily, tp_daily)))
|
||||
all_sr = [r["full"] for r in rows]
|
||||
print(f"\n griglia: {len(rows)} celle valide su {len(grid)} "
|
||||
f"(trial per deflated-Sharpe = {len(all_sr)}; il conteggio VERO del programma e' >>)")
|
||||
|
||||
hdr = f" {'cfg':<26}{'IS':>7}{'FULL':>7}{'HOLD':>7}{'DD%':>6}{'anni+':>7}{'corrXS':>8}{'corrTP':>8}{'turn/y':>8}"
|
||||
valid = [r for r in rows if np.isfinite(r["is_sh"])]
|
||||
print("\n TOP-5 per Sharpe IN-SAMPLE (pre-2025) — la selezione ONESTA:")
|
||||
print(hdr)
|
||||
for r in sorted(valid, key=lambda r: -r["is_sh"])[:5]:
|
||||
print(f" {tag(r['cfg']):<26}{r['is_sh']:>7.2f}{r['full']:>7.2f}{r['hold']:>7.2f}"
|
||||
f"{r['dd']*100:>6.0f}{r['pct']*100:>6.0f}%{r['corrXS']:>+8.2f}{r['corrTP']:>+8.2f}{r['turn']:>8.0f}")
|
||||
print("\n TOP-5 per HOLD (solo trasparenza — selezionare qui = selection-on-holdout):")
|
||||
print(hdr)
|
||||
for r in sorted(rows, key=lambda r: -r["hold"])[:5]:
|
||||
iss = f"{r['is_sh']:.2f}" if np.isfinite(r["is_sh"]) else "n/a"
|
||||
print(f" {tag(r['cfg']):<26}{iss:>7}{r['full']:>7.2f}{r['hold']:>7.2f}"
|
||||
f"{r['dd']*100:>6.0f}{r['pct']*100:>6.0f}%{r['corrXS']:>+8.2f}{r['corrTP']:>+8.2f}{r['turn']:>8.0f}")
|
||||
|
||||
if not valid:
|
||||
print("\n >>> nessuna cella con in-sample valutabile. SCARTATO.")
|
||||
return
|
||||
|
||||
pick = max(valid, key=lambda r: r["is_sh"])
|
||||
daily = pick["daily"]
|
||||
print("\n" + "=" * 104)
|
||||
print(f" CELLA SCELTA (in-sample-only): {tag(pick['cfg'])}")
|
||||
print("=" * 104)
|
||||
print(f" IS Sh {pick['is_sh']:.2f} | FULL {pick['full']:.2f} | HOLD {pick['hold']:.2f}"
|
||||
f" | DD {pick['dd']*100:.0f}% | ret {pick['ret']*100:+.0f}% | anni+ {pick['pct']*100:.0f}%"
|
||||
f" | turnover/y {pick['turn']:.0f}")
|
||||
print(f" corr vs XS01 {pick['corrXS']:+.2f} | corr vs TP01 {pick['corrTP']:+.2f}"
|
||||
f" | per-anno {per_year(daily)}")
|
||||
|
||||
caus = causality_prefix_check(PX, VOL, pick["cfg"])
|
||||
print(f" CAUSALITA' (prefix-check): ok={caus['ok']} max_tail_diff={caus['max_tail_diff']:.2e}")
|
||||
|
||||
dsr, sr0 = xv.deflated_sharpe(pick["full"], all_sr, daily)
|
||||
print(f" DEFLATED Sharpe (N={len(all_sr)} trial di QUESTA griglia): {dsr:.3f}"
|
||||
f" | soglia null-max annualizz. {sr0:.2f} (serve >=0.95)")
|
||||
|
||||
print(" fee sweep (RT per gamba):", end=" ")
|
||||
for fee in (0.0005, 0.001, 0.002, 0.003):
|
||||
d_f, *_ = run_cell(PX, VOL, pick["cfg"], fee=fee)
|
||||
ff = xv.metrics(d_f)
|
||||
hh = xv.metrics(d_f[d_f.index >= HOLDOUT])
|
||||
print(f"{fee*100:.2f}%: F{ff['sharpe']:+.2f}/H{hh['sharpe']:+.2f}", end=" ")
|
||||
print()
|
||||
|
||||
sc = smallcap_check(PX, pick["W"], pick["scale"])
|
||||
print(f" SMALL-CAP $600/min$5 (dichiarativo, resta STAT-MODE): modeled Sh {sc['sharpe_modeled']:.2f}"
|
||||
f" -> realistic {sc['sharpe_realistic']:.2f} (haircut {sc['haircut']:+.2f});"
|
||||
f" fill eseguiti {sc['n_executed']}/{sc['n_modeled']}"
|
||||
f" turn/y {sc['turnover_real']:.0f} vs {sc['turnover_modeled']:.0f}")
|
||||
|
||||
# --- confronto PORTAFOGLIO -----------------------------------------------------------
|
||||
print("\n PORTAFOGLIO (TP01+XS01+VRP01+SKH01, pesi canonici):")
|
||||
_, (bf, bh) = _base()
|
||||
print(f" BASE 4-sleeve FULL Sh {bf['sharpe']:.2f} DD {bf['maxdd']*100:.1f}%"
|
||||
f" | HOLD Sh {bh['sharpe']:.2f} DD {bh['maxdd']*100:.1f}%")
|
||||
sf, sh_ = substitute_xs01(daily)
|
||||
sub_full_d, sub_hold_d = sf["sharpe"] - bf["sharpe"], sh_["sharpe"] - bh["sharpe"]
|
||||
print(f" SOSTITUZIONE XS01 -> cand FULL Sh {sf['sharpe']:.2f} ({sub_full_d:+.2f}) DD {sf['maxdd']*100:.1f}%"
|
||||
f" | HOLD Sh {sh_['sharpe']:.2f} ({sub_hold_d:+.2f}) DD {sh_['maxdd']*100:.1f}%")
|
||||
up_best = (-9.0, -9.0)
|
||||
for fr in (0.10, 0.15):
|
||||
cf, ch, wgt = add_uplift(daily, fr)
|
||||
d_f, d_h = cf["sharpe"] - bf["sharpe"], ch["sharpe"] - bh["sharpe"]
|
||||
if d_h > up_best[1]:
|
||||
up_best = (d_f, d_h)
|
||||
print(f" AGGIUNTA 5o sleeve @{wgt*100:>4.1f}% FULL Sh {cf['sharpe']:.2f} ({d_f:+.2f}) DD {cf['maxdd']*100:.1f}%"
|
||||
f" | HOLD Sh {ch['sharpe']:.2f} ({d_h:+.2f}) DD {ch['maxdd']*100:.1f}%")
|
||||
mb = marginal_vs_book(daily)
|
||||
print(f" MARGINAL vs BOOK: corr {mb['corr_book']:+.2f} | uplift@10% full {mb['uplift_full']:+.3f}"
|
||||
f" hold {mb['uplift_hold']:+.3f} | multi-cut {mb['multicut']}")
|
||||
|
||||
# --- VERDETTO -------------------------------------------------------------------------
|
||||
print("\n" + "=" * 104)
|
||||
beats_xs_standalone = (pick["full"] > xs_f["sharpe"] and pick["hold"] > xs_h["sharpe"])
|
||||
dominates = sub_full_d > 0.02 and sub_hold_d > 0.05 and beats_xs_standalone
|
||||
diversifies = (abs(pick["corrXS"]) < 0.6 and abs(pick["corrTP"]) < 0.5
|
||||
and up_best[1] > 0.05 and mb["uplift_hold"] is not None and mb["uplift_hold"] > 0
|
||||
and all(u > 0 for u in mb["multicut"].values()))
|
||||
dsr_ok = np.isfinite(dsr) and dsr >= 0.95
|
||||
if not caus["ok"]:
|
||||
verdict, why = "SCARTATO", "prefix-check di causalita' fallito"
|
||||
elif pick["full"] <= 0.3 or pick["hold"] <= 0:
|
||||
verdict, why = "SCARTATO", f"standalone debole (FULL {pick['full']:+.2f}, HOLD {pick['hold']:+.2f})"
|
||||
elif abs(pick["corrXS"]) > 0.6 and not dominates:
|
||||
verdict, why = "REDUNDANT", f"corrXS {pick['corrXS']:+.2f}>0.6 e non batte XS01 (sub HOLD {sub_hold_d:+.2f})"
|
||||
elif dominates and dsr_ok:
|
||||
verdict, why = "CANDIDATO-SLEEVE (sostituto)", "batte XS01 standalone E nel book, DSR>=0.95"
|
||||
elif diversifies and dsr_ok:
|
||||
verdict, why = "CANDIDATO-SLEEVE (5o)", "scorrelato, uplift book persistente, DSR>=0.95"
|
||||
elif dominates or diversifies:
|
||||
verdict, why = "LEAD-forward", f"profilo utile ma DSR {dsr:.2f}<0.95 (storia ~2.5a, multiple-testing)"
|
||||
else:
|
||||
verdict, why = "SCARTATO", (f"ne' sostituto (sub HOLD {sub_hold_d:+.2f}) ne' additivo"
|
||||
f" (uplift HOLD {up_best[1]:+.2f}, corrXS {pick['corrXS']:+.2f})")
|
||||
print(f" VERDETTO: {verdict} — {why}")
|
||||
print(" CAVEAT immutabili: storia HL ~2.5 anni; in-sample = solo 2024 (selezione su finestra corta);")
|
||||
print(" book L/S multi-gamba -> STAT-MODE a $600 (come XS01), mai deploy a questo capitale.")
|
||||
print("=" * 104)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user