research(crypto): 4 filoni 2026-06-29 — ERM lead sub-daily (forward), 3 scartati/deboli
Ricerca onesta su BTC/ETH + universo HL, branch separato (nessun impatto live).
Harness condiviso altlib (causale, fee 0.10% RT, marginal vs TP01, day-boundary,
haircut $600). Test 19/19 verdi.
- A DVOL direzionale -> LEAD hedge/DD-dampener, NON sleeve (buy-the-fear; is_hedge).
- B Intraday ERM 8h -> LEAD forte / forward-monitor: earns_slot=True, ADDS oltre
SKH01 (TP01+SKH+ERM 60/25/15 FULL 1.88/HOLD 1.46/DD 8.9%).
Caveat: plateau hold-out single-row, multiple-testing non
deflazionato, exec 8h. Controllo TOD = FAIL atteso.
- C Cross-sectional non-mom (low-vol HL) -> DEBOLE/forward-monitor (deflated-Sh 0.13,
storia 2.5a, non eseguibile $600) STAT-MODE.
- D Macro regime-gate -> RIDONDANTE col trend (corr->TP01 0.989), SCARTATO.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,433 @@
|
||||
"""XSEC v2 — segnali cross-sectional NON-MOMENTUM su 51 asset Hyperliquid (STAT-MODE).
|
||||
|
||||
TESI (filone C). XS01 (sleeve attivo) e' momentum cross-sectional sui 19 major. Lezione del
|
||||
progetto (diari 2026-06-19/20): ESPANDERE IL NUMERO di asset NON aiuta il momentum (gli small-cap
|
||||
diluiscono/invertono il segnale). Quindi qui NON ri-proviamo l'espansione-universo: cerchiamo un
|
||||
MECCANISMO DIVERSO dal momentum che, market-neutral e scorrelato, possa diversificare il portafoglio.
|
||||
|
||||
Meccanismi provati (tutti L/S dollar-neutral, vol-target ~20%, ribilancio periodico, CAUSALI):
|
||||
REV - short-term REVERSAL cross-sectional grezzo (long i loser di breve, short i winner).
|
||||
IREV - REVERSAL IDIOSINCRATICO: reversal sul RESIDUO dopo aver tolto il mercato (beta-adjusted).
|
||||
LOWVOL - factor LOW-VOL: long bassa vol realizzata / short alta vol (betting-against-vol).
|
||||
IMOM - MOMENTUM IDIOSINCRATICO: momentum sul residuo (toglie il fattore mercato, != raw mom).
|
||||
BAB - betting-against-beta: long basso beta / short alto beta.
|
||||
MOM - (riferimento) momentum grezzo, per confronto.
|
||||
|
||||
GATE (CLAUDE.md, metodologia obbligatoria):
|
||||
1. CAUSALE: score a close[i], peso tenuto in i+1 (l'engine shifta: W[i-1]*dret[i]); vol=0 gata.
|
||||
2. NETTO fee 0.10% RT su OGNI gamba a OGNI ribilancio + sweep fee.
|
||||
3. OOS hold-out 2025-01-01 + plateau su (lookback, H, k) + 2 universi (51 vs 19 major).
|
||||
4. Storia ~2.5 anni + molte config -> DEFLATED Sharpe (multiple-testing) e onesta' brutale.
|
||||
5. Confronto: Sharpe standalone FULL/HOLD/DD, corr vs XS01 e TP01, uplift del portafoglio a 4->5
|
||||
sleeve (portfolio.py, riusa active_sleeves senza modificarli).
|
||||
6. CAVEAT: book a molte gambe NON eseguibile a $600 -> STAT-MODE / forward-monitor, non deploy.
|
||||
|
||||
uv run python scripts/research/xsec_v2_nonmom.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys, glob, math
|
||||
from pathlib import Path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from scipy.stats import norm
|
||||
|
||||
from src.portfolio.portfolio import to_daily, metrics, HOLDOUT, Sleeve, StrategyPortfolio
|
||||
from src.portfolio.sleeves import tp01_sleeve, xsec_sleeve, active_sleeves, XS_UNIVERSE
|
||||
|
||||
RAW = PROJECT_ROOT / "data" / "raw"
|
||||
FEE = 0.001 # 0.10% RT (Deribit taker): fee per gamba per lato = FEE/2 = 0.0005
|
||||
TV = 0.20 # vol-target annuo
|
||||
DPY = 365.25
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# DATI — matrice prezzi/volumi (outer-join: ragged start, NaN prima del listing)
|
||||
# ===========================================================================
|
||||
def load_matrix(universe=None):
|
||||
px, vol = {}, {}
|
||||
files = sorted(glob.glob(str(RAW / "hl_*_1d.parquet")))
|
||||
for f in files:
|
||||
sym = Path(f).stem.replace("hl_", "").replace("_1d", "").upper()
|
||||
if universe is not None and sym not in universe:
|
||||
continue
|
||||
d = pd.read_parquet(f)
|
||||
idx = pd.to_datetime(d["timestamp"], unit="ms", utc=True)
|
||||
px[sym] = pd.Series(d["close"].values.astype(float), index=idx)
|
||||
vol[sym] = pd.Series(d["volume"].values.astype(float), index=idx)
|
||||
PX = pd.concat(px, axis=1).sort_index()
|
||||
VOL = pd.concat(vol, axis=1).sort_index().reindex_like(PX)
|
||||
return PX, VOL
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# ENGINE cross-sectional NaN-aware (causale). score_at(i)->(score[A], valid[A]).
|
||||
# Convenzione UNICA: long alto score / short basso score. Ogni meccanismo passa
|
||||
# lo score giusto (es. reversal = -ritorno; low-vol = -vol; bab = -beta).
|
||||
# ===========================================================================
|
||||
def xs_engine(PX, VOL, score_at, H, k, target_vol=TV, fee=FEE, min_assets=10, warmup=0):
|
||||
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)
|
||||
for i in range(n):
|
||||
if i >= warmup and i % H == 0:
|
||||
score, valid = score_at(i)
|
||||
valid = valid & np.isfinite(score) & (vol[i] > 0)
|
||||
idxv = np.where(valid)[0]
|
||||
if len(idxv) >= min_assets:
|
||||
kk = min(k, len(idxv) // 2)
|
||||
order = idxv[np.argsort(score[idxv])] # ascendente
|
||||
lo, hi = order[:kk], order[-kk:] # basso score / alto score
|
||||
w = np.zeros(A)
|
||||
w[hi] = 0.5 / kk # long alto score
|
||||
w[lo] = -0.5 / kk # short basso score
|
||||
else:
|
||||
w = np.zeros(A)
|
||||
W[i] = w
|
||||
# rendimento book: W[i-1] guadagna dret[i]; NaN (asset non listato) -> 0
|
||||
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
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# SCORE BUILDERS — ognuno ritorna una closure score_at(i) + warmup richiesto.
|
||||
# Tutti CAUSALI: usano dati <= i (close[i] noto al momento della decisione).
|
||||
# ===========================================================================
|
||||
def _precompute(PX):
|
||||
px = PX.values
|
||||
n, A = px.shape
|
||||
DR = PX.pct_change() # ritorni giornalieri (NaN ragged)
|
||||
m = DR.mean(axis=1) # mercato equal-weight (skipna)
|
||||
return px, n, A, DR, m
|
||||
|
||||
|
||||
def make_mom(PX, L, sign=+1):
|
||||
px, n, A, *_ = _precompute(PX)
|
||||
def score_at(i):
|
||||
if i - L < 0:
|
||||
return np.full(A, np.nan), np.zeros(A, bool)
|
||||
r = px[i] / px[i - L] - 1.0
|
||||
valid = np.isfinite(px[i]) & np.isfinite(px[i - L])
|
||||
return sign * r, valid
|
||||
return score_at, L + 1
|
||||
|
||||
|
||||
def make_lowvol(PX, B):
|
||||
px, n, A, DR, m = _precompute(PX)
|
||||
RV = DR.rolling(B, min_periods=int(0.6 * B)).std().values
|
||||
def score_at(i):
|
||||
rv = RV[i]
|
||||
valid = np.isfinite(rv) & np.isfinite(px[i])
|
||||
return -rv, valid # long bassa vol / short alta vol
|
||||
return score_at, B + 1
|
||||
|
||||
|
||||
def _rolling_beta(DR, m, B):
|
||||
mp = int(0.6 * B)
|
||||
Em = m.rolling(B, min_periods=mp).mean()
|
||||
Em2 = (m * m).rolling(B, min_periods=mp).mean()
|
||||
varm = Em2 - Em ** 2
|
||||
Ex = DR.rolling(B, min_periods=mp).mean()
|
||||
Exm = DR.mul(m, axis=0).rolling(B, min_periods=mp).mean()
|
||||
beta = Exm.sub(Ex.mul(Em, axis=0)).div(varm.replace(0, np.nan), axis=0)
|
||||
return beta.values, varm.values
|
||||
|
||||
|
||||
def make_bab(PX, B):
|
||||
px, n, A, DR, m = _precompute(PX)
|
||||
beta, _ = _rolling_beta(DR, m, B)
|
||||
def score_at(i):
|
||||
b = beta[i]
|
||||
valid = np.isfinite(b) & np.isfinite(px[i])
|
||||
return -b, valid # long basso beta / short alto beta
|
||||
return score_at, B + 1
|
||||
|
||||
|
||||
def make_resid(PX, L, B, sign):
|
||||
"""Momentum/reversal IDIOSINCRATICO: residuo = ritorno - beta*mercato (beta su finestra B),
|
||||
cumulato sugli ultimi L giorni. sign=+1 -> momentum residuo; sign=-1 -> reversal residuo."""
|
||||
px, n, A, DR, m = _precompute(PX)
|
||||
beta, _ = _rolling_beta(DR, m, B)
|
||||
SDR = DR.rolling(L, min_periods=int(0.8 * L)).sum().values # somma ritorni asset su L
|
||||
SM = m.rolling(L, min_periods=int(0.8 * L)).sum().values # somma mercato su L
|
||||
cnt = DR.rolling(L, min_periods=1).count().values
|
||||
def score_at(i):
|
||||
b = beta[i]
|
||||
resid_cum = SDR[i] - b * SM[i]
|
||||
valid = np.isfinite(resid_cum) & (cnt[i] >= 0.8 * L) & np.isfinite(px[i])
|
||||
return sign * resid_cum, valid
|
||||
return score_at, max(L, B) + 1
|
||||
|
||||
|
||||
# Catalogo meccanismi: nome -> (builder, lista di config (param dict)).
|
||||
def mechanisms():
|
||||
return {
|
||||
"MOM": (lambda PX, p: make_mom(PX, p["L"], +1),
|
||||
[dict(L=L, H=H, k=k) for L in (30, 60, 90) for H in (5, 10) for k in (5, 8)]),
|
||||
"REV": (lambda PX, p: make_mom(PX, p["L"], -1),
|
||||
[dict(L=L, H=H, k=k) for L in (2, 3, 5, 7, 10) for H in (1, 2, 3, 5) for k in (5, 8)]),
|
||||
"IREV": (lambda PX, p: make_resid(PX, p["L"], p.get("B", 60), -1),
|
||||
[dict(L=L, H=H, k=k, B=60) for L in (3, 5, 7, 10) for H in (2, 3, 5) for k in (5, 8)]),
|
||||
"IMOM": (lambda PX, p: make_resid(PX, p["L"], p.get("B", 60), +1),
|
||||
[dict(L=L, H=H, k=k, B=60) for L in (30, 60, 90) for H in (5, 10) for k in (5, 8)]),
|
||||
"LOWVOL": (lambda PX, p: make_lowvol(PX, p["B"]),
|
||||
[dict(B=B, H=H, k=k) for B in (20, 30, 60) for H in (5, 10) for k in (5, 8)]),
|
||||
"BAB": (lambda PX, p: make_bab(PX, p["B"]),
|
||||
[dict(B=B, H=H, k=k) for B in (30, 60) for H in (5, 10) for k in (5, 8)]),
|
||||
}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# METRICHE / STATISTICA
|
||||
# ===========================================================================
|
||||
def yr_breadth(daily):
|
||||
yr = [float((1 + g).prod() - 1) for _, g in daily.groupby(daily.index.year)]
|
||||
return (sum(v > 0 for v in yr) / len(yr) if yr else 0.0), yr
|
||||
|
||||
|
||||
def deflated_sharpe(sr_ann, all_sr_ann, daily_ret):
|
||||
"""Deflated Sharpe Ratio (Bailey & Lopez de Prado): probabilita' che lo Sharpe vero superi
|
||||
lo Sharpe-massimo atteso sotto il null di N trial indipendenti. Penalizza il multiple-testing.
|
||||
sr_ann: Sharpe annualizzato della config scelta; all_sr_ann: tutti gli Sharpe testati;
|
||||
daily_ret: serie ritorni giornalieri (per skew/kurt/T). Ritorna (DSR, sr0_ann)."""
|
||||
r = np.asarray(pd.Series(daily_ret).dropna().values, float)
|
||||
T = len(r)
|
||||
if T < 30 or np.std(r) == 0:
|
||||
return float("nan"), float("nan")
|
||||
sr = sr_ann / math.sqrt(DPY) # per-osservazione
|
||||
trials = np.asarray([s / math.sqrt(DPY) for s in all_sr_ann if np.isfinite(s)], float)
|
||||
N = max(len(trials), 2)
|
||||
var_tr = float(np.var(trials, ddof=1)) if N > 1 else 0.0
|
||||
emc = 0.5772156649
|
||||
z1 = norm.ppf(1 - 1.0 / N)
|
||||
z2 = norm.ppf(1 - 1.0 / (N * math.e))
|
||||
sr0 = math.sqrt(var_tr) * ((1 - emc) * z1 + emc * z2)
|
||||
sk = float(pd.Series(r).skew())
|
||||
ku = float(pd.Series(r).kurt()) + 3.0 # pandas kurt = excess
|
||||
den = math.sqrt(max(1e-9, 1 - sk * sr + (ku - 1) / 4.0 * sr ** 2))
|
||||
dsr = float(norm.cdf((sr - sr0) * math.sqrt(T - 1) / den))
|
||||
return dsr, sr0 * math.sqrt(DPY)
|
||||
|
||||
|
||||
def evalcfg(daily):
|
||||
f = metrics(daily)
|
||||
h = metrics(daily[daily.index >= HOLDOUT])
|
||||
pct, _ = yr_breadth(daily)
|
||||
return f, h, pct
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# RUN griglia per meccanismo / universo
|
||||
# ===========================================================================
|
||||
def run_grid(PX, VOL, mech_name, builder, cfgs, xs_daily, tp_daily, label):
|
||||
rows = []
|
||||
for p in cfgs:
|
||||
score_at, warm = builder(PX, p)
|
||||
daily, turn = xs_engine(PX, VOL, score_at, p["H"], p["k"], warmup=warm)
|
||||
daily = to_daily(daily)
|
||||
if daily.std() == 0 or len(daily) < 60:
|
||||
continue
|
||||
f, h, pct = evalcfg(daily)
|
||||
cx = _corr(daily, xs_daily)
|
||||
ct = _corr(daily, tp_daily)
|
||||
rows.append(dict(cfg=p, daily=daily, full=f["sharpe"], hold=h["sharpe"], dd=f["maxdd"],
|
||||
ret=f["ret"], pct=pct, corrXS=cx, corrTP=ct, turn=turn))
|
||||
return rows
|
||||
|
||||
|
||||
def _corr(a, b):
|
||||
J = pd.concat({"a": a, "b": b}, axis=1, join="inner").dropna()
|
||||
return float(J["a"].corr(J["b"])) if len(J) > 10 else float("nan")
|
||||
|
||||
|
||||
def tag(p):
|
||||
return " ".join(f"{k}{v}" for k, v in p.items())
|
||||
|
||||
|
||||
MOM_FAMILY = ("MOM", "IMOM") # momentum (anche residuo) -> NON e' "non-momentum"
|
||||
|
||||
|
||||
def causality_prefix_check(PX, VOL, builder, cfg, frac=0.85, tail=60, tol=1e-9):
|
||||
"""Guard look-ahead per l'engine cross-sectional: ricostruisce la serie su un PREFISSO della
|
||||
matrice (primi `frac`) e verifica che la coda combaci con la run completa sugli stessi indici.
|
||||
Un feature non-causale (finestra centrata, statistica full-sample, shift(-k)) divergerebbe."""
|
||||
score_full, warm = builder(PX, cfg)
|
||||
full, _ = xs_engine(PX, VOL, score_full, cfg["H"], cfg["k"], warmup=warm)
|
||||
cut = int(len(PX) * frac)
|
||||
PXc, VOLc = PX.iloc[:cut], VOL.iloc[:cut]
|
||||
score_pre, warm2 = builder(PXc, cfg)
|
||||
pre, _ = xs_engine(PXc, VOLc, score_pre, cfg["H"], cfg["k"], warmup=warm2)
|
||||
lo = max(0, cut - tail)
|
||||
a = full.values[lo:cut]
|
||||
b = 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))
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# PORTAFOGLIO — uplift 4 -> 5 sleeve (riusa active_sleeves SENZA modificarli)
|
||||
# ===========================================================================
|
||||
def portfolio_uplift(cand_fn, fractions=(0.10, 0.15)):
|
||||
base = active_sleeves() # 4 sleeve validati
|
||||
pf0 = StrategyPortfolio(base)
|
||||
bt0 = pf0.backtest() # popola le cache degli sleeve
|
||||
base_full = metrics(pf0.combined_daily())
|
||||
base_hold = metrics(pf0.combined_daily(lo=HOLDOUT))
|
||||
out = {"base": (base_full, base_hold), "variants": {}}
|
||||
for fr in fractions:
|
||||
wraw = fr / (1.0 - fr) # cand_frac = wraw/(sum_base + wraw), sum_base=1
|
||||
cand = Sleeve("XSV2_cand", wraw, cand_fn)
|
||||
pf1 = StrategyPortfolio(base + [cand]) # riusa le cache di base
|
||||
cf = metrics(pf1.combined_daily())
|
||||
ch = metrics(pf1.combined_daily(lo=HOLDOUT))
|
||||
out["variants"][fr] = (cf, ch, pf1.weights().get("XSV2_cand", 0.0))
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 100)
|
||||
print(" XSEC v2 — CROSS-SECTIONAL NON-MOMENTUM su Hyperliquid (STAT-MODE, storia ~2.5 anni)")
|
||||
print("=" * 100)
|
||||
|
||||
tp_daily = tp01_sleeve().daily()
|
||||
xs_daily = xsec_sleeve().daily()
|
||||
print(f" riferimenti: TP01 (corr target) e XS01 (momentum, sleeve attivo).")
|
||||
|
||||
universes = {
|
||||
"51-all": None,
|
||||
"19-major": XS_UNIVERSE,
|
||||
}
|
||||
mats = {}
|
||||
for uname, u in universes.items():
|
||||
PX, VOL = load_matrix(u)
|
||||
mats[uname] = (PX, VOL)
|
||||
print(f" universo {uname:<9}: {PX.shape[1]} asset, {PX.shape[0]} giorni "
|
||||
f"[{PX.index[0].date()} -> {PX.index[-1].date()}]")
|
||||
|
||||
mechs = mechanisms()
|
||||
all_sr = [] # per deflated-Sharpe (tutti i trial)
|
||||
best_per_mech = {} # (uname, mech) -> best row by hold
|
||||
for uname, (PX, VOL) in mats.items():
|
||||
print("\n" + "#" * 100)
|
||||
print(f"# UNIVERSO {uname}")
|
||||
print("#" * 100)
|
||||
for mech_name, (builder, cfgs) in mechs.items():
|
||||
rows = run_grid(PX, VOL, mech_name, builder, cfgs, xs_daily, tp_daily, uname)
|
||||
if not rows:
|
||||
continue
|
||||
all_sr.extend([r["full"] for r in rows])
|
||||
pos_full = sum(r["full"] > 0 for r in rows)
|
||||
# migliore per HOLD-OUT (diversificatore: vogliamo OOS robusto)
|
||||
best = max(rows, key=lambda r: r["hold"])
|
||||
best_per_mech[(uname, mech_name)] = best
|
||||
print(f"\n [{mech_name}] {len(rows)} config | plateau full>0: {pos_full}/{len(rows)}"
|
||||
f" | best-hold: {tag(best['cfg'])}")
|
||||
print(f" {'cfg':<22}{'FULL':>7}{'HOLD':>7}{'DD%':>6}{'ret%':>7}{'anni+':>7}"
|
||||
f"{'corrXS':>8}{'corrTP':>8}{'turn/y':>8}")
|
||||
# mostra le top-3 per HOLD per leggere il plateau
|
||||
for r in sorted(rows, key=lambda r: -r["hold"])[:3]:
|
||||
print(f" {tag(r['cfg']):<22}{r['full']:>7.2f}{r['hold']:>7.2f}{r['dd']*100:>6.0f}"
|
||||
f"{r['ret']*100:>+7.0f}{r['pct']*100:>6.0f}%{r['corrXS']:>+8.2f}{r['corrTP']:>+8.2f}"
|
||||
f"{r['turn']:>8.0f}")
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# SELEZIONE: miglior candidato NON-MOMENTUM (escluse le famiglie momentum MOM/IMOM).
|
||||
# gate standalone: FULL>0.5, HOLD>0, |corrXS|<0.6 -> ranking per (FULL+HOLD)/2.
|
||||
# IMOM/MOM restano in tabella come RIFERIMENTO (sono momentum, non il target del filone).
|
||||
# -------------------------------------------------------------------
|
||||
print("\n" + "=" * 100)
|
||||
print(" SELEZIONE CANDIDATO non-momentum — gate: FULL>0.5, HOLD>0, |corrXS|<0.6 (escluse MOM/IMOM)")
|
||||
print("=" * 100)
|
||||
nm = [s for s in all_sr if np.isfinite(s)]
|
||||
pool = [(u, mn, r) for (u, mn), r in best_per_mech.items()]
|
||||
nonmom = [(u, mn, r) for (u, mn, r) in pool if mn not in MOM_FAMILY]
|
||||
elig = [(u, mn, r) for (u, mn, r) in nonmom
|
||||
if r["full"] > 0.5 and r["hold"] > 0 and abs(r["corrXS"]) < 0.6]
|
||||
elig.sort(key=lambda x: -0.5 * (x[2]["full"] + x[2]["hold"]))
|
||||
for u, mn, r in sorted(pool, key=lambda x: -0.5 * (x[2]["full"] + x[2]["hold"])):
|
||||
fam = "(momentum-ref)" if mn in MOM_FAMILY else ""
|
||||
flag = "OK" if (u, mn, r) in elig else "--"
|
||||
print(f" [{flag}] {mn:<7} {u:<9} {tag(r['cfg']):<20} FULL {r['full']:+.2f} HOLD {r['hold']:+.2f}"
|
||||
f" DD {r['dd']*100:.0f}% corrXS {r['corrXS']:+.2f} corrTP {r['corrTP']:+.2f} {fam}")
|
||||
|
||||
if not elig:
|
||||
print("\n >>> NESSUN candidato NON-momentum supera il gate standalone. SCARTATO.")
|
||||
_final_note()
|
||||
return
|
||||
|
||||
print(f"\n candidati idonei (non-momentum): {len(elig)}")
|
||||
|
||||
# valuta UPLIFT PORTAFOGLIO per i top-3 idonei (LOWVOL/BAB/...): cache base riusata
|
||||
base = active_sleeves()
|
||||
pf0 = StrategyPortfolio(base); pf0.backtest()
|
||||
bf = metrics(pf0.combined_daily()); bh = metrics(pf0.combined_daily(lo=HOLDOUT))
|
||||
print("\n UPLIFT PORTAFOGLIO (active_sleeves 4 -> 5 sleeve; candidato come 5o sleeve):")
|
||||
print(f" BASE (4 sleeve) FULL Sh {bf['sharpe']:.2f} DD {bf['maxdd']*100:.0f}%"
|
||||
f" | HOLD Sh {bh['sharpe']:.2f} DD {bh['maxdd']*100:.0f}%")
|
||||
uplifts = {}
|
||||
for u, mn, r in elig[:3]:
|
||||
cand_fn = (lambda d: (lambda: d))(r["daily"])
|
||||
best_var = None
|
||||
for fr in (0.10, 0.15):
|
||||
wraw = fr / (1.0 - fr)
|
||||
cand = Sleeve("XSV2_cand", wraw, cand_fn)
|
||||
pf1 = StrategyPortfolio(base + [cand])
|
||||
cf = metrics(pf1.combined_daily()); ch = metrics(pf1.combined_daily(lo=HOLDOUT))
|
||||
wgt = pf1.weights().get("XSV2_cand", 0.0)
|
||||
print(f" +{mn:<6} [{u}] {tag(r['cfg']):<16} @{wgt*100:>4.1f}% "
|
||||
f"FULL {cf['sharpe']:.2f} ({cf['sharpe']-bf['sharpe']:+.2f}) DD {cf['maxdd']*100:.0f}%"
|
||||
f" | HOLD {ch['sharpe']:.2f} ({ch['sharpe']-bh['sharpe']:+.2f})")
|
||||
d_full, d_hold = cf['sharpe'] - bf['sharpe'], ch['sharpe'] - bh['sharpe']
|
||||
if best_var is None or (d_full + d_hold) > best_var:
|
||||
best_var = d_full + d_hold
|
||||
uplifts[(u, mn)] = best_var
|
||||
|
||||
# TOP candidato = miglior non-momentum idoneo
|
||||
u, mn, best = elig[0]
|
||||
daily = best["daily"]
|
||||
f, h, pct = evalcfg(daily)
|
||||
dsr, sr0 = deflated_sharpe(f["sharpe"], all_sr, daily)
|
||||
caus = causality_prefix_check(*mats[u], mechs[mn][0], best["cfg"])
|
||||
print("\n" + "=" * 100)
|
||||
print(f" TOP CANDIDATO non-momentum: {mn} [{u}] {tag(best['cfg'])}")
|
||||
print("=" * 100)
|
||||
print(f" FULL Sharpe {f['sharpe']:.2f} | HOLD {h['sharpe']:.2f} | DD {f['maxdd']*100:.0f}%"
|
||||
f" | ret {f['ret']*100:+.0f}% | anni+ {pct*100:.0f}% | turnover/y {best['turn']:.0f}")
|
||||
print(f" corr vs XS01 {best['corrXS']:+.2f} | corr vs TP01 {best['corrTP']:+.2f}")
|
||||
print(f" CAUSALITA' (prefix-check): ok={caus['ok']} max_tail_diff={caus['max_tail_diff']:.2e}")
|
||||
print(f" DEFLATED Sharpe (N={len(nm)} trial GLOBALI): {dsr:.3f}"
|
||||
f" | soglia Sharpe-max-null annualizz. {sr0:.2f} (serve DSR>0.95)")
|
||||
_, yrs = yr_breadth(daily)
|
||||
per = [(int(y), round(v, 3)) for y, v in zip([yy for yy, _ in daily.groupby(daily.index.year)], yrs)]
|
||||
print(f" per-anno: {per}")
|
||||
|
||||
helps = (uplifts.get((u, mn), -9) or -9) > 0.10 # uplift combinato full+hold meaningful
|
||||
robust = dsr > 0.95 and best["hold"] > 0.3 and best["full"] > 0.7 and caus["ok"]
|
||||
print("\n VERDETTO INDICATIVO:",
|
||||
"PASS-LEAD (forward-monitor)" if (helps and robust) else
|
||||
("DEBOLE/forward-monitor" if (helps or (best['full'] > 0.7 and best['hold'] > 0.3)) else "SCARTATO"))
|
||||
_final_note()
|
||||
|
||||
|
||||
def _final_note():
|
||||
print("\n CAVEAT (immutabili): storia ~2.5 anni (deflated-Sharpe + multiple-testing), book a molte")
|
||||
print(" gambe NON eseguibile a $600 -> STAT-MODE / forward-monitor, MAI deploy. Nessuno sleeve")
|
||||
print(" registrato: questo e' solo lavoro statistico (vincoli del filone C).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user