research(sweep): 5 thread paralleli — 0 nuovi sleeve, STATARB-RESID LEAD ortogonale+eseguibile

Ricerca onesta su aree inesplorate (harness altlib+xsec_v2_nonmom, tutti i gate incl.
study_family_honest anti-selection-on-holdout). Branch main, nessun impatto live, test 143/143.

1 XSEC low-risk cousins (MAX/idio-vol/Amihud) -> 1 LEAD (IVOL), STAT-MODE, DSR 0.37<0.95
2 XSEC momentum-structure vs XS01            -> tutto REDUNDANT (sostituire XS01 distrugge hold)
3 Meta-allocazione dinamica (4 sleeve)       -> pesi fissi vincono (gia quasi risk-parity)
4 Segnali ortogonali ETH/BTC (2 gambe)       -> STATARB-RESID + DVOLSPREAD LEAD
5 1-gamba a segnale (MACD/RSI/Supertrend/...) -> 0/12 earns_slot (trend=TP01, MR morta, hedge)

LEAD principale STATARB-RESID (mean-rev residuo ETH-b*BTC, OLS rolling, 2 gambe): primo stream
INSIEME ortogonale (corr->book 0.027, beta-mkt 0.013) ED eseguibile a $600 (haircut ~0, NON
STAT-MODE) -> cadono i 2 muri di XS01/opzioni. Resta solo il muro dell'edge (Sharpe 0.84,
DSR 0.929 same-sign <0.95). Causalita+fee verificate dal coordinatore. Forward-monitor, non sleeve.

Soffitto direzionale ~1.3 riconfermato. Diario 2026-06-29-strategy-search-5threads.md, CLAUDE.md agg.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-29 20:50:33 +00:00
parent 02d875b07b
commit cff5fa2bf5
12 changed files with 2692 additions and 0 deletions
+410
View File
@@ -0,0 +1,410 @@
"""META-ALLOCATION — allocazione DINAMICA CAUSALE tra i 4 sleeve esistenti vs PESI FISSI.
TESI (angolo nuovo, NON un 5o sleeve): il portafoglio attivo combina TP01/XS01/VRP01/SKH01 a
PESO FISSO (41.25/18.75/15/25, rinormalizzati per-riga sugli sleeve attivi — vedi
src/portfolio/portfolio.combined_daily). Domanda: una regola di allocazione DINAMICA e CAUSALE
fra gli stessi 4 sleeve batte i pesi fissi OUT-OF-SAMPLE? Cioe' c'e' meta-alpha di timing di
portafoglio, oltre ai pesi fissi?
MECCANISMI testati (tutti CAUSALI: decisione con dati <= t-1, peso applicato in t; ribilancio
SETTIMANALE con costo sul turnover dei pesi |Δw|*cost_rate, cosi' una regola che ribilancia di
continuo PAGA il suo attrito — non si bara):
1. VOL-PARITY — peso inverso alla vol realizzata rolling (risk-parity causale). Pure + tilt.
2. MOMENTUM-OF-SLEEVES — sovrappesa gli sleeve con Sharpe rolling recente migliore (tilt capato).
3. DISPERSION-REGIME — tilt verso XS01 quando la dispersione cross-section degli alt e' alta
(percentile ESPANDENTE causale), verso il resto altrimenti.
4. DRAWDOWN-CONTROL — riduce l'esposizione aggregata (-> cash) o ribilancia verso VRP/SKH
quando il portafoglio e' in drawdown rolling (causale sull'equity propria).
GATE / ONESTA':
- FULL e HOLD-OUT (2025-01-01+) Sharpe + maxDD, per-anno, turnover dei pesi/anno.
- Confronto vs BASE pesi-fissi sulla STESSA finestra e con lo STESSO motore (entrambi pagano il
costo di ribilancio): il miglioramento deve esserci su HOLD-OUT, non solo FULL.
- MULTI-CUT: uplift dello Sharpe a piu' date di taglio (2022/23/24/25). Robusto solo se positivo
su piu' finestre, non su una sola fortunata.
- DE-LEVERING: lo Sharpe e' scale-invariant. Se uno schema ABBASSA DD/vol ma NON alza lo Sharpe,
il taglio di DD e' solo de-levering (replicabile abbassando la leva di BASE) -> NON e' alpha di
timing. Lo riportiamo esplicitamente confrontando BASE de-levered a pari vol.
VERDETTO per schema: BATTE-FISSO / solo-de-levering / RIDONDANTE / SCARTATO.
uv run python scripts/research/meta_allocation.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
import pandas as pd
from src.portfolio.sleeves import active_sleeves, XS_UNIVERSE, _HL_DIR
from src.portfolio.portfolio import metrics, yearly, HOLDOUT, DAYS_PER_YEAR
REBAL_DAYS = 7 # ribilancio settimanale
COST_RATE = 0.0005 # 5 bps per-lato sul turnover dei pesi (Deribit taker ~ questo ordine)
VOL_WIN = 60 # finestra vol realizzata (risk-parity)
MOM_WIN = 63 # finestra Sharpe rolling (momentum-of-sleeves, ~1 trimestre)
WARMUP = 90 # giorni di warm-up: prima -> fallback ai pesi fissi
# ----------------------------------------------------------------------------- data
def sleeve_matrix() -> tuple[pd.DatetimeIndex, np.ndarray, np.ndarray, list[str], np.ndarray]:
"""Matrice daily allineata dei 4 sleeve (outer-join). Ritorna (index, R, active, names, fixed_w).
R = rendimenti (0 dove inattivo), active = maschera bool di disponibilita'."""
base = active_sleeves()
names = [s.name for s in base]
fixed_w = np.array([s.weight for s in base], float)
cols = {s.name: s.daily() for s in base}
J = pd.concat(cols, axis=1, join="outer").sort_index()
J = J[J.notna().any(axis=1)]
active = J.notna().values
R = np.nan_to_num(J.values, nan=0.0)
return J.index, R, active, names, fixed_w
def dispersion_series(index: pd.DatetimeIndex) -> np.ndarray:
"""Dispersione cross-section dei rendimenti degli alt Hyperliquid (std cross-section dei ritorni
daily sull'universo XS01), allineata all'index del portafoglio. NaN dove non c'e' dato HL."""
cols = {}
for sym in XS_UNIVERSE:
p = _HL_DIR / f"hl_{sym.lower()}_1d.parquet"
if not p.exists():
continue
d = pd.read_parquet(p)
cols[sym] = pd.Series(d["close"].values.astype(float),
index=pd.to_datetime(d["timestamp"], unit="ms", utc=True))
C = pd.concat(cols, axis=1, join="inner").sort_index().dropna()
ret = C.pct_change()
disp = ret.std(axis=1) # dispersione cross-section per giorno
disp.index = disp.index.normalize()
return disp.reindex(index.normalize()).values
# ----------------------------------------------------------------------------- weight helpers
def _renorm_rows(W: np.ndarray, active: np.ndarray, expo: np.ndarray | None = None) -> np.ndarray:
"""Maschera inattivi -> 0, rinormalizza ogni riga alla esposizione `expo` (default 1)."""
Wm = W * active
rs = Wm.sum(axis=1, keepdims=True)
out = np.divide(Wm, rs, out=np.zeros_like(Wm), where=rs > 0)
if expo is not None:
out = out * expo[:, None]
return out
def base_weights(R, active, fixed_w) -> np.ndarray:
"""Pesi FISSI rinormalizzati per-riga sugli sleeve attivi (replica combined_daily)."""
n, A = R.shape
return _renorm_rows(np.tile(fixed_w, (n, 1)), active)
def add_cash(W: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""Appende una colonna CASH (rendimento 0) che assorbe 1 - somma-pesi (per schemi che de-levano).
Ritorna (W_aug, is_cash_active=True)."""
cash = np.clip(1.0 - W.sum(axis=1, keepdims=True), 0.0, 1.0)
return np.hstack([W, cash])
# ----------------------------------------------------------------------------- engine
def simulate(R: np.ndarray, active: np.ndarray, Wtgt: np.ndarray,
period_days: int = REBAL_DAYS, cost_rate: float = COST_RATE) -> dict:
"""Motore di ribilancio PERIODICO realistico, CAUSALE.
Wtgt[t] = pesi-bersaglio decisi con dati <= t-1 (vedi costruttori schemi), una colonna CASH in
coda (rend. 0). Fra un ribilancio e l'altro i pesi DERIVANO col rendimento; ogni `period_days`
si torna al target pagando cost_rate*|v-target|. Il costo grava sul rendimento del giorno.
period_days=1, cost=0 -> rebalance-continuo (= combined_daily)."""
n = R.shape[0]
Raug = np.hstack([R, np.zeros((n, 1))]) # colonna cash
v = Wtgt[0].copy() # equity iniziale = 1.0, allocata al target
out = np.zeros(n)
turn_tot = 0.0
n_rebal = 0
for t in range(n):
E_start = float(v.sum())
if t > 0 and (t % period_days == 0) and E_start > 0:
target = Wtgt[t] * E_start
turn = float(np.abs(v - target).sum())
v = Wtgt[t] * (E_start - cost_rate * turn)
turn_tot += turn / E_start
n_rebal += 1
v = v * (1.0 + Raug[t])
E_end = float(v.sum())
out[t] = E_end / E_start - 1.0 if E_start > 0 else 0.0
years = n / DAYS_PER_YEAR
return dict(daily=pd.Series(out),
turnover_per_year=turn_tot / years if years > 0 else 0.0,
n_rebalances=n_rebal)
# ----------------------------------------------------------------------------- schemes (causal Wtgt builders, with cash col)
def scheme_base(index, R, active, fixed_w, **_):
return add_cash(base_weights(R, active, fixed_w))
def _rolling_vol(R, active, win):
"""Vol realizzata rolling per-sleeve, SHIFTATA di 1 (causale: usa <= t-1)."""
df = pd.DataFrame(np.where(active, R, np.nan))
vol = df.rolling(win, min_periods=max(10, win // 2)).std().shift(1).values
return vol
def scheme_volpar_pure(index, R, active, fixed_w, win=VOL_WIN, **_):
"""Risk-parity puro: w_i ∝ 1/vol_i sugli sleeve attivi (causale). Warm-up -> BASE."""
vol = _rolling_vol(R, active, win)
inv = np.divide(1.0, vol, out=np.zeros_like(vol), where=(vol > 0) & np.isfinite(vol))
W = _renorm_rows(inv, active & np.isfinite(vol) & (vol > 0))
bw = base_weights(R, active, fixed_w)
bad = W.sum(axis=1) <= 0
W[bad] = bw[bad]
W[:WARMUP] = bw[:WARMUP]
return add_cash(W)
def scheme_volpar_tilt(index, R, active, fixed_w, win=VOL_WIN, **_):
"""Tilt dei pesi FISSI per inverso-vol: w_i ∝ fixed_i / vol_i (ancorato ai pesi fissi)."""
vol = _rolling_vol(R, active, win)
inv = np.divide(1.0, vol, out=np.zeros_like(vol), where=(vol > 0) & np.isfinite(vol))
W = _renorm_rows(fixed_w[None, :] * inv, active & np.isfinite(vol) & (vol > 0))
bw = base_weights(R, active, fixed_w)
bad = W.sum(axis=1) <= 0
W[bad] = bw[bad]
W[:WARMUP] = bw[:WARMUP]
return add_cash(W)
def scheme_momentum(index, R, active, fixed_w, win=MOM_WIN, tilt=0.5, cap=0.55, **_):
"""Momentum-of-sleeves: tilt dei pesi fissi per lo Sharpe rolling z-scored (causale), capato.
w_i ∝ fixed_i * (1 + tilt*z_i)+, z = standardizzazione cross-sleeve dello Sharpe rolling.
Cap per non concentrare. Warm-up / regime piatto -> BASE."""
df = pd.DataFrame(np.where(active, R, np.nan))
mu = df.rolling(win, min_periods=win // 2).mean().shift(1).values
sd = df.rolling(win, min_periods=win // 2).std().shift(1).values
sh = np.divide(mu, sd, out=np.full_like(mu, np.nan), where=(sd > 0)) * np.sqrt(DAYS_PER_YEAR)
n, A = R.shape
W = np.zeros((n, A))
bw = base_weights(R, active, fixed_w)
for t in range(n):
m = active[t] & np.isfinite(sh[t])
if m.sum() < 2 or t < WARMUP:
W[t] = bw[t]; continue
z = np.zeros(A); s = sh[t][m]
zsd = s.std()
if zsd > 0:
z[m] = (sh[t][m] - s.mean()) / zsd
raw = fixed_w * np.clip(1.0 + tilt * z, 0.0, None) * m
if raw.sum() <= 0:
W[t] = bw[t]; continue
w = raw / raw.sum()
for _ in range(3): # impone il cap iterando
over = w > cap
if not over.any():
break
excess = (w[over] - cap).sum()
w[over] = cap
room = m & ~over
if room.sum() == 0 or w[room].sum() == 0:
break
w[room] += excess * w[room] / w[room].sum()
W[t] = w / w.sum()
return add_cash(W)
def scheme_dispersion(index, R, active, fixed_w, pct=60, minhist=120, boost=2.0, **_):
"""Dispersion-regime: quando la dispersione cross-section degli alt supera il percentile
ESPANDENTE causale (pct), boost del peso XS01; sotto, XS01 -> 0 e redistribuito. Pesi fissi
altrove. XS01 attivo solo dal 2024 (prima: BASE)."""
disp = dispersion_series(index)
n, A = R.shape
names_idx = 1 # XS01 e' la colonna 1 (vedi active_sleeves)
bw = base_weights(R, active, fixed_w)
W = bw.copy()
hist = []
high = np.zeros(n, bool)
for t in range(n):
d = disp[t - 1] if t > 0 else np.nan # causale: dispersione <= t-1
if np.isfinite(d):
thr = np.percentile(hist, pct) if len(hist) >= minhist else np.inf
high[t] = d >= thr
hist.append(d)
for t in range(n):
if t < WARMUP or not active[t, names_idx]:
continue
raw = fixed_w.copy()
raw[names_idx] *= boost if high[t] else 0.05 # boost XS in regime disperso, quasi-spento altrove
W[t] = _renorm_rows(raw[None, :], active[t][None, :])[0]
return add_cash(W)
def scheme_dd_cash(index, R, active, fixed_w, dd_thr=0.05, floor=0.5, win=0, **_):
"""Drawdown-control (DE-LEVERING esplicito): traccia l'equity di BASE (causale, shiftata),
se il drawdown corrente > dd_thr riduce l'esposizione aggregata a `floor` (resto in CASH).
E' il caso-test del de-levering: ci aspettiamo DD piu' basso ma Sharpe NON piu' alto."""
bw = base_weights(R, active, fixed_w)
base_daily = simulate(R, active, add_cash(bw))["daily"].values
eq = np.cumprod(1.0 + base_daily)
pk = np.maximum.accumulate(eq)
dd = (pk - eq) / pk # drawdown realizzato
expo = np.ones(R.shape[0])
for t in range(R.shape[0]):
d = dd[t - 1] if t > 0 else 0.0 # causale
expo[t] = floor if d > dd_thr else 1.0
expo[:WARMUP] = 1.0
W = bw * expo[:, None]
return add_cash(W)
def scheme_dd_defensive(index, R, active, fixed_w, dd_thr=0.05, **_):
"""Drawdown-control DIFENSIVO: in drawdown ribilancia verso VRP01(2)/SKH01(3) (scorrelati),
via TP01(0)/XS01(1). Pienamente investito (no cash) -> isola il timing dal de-levering."""
bw = base_weights(R, active, fixed_w)
base_daily = simulate(R, active, add_cash(bw))["daily"].values
eq = np.cumprod(1.0 + base_daily)
pk = np.maximum.accumulate(eq)
dd = (pk - eq) / pk
n, A = R.shape
defensive = np.array([0.10, 0.10, 0.35, 0.45]) # VRP/SKH pesati in DD
W = bw.copy()
for t in range(n):
d = dd[t - 1] if t > 0 else 0.0
if t >= WARMUP and d > dd_thr:
W[t] = _renorm_rows(defensive[None, :], active[t][None, :])[0]
return add_cash(W)
SCHEMES = [
("BASE (pesi fissi)", scheme_base),
("VOLPAR pure (1/vol)", scheme_volpar_pure),
("VOLPAR tilt (fix/vol)", scheme_volpar_tilt),
("MOMENTUM-of-sleeves", scheme_momentum),
("DISPERSION-regime->XS", scheme_dispersion),
("DRAWDOWN-ctrl (cash)", scheme_dd_cash),
("DRAWDOWN-ctrl (defens.)", scheme_dd_defensive),
]
CUTS = ["2022-01-01", "2023-01-01", "2024-01-01", "2025-01-01"]
# ----------------------------------------------------------------------------- run
def run():
index, R, active, names, fixed_w = sleeve_matrix()
print("=" * 100)
print(" META-ALLOCATION — allocazione dinamica causale tra i 4 sleeve vs PESI FISSI")
print(f" sleeve: {names}")
print(f" pesi fissi: {dict(zip(names, np.round(fixed_w, 4)))}")
print(f" finestra {index.min().date()} -> {index.max().date()} | n={len(index)} giorni | "
f"hold-out {HOLDOUT.date()}+ | ribilancio {REBAL_DAYS}g | costo {COST_RATE*1e4:.0f}bps/lato")
print("=" * 100)
results = {}
for label, fn in SCHEMES:
Wtgt = fn(index, R, active, fixed_w)
sim = simulate(R, active, Wtgt)
d = pd.Series(sim["daily"].values, index=index)
results[label] = dict(daily=d, turnover=sim["turnover_per_year"], W=Wtgt)
base_d = results["BASE (pesi fissi)"]["daily"]
mb_full = metrics(base_d)
mb_hold = metrics(base_d[base_d.index >= HOLDOUT])
print(f"\n {'SCHEMA':<26s} | {'FULL Sh':>7s} {'CAGR':>7s} {'DD':>6s} | {'HOLD Sh':>7s} {'HOLD ret':>8s} {'DD':>6s} | turn/y")
print(" " + "-" * 96)
for label, _ in SCHEMES:
d = results[label]["daily"]
mf = metrics(d); mh = metrics(d[d.index >= HOLDOUT])
print(f" {label:<26s} | {mf['sharpe']:>7.2f} {mf['cagr']*100:>6.1f}% {mf['maxdd']*100:>5.1f}% | "
f"{mh['sharpe']:>7.2f} {mh['ret']*100:>+7.1f}% {mh['maxdd']*100:>5.1f}% | {results[label]['turnover']:>5.2f}")
print(f"\n delta vs BASE (FULL Sh {mb_full['sharpe']:.2f} / HOLD Sh {mb_hold['sharpe']:.2f}):")
print(f" {'SCHEMA':<26s} | {'ΔFULL Sh':>9s} {'ΔHOLD Sh':>9s} {'ΔFULL DD':>9s} {'ΔHOLD DD':>9s} | corr(BASE)")
print(" " + "-" * 96)
for label, _ in SCHEMES:
if label.startswith("BASE"):
continue
d = results[label]["daily"]
mf = metrics(d); mh = metrics(d[d.index >= HOLDOUT])
corr = float(np.corrcoef(d.values, base_d.values)[0, 1])
print(f" {label:<26s} | {mf['sharpe']-mb_full['sharpe']:>+9.2f} {mh['sharpe']-mb_hold['sharpe']:>+9.2f} "
f"{(mf['maxdd']-mb_full['maxdd'])*100:>+8.1f}% {(mh['maxdd']-mb_hold['maxdd'])*100:>+8.1f}% | {corr:>6.3f}")
# ---- MULTI-CUT: uplift Sharpe a piu' date di taglio (anti-overfit hold-out singolo) ----
print("\n MULTI-CUT — ΔSharpe (schema BASE) su finestre [cut, fine]:")
header = " " + f"{'SCHEMA':<26s} | " + " ".join(f"{c[:4]:>7s}" for c in CUTS)
print(header); print(" " + "-" * (len(header) - 2))
for label, _ in SCHEMES:
if label.startswith("BASE"):
continue
d = results[label]["daily"]
row = []
for c in CUTS:
lo = pd.Timestamp(c, tz="UTC")
sd = metrics(d[d.index >= lo])["sharpe"]
sb = metrics(base_d[base_d.index >= lo])["sharpe"]
row.append(f"{sd-sb:>+7.2f}")
print(f" {label:<26s} | " + " ".join(row))
# ---- DE-LEVERING check: BASE de-levered alla vol dello schema -> stesso DD? ----
print("\n DE-LEVERING check (Sharpe e' scale-invariant: DD-piu'-basso a pari-Sharpe = solo de-lever):")
print(f" {'SCHEMA':<26s} | {'vol/volBASE':>11s} | {'DD schema':>9s} {'DD BASE@volSchema':>18s}")
print(" " + "-" * 70)
vol_base = base_d.std()
dd_base = mb_full["maxdd"]
for label, _ in SCHEMES:
if label.startswith("BASE"):
continue
d = results[label]["daily"]
ratio = d.std() / vol_base if vol_base > 0 else 1.0
# BASE riscalato alla stessa vol dello schema -> il suo DD a quella leva
dd_base_scaled = metrics(base_d * ratio)["maxdd"]
print(f" {label:<26s} | {ratio:>11.3f} | {metrics(d)['maxdd']*100:>8.1f}% {dd_base_scaled*100:>17.1f}%")
# ---- PER-ANNO dei due piu' interessanti vs BASE ----
print("\n PER-ANNO ret% (BASE vs schemi):")
yb = yearly(base_d)
yrs = sorted(yb.keys())
print(" " + f"{'SCHEMA':<26s} | " + " ".join(f"{y:>7d}" for y in yrs))
print(" " + "-" * (28 + 8 * len(yrs)))
for label, _ in SCHEMES:
d = results[label]["daily"]; yd = yearly(d)
print(f" {label:<26s} | " + " ".join(f"{yd.get(y,{'ret':0})['ret']*100:>+6.1f}%" for y in yrs))
# ---- VERDETTI ----
print("\n VERDETTI (BATTE-FISSO richiede ΔHOLD Sh > +0.10 E multi-cut maggioritario positivo E"
" non solo de-levering):")
vol_base = base_d.std()
for label, _ in SCHEMES:
if label.startswith("BASE"):
continue
d = results[label]["daily"]
mf = metrics(d); mh = metrics(d[d.index >= HOLDOUT])
dfull = mf["sharpe"] - mb_full["sharpe"]
dhold = mh["sharpe"] - mb_hold["sharpe"]
cut_ups = []
for c in CUTS:
lo = pd.Timestamp(c, tz="UTC")
cut_ups.append(metrics(d[d.index >= lo])["sharpe"] - metrics(base_d[base_d.index >= lo])["sharpe"])
n_pos = sum(1 for x in cut_ups if x > 0.02)
vr = d.std() / vol_base if vol_base > 0 else 1.0
dd_lower = mf["maxdd"] < mb_full["maxdd"] - 0.005
is_delever = (vr < 0.97) and dd_lower and (dfull <= 0.03) # vol giu', DD giu', Sharpe non meglio
if dhold > 0.10 and dfull > -0.05 and n_pos >= 3:
verdict, why = "BATTE-FISSO", f"ΔHOLD {dhold:+.2f}, multi-cut {n_pos}/4 positivi, FULL non peggiore"
elif (dhold <= -0.10) or (n_pos == 0 and dfull < -0.07):
verdict, why = "SCARTATO", f"peggio OOS (ΔFULL {dfull:+.2f}, ΔHOLD {dhold:+.2f}, multi-cut {n_pos}/4 con turn/y {results[label]['turnover']:.1f})"
elif is_delever:
verdict, why = "solo-de-levering", f"vol {vr:.2f}×BASE, DD {mf['maxdd']*100:.1f}%<{mb_full['maxdd']*100:.1f}% ma Sharpe non meglio (ΔFULL {dfull:+.2f}) -> replicabile abbassando la leva"
else:
why = (f"≈BASE OOS (ΔHOLD {dhold:+.2f}); FULL ΔSh {dfull:+.2f}, ΔDD {(mf['maxdd']-mb_full['maxdd'])*100:+.1f}%"
+ (" [marginale in-sample, nullo su hold-out]" if abs(dfull) >= 0.03 else ""))
verdict = "RIDONDANTE"
print(f" {label:<26s} -> {verdict:<16s} {why}")
print("\n" + "=" * 100)
print(" CONCLUSIONE: vedi i verdetti sopra. Soglia BATTE-FISSO deliberatamente alta (anti-overfit):")
print(" l'allocazione dinamica deve battere i pesi fissi su HOLD-OUT *e* multi-cut, non su una")
print(" finestra fortunata, e non per solo de-levering (replicabile abbassando target_vol/leva).")
print("=" * 100)
if __name__ == "__main__":
run()
+604
View File
@@ -0,0 +1,604 @@
"""orthogonal_signals.py — SIGNAL-BASED, BY-CONSTRUCTION-ORTHOGONAL streams (2026-06-29).
TESI (richiesta utente). Il book attivo è TP01 (trend) + XS01 (cross-sectional) + VRP01
(short-vol) + SKH01 (regime L/S). Tutti hanno beta direzionale o vol crypto. Cerchiamo uno
stream a **beta di mercato ~0 e bassa corr al book** ma con un **EDGE DI SEGNALE reale** —
NON la "diversification math" di uno stream a Sharpe~0 (il marginal scorer indurito la boccia:
serve has_insample_edge, Sharpe standalone PRE-2025 >= 0.5, deflated-Sharpe sull'intera griglia,
e selezione della cella IN-SAMPLE, mai sul max hold-out).
FOCUS PRIMARIO — RELATIVE-VALUE ETH/BTC (dollar-neutral, 2 gambe). La posizione è sul SPREAD
long-ETH/short-BTC (o viceversa): r_spread[i] = pos[i-1]*(r_eth[i]-r_btc[i]) - fee*2*|Δpos|.
Per costruzione beta_mercato ~0 -> scorrelato a TP01/SKH. VANTAGGIO: un book a 2 gambe su
Deribit perp (BTC+ETH entrambi live) è MOLTO più vicino all'eseguibile a $600 di uno a 19 gambe
(XS01 è STAT-MODE). Segnali sul ratio log(ETH/BTC), tutti CAUSALI (decisione <= close[i]):
1. RATIO-MOM momentum del ratio (trend dello spread).
2. RATIO-REV reversal di breve del ratio.
3. RATIO-ACCEL accelerazione (2a differenza / curvatura) del ratio.
4. VOLSPREAD vol realizzata relativa BTC vs ETH -> verso l'asset col profilo giusto.
5. DVOLSPREAD vol IMPLICITA relativa (al.dvol BTC/ETH) -> re-valida l'ex-lead 'dvol_spread'.
6. STATARB-RESID residuo di ETH dopo beta*BTC (rolling OLS causale) -> mean-revert il residuo.
SECONDARIO — CRYPTO vs MACRO (GLD/QQQ/TLT): long/short crypto vs hedge su momentum relativo,
merge_asof backward (equity 5gg/sett vs crypto 24/7), niente look-ahead. Probabile debole.
GATE (tutti obbligatori, replicano altlib indurito):
* CAUSALITÀ: prefix-check sul SPREAD (ricostruisci su prefisso, la coda deve combaciare).
* NETTO fee 0.10% RT su 2 gambe + SWEEP (0.00-0.30% RT).
* SELEZIONE CELLA IN-SAMPLE-ONLY (pre-2025), MAI sul max hold-out (punto cieco filone B).
* DEFLATED-SHARPE su TUTTE le celle cercate (multiple-testing).
* has_insample_edge: Sharpe standalone PRE-2025 >= 0.5 (no diversification-math).
* OOS hold-out 2025+, plateau su griglia, per-anno.
* corr vs BOOK 4-sleeve (|corr|<0.2 ideale) + beta vs mercato (50/50 BTC+ETH) ~0.
* marginal_vs_tp01 (ADDS / HEDGE / NOISE / NEUTRAL) -> earns_slot_honest.
* EXEC $600: haircut a 2 gambe (min $5/gamba, fee 0.10% RT) — il punto FORTE del filone.
ONESTÀ BRUTALE: bassa-corr da sola NON basta. dvol_spread era forward-monitor (storia DVOL
corta + multiple-testing); ratio_accel era lead debole. Se è diversification-math o
hold-out-fitting -> NOISE/SCARTATO con numeri.
Esecuzione: cd /opt/docker/PythagorasGoal && uv run python scripts/research/orthogonal_signals.py
Idempotente, solo stdout.
"""
from __future__ import annotations
import sys
import math
import warnings
from pathlib import Path
import numpy as np
import pandas as pd
warnings.filterwarnings("ignore")
_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(_ROOT / "scripts" / "research" / "alt"))
sys.path.insert(0, str(_ROOT))
import altlib as al # noqa: E402
FEE_SIDE = al.FEE_SIDE # 0.0005 = 0.05%/side
FEE_SWEEP = (0.0, 0.00025, 0.0005, 0.001, 0.0015) # per-side; ×2 legs ×2 (RT) for RT%
HOLDOUT = al.HOLDOUT
ANN = 365.25
# ===========================================================================
# JOINT FRAME + DOLLAR-NEUTRAL EVALUATOR (custom — eval_weights is single-asset)
# ===========================================================================
def build_joint(tf: str = "1d") -> pd.DataFrame:
"""BTC/ETH allineati sull'indice comune (inner join su timestamp). Ritorna un frame con
r_btc/r_eth (simple), close_*, log_ratio = log(ETH/BTC), datetime, timestamp."""
b = al.get("BTC", tf)[["timestamp", "datetime", "close"]].rename(columns={"close": "cb"})
e = al.get("ETH", tf)[["timestamp", "close"]].rename(columns={"close": "ce"})
j = b.merge(e, on="timestamp", how="inner").sort_values("timestamp").reset_index(drop=True)
j["r_btc"] = al.simple_returns(j["cb"].values)
j["r_eth"] = al.simple_returns(j["ce"].values)
j["log_ratio"] = np.log(j["ce"].values / j["cb"].values)
return j
def spread_ret(j: pd.DataFrame) -> np.ndarray:
"""Ritorno dollar-neutral per unità di gross-per-gamba: long $1 ETH, short $1 BTC."""
return j["r_eth"].values - j["r_btc"].values
def vol_target_spread(direction: np.ndarray, j: pd.DataFrame, target_vol: float = 0.20,
win_days: int = 30, cap: float = 2.0) -> np.ndarray:
"""Scala una direzione in [-1,1] a vol-target sullo SPREAD (causale: vol realizzata <= i)."""
s = spread_ret(j)
bpd = al.bars_per_day(j)
bpy = bpd * ANN
vol = al.realized_vol(s, max(2, win_days * bpd), bpy)
scal = np.where((vol > 0) & np.isfinite(vol), target_vol / vol, 0.0)
pos = np.clip(np.nan_to_num(direction) * scal, -cap, cap)
pos[~np.isfinite(pos)] = 0.0
return pos
def eval_spread(j: pd.DataFrame, pos: np.ndarray, fee_side: float = FEE_SIDE) -> dict:
"""Backtest ONESTO del SPREAD. pos[i] decisa <= close[i], TENUTA durante la barra i+1
(lo shift è qui -> niente leak). Fee su 2 GAMBE: ogni Δpos muove ETH e BTC -> 2×|Δpos|."""
pos = np.nan_to_num(np.asarray(pos, float))
s = spread_ret(j)
held = np.zeros(len(pos)); held[1:] = pos[:-1]
gross = held * s
turn = np.abs(np.diff(held, prepend=0.0)) # turnover per-gamba
net = gross - fee_side * 2.0 * turn # 2 gambe
net[0] = 0.0
idx = pd.DatetimeIndex(pd.to_datetime(j["datetime"], utc=True))
full = al._metrics_from_net(net, idx)
hmask = idx >= HOLDOUT
hold = al._metrics_from_net(net[hmask], idx[hmask]) if hmask.sum() > 3 else dict(sharpe=0.0, n=0)
return dict(full=full, holdout=hold, yearly=al._yearly(net, idx),
tim=round(float(np.mean(held != 0)), 3),
turnover=round(float(turn.sum() / (len(turn) / (al.bars_per_day(j) * ANN))), 1),
net=net, idx=idx)
def spread_daily(j: pd.DataFrame, pos: np.ndarray, fee_side: float = FEE_SIDE) -> pd.Series:
"""Serie NET giornaliera del candidato spread (compounded a 1d se TF sub-daily)."""
ev = eval_spread(j, pos, fee_side=fee_side)
s = pd.Series(ev["net"], index=ev["idx"])
return al._to_daily(s)
def eval_spread_smallcap(j: pd.DataFrame, pos: np.ndarray, capital: float = 600.0,
min_order: float = 5.0, fee_side: float = FEE_SIDE) -> dict:
"""Net REALISTICO a $600 su 2 GAMBE. Un Δpos il cui nozionale PER-GAMBA |Δpos|*capital < $5
NON si esegue (held). Le due gambe cambiano dello stesso |Δ| -> il vincolo binding è
|Δpos|*capital >= min_order. Riporta Sharpe modellato vs realistico + haircut + n trade."""
pos = np.clip(np.nan_to_num(np.asarray(pos, float)), -10, 10)
held = np.empty(len(pos)); cur = 0.0; n_tr = 0
for i in range(len(pos)):
if abs(pos[i] - cur) * capital >= min_order:
cur = pos[i]; n_tr += 1
held[i] = cur
s = spread_ret(j)
p = np.zeros(len(held)); p[1:] = held[:-1]
turn = np.abs(np.diff(p, prepend=0.0))
net = p * s - fee_side * 2.0 * turn; net[0] = 0.0
idx = pd.DatetimeIndex(pd.to_datetime(j["datetime"], utc=True))
real = al._metrics_from_net(net, idx)
modeled = eval_spread(j, pos, fee_side=fee_side)["full"]
return dict(realistic=real, modeled=modeled,
sharpe_haircut=round(modeled["sharpe"] - real["sharpe"], 3),
n_executed_trades=int(n_tr))
# ===========================================================================
# CAUSALITY — prefix-check sul SPREAD (la coda del prefisso deve combaciare col full).
# ===========================================================================
def causality_spread(make_pos, tf: str = "1d", tail: int = 80, tol: float = 1e-6) -> dict:
"""make_pos(j) -> pos sull'intero frame. Ricostruisce su prefissi; la coda deve combaciare."""
j = build_joint(tf)
full = np.nan_to_num(make_pos(j))
n = len(j)
worst = 0.0; bad = False; checked = 0
for cut in (int(n * 0.80), int(n * 0.92)):
if cut <= tail + 5 or cut >= n:
continue
sub = j.iloc[:cut].reset_index(drop=True)
s = np.nan_to_num(make_pos(sub))
if len(s) != cut:
bad = True; continue
d = np.abs(s[cut - tail:cut] - full[cut - tail:cut])
worst = max(worst, float(np.max(d)) if len(d) else 0.0)
checked += 1
return dict(ok=bool((not bad) and worst <= tol), max_tail_diff=round(worst, 10), checked=checked)
# ===========================================================================
# SIGNAL FACTORIES — factory(tf, **p) -> make_pos(j) -> vol-targeted position on the spread
# (direzione in [-1,1] internamente, poi vol_target_spread). 'sgn' (+/-1) testa il verso.
# ===========================================================================
def f_ratio_mom(tf="1d", L=30, sgn=1, tv=0.20, vw=30, cap=2.0):
def make(j):
lr = j["log_ratio"].values
mom = lr - pd.Series(lr).shift(L).values # momentum L-barre del ratio
z = al.zscore(mom, max(20, L))
d = sgn * np.tanh(np.nan_to_num(z))
return vol_target_spread(d, j, tv, vw, cap)
return make
def f_ratio_rev(tf="1d", L=5, sgn=-1, tv=0.20, vw=30, cap=2.0):
def make(j):
lr = j["log_ratio"].values
z = al.zscore(lr, L) # deviazione di breve dal trend locale
d = sgn * np.tanh(np.nan_to_num(z))
return vol_target_spread(d, j, tv, vw, cap)
return make
def f_ratio_accel(tf="1d", L=20, sgn=-1, tv=0.20, vw=30, cap=2.0):
def make(j):
lr = pd.Series(j["log_ratio"].values)
accel = lr - 2 * lr.shift(L) + lr.shift(2 * L) # 2a differenza (curvatura)
z = al.zscore(np.nan_to_num(accel.values), max(20, L))
d = sgn * np.tanh(np.nan_to_num(z))
return vol_target_spread(d, j, tv, vw, cap)
return make
def f_volspread(tf="1d", W=30, sgn=1, tv=0.20, vw=30, cap=2.0):
def make(j):
bpd = al.bars_per_day(j); bpy = bpd * ANN
vb = al.realized_vol(j["r_btc"].values, max(2, W * bpd), bpy)
ve = al.realized_vol(j["r_eth"].values, max(2, W * bpd), bpy)
z = al.zscore(np.nan_to_num(vb - ve), max(30, W)) # BTC più volatile di ETH?
d = sgn * np.tanh(np.nan_to_num(z))
return vol_target_spread(d, j, tv, vw, cap)
return make
def f_dvolspread(tf="1d", W=30, sgn=1, tv=0.20, vw=30, cap=2.0):
def make(j):
db = al.dvol(j, "BTC"); de = al.dvol(j, "ETH") # vol IMPLICITA causale (merge_asof)
sp = np.nan_to_num(db - de)
z = al.zscore(sp, max(30, W))
d = sgn * np.tanh(np.nan_to_num(z))
return vol_target_spread(d, j, tv, vw, cap)
return make
def f_statarb_resid(tf="1d", W=60, sgn=-1, tv=0.20, vw=30, cap=2.0):
def make(j):
x = np.log(j["cb"].values); y = np.log(j["ce"].values) # OLS rolling causale y~a+b x
sx = pd.Series(x); sy = pd.Series(y)
mx = sx.rolling(W, min_periods=W).mean()
my = sy.rolling(W, min_periods=W).mean()
cov = (sx * sy).rolling(W, min_periods=W).mean() - mx * my
var = (sx * sx).rolling(W, min_periods=W).mean() - mx * mx
beta = (cov / var.replace(0, np.nan))
resid = (sy - (my - beta * mx) - beta * sx).values # resid al tempo i (usa <= i)
z = al.zscore(np.nan_to_num(resid), W)
d = sgn * np.tanh(np.nan_to_num(z)) # mean-revert il residuo
return vol_target_spread(d, j, tv, vw, cap)
return make
FAMILIES = {
"RATIO-MOM": (f_ratio_mom, [dict(L=L, sgn=s) for L in (15, 30, 45, 60, 90) for s in (1, -1)], ("1d",)),
"RATIO-REV": (f_ratio_rev, [dict(L=L, sgn=s) for L in (3, 5, 8, 12, 20) for s in (1, -1)], ("1d",)),
"RATIO-ACCEL": (f_ratio_accel, [dict(L=L, sgn=s) for L in (10, 20, 30, 45) for s in (1, -1)], ("1d",)),
"VOLSPREAD": (f_volspread, [dict(W=W, sgn=s) for W in (10, 20, 30, 60) for s in (1, -1)], ("1d",)),
"DVOLSPREAD": (f_dvolspread, [dict(W=W, sgn=s) for W in (15, 30, 45, 60) for s in (1, -1)], ("1d",)),
"STATARB-RESID": (f_statarb_resid, [dict(W=W, sgn=s) for W in (30, 45, 60, 90, 120) for s in (1, -1)], ("1d",)),
}
# ===========================================================================
# BOOK + MARKET references (for corr / beta)
# ===========================================================================
def book_daily() -> pd.Series:
"""Serie NET giornaliera del BOOK attivo a 4 sleeve (TP01+XS01+VRP01+SKH01)."""
from src.portfolio.sleeves import active_sleeves
from src.portfolio.portfolio import StrategyPortfolio
pf = StrategyPortfolio(active_sleeves()); pf.backtest()
s = pf.combined_daily()
if not isinstance(s.index, pd.DatetimeIndex):
s.index = pd.to_datetime(s.index, utc=True)
if s.index.tz is None:
s.index = s.index.tz_localize("UTC")
return s.dropna()
def market_daily() -> pd.Series:
"""Mercato di riferimento: 50/50 BTC+ETH ritorni semplici giornalieri (per beta ~0)."""
series = {}
for a in ("BTC", "ETH"):
df = al.get(a, "1d")
series[a] = pd.Series(al.simple_returns(df["close"].values),
index=pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)))
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
return (0.5 * J["BTC"] + 0.5 * J["ETH"]).dropna()
def beta_to(cand: pd.Series, ref: pd.Series) -> tuple[float, float]:
J = pd.concat({"c": cand, "r": ref}, axis=1, join="inner").dropna()
if len(J) < 30 or J["r"].var() == 0:
return float("nan"), float("nan")
c, r = J["c"].values, J["r"].values
beta = float(np.cov(c, r)[0, 1] / np.var(r))
return round(beta, 3), round(float(J["c"].corr(J["r"])), 3)
# ===========================================================================
# HONEST FAMILY STUDY for the SPREAD (replica study_family_honest a mano):
# (1) cella scelta su Sharpe IN-SAMPLE (pre-HOLDOUT), MAI sul max hold-out;
# (2) deflated-Sharpe su TUTTE le celle; (3) marginal_vs_tp01 sulla cella scelta.
# ===========================================================================
def _sh(s) -> float:
return al._sh(s)
def study_spread_family(name, factory, grid, tfs, refs, fee_side=FEE_SIDE, dsr_min=0.95) -> dict:
rows = []
for tf in tfs:
for p in grid:
try:
j = build_joint(tf)
pos = factory(tf=tf, **p)(j)
daily = spread_daily(j, pos, fee_side=fee_side)
except Exception as ex: # pragma: no cover
rows.append(dict(tf=tf, params=p, err=str(ex)[:60])); continue
ins = daily[daily.index < HOLDOUT]
is_sh = _sh(ins) if len(ins) > 60 else float("nan")
rows.append(dict(tf=tf, params=p, daily=daily, insample_sharpe=round(is_sh, 3),
full_sharpe=round(_sh(daily), 3)))
valid = [r for r in rows if r.get("insample_sharpe") is not None
and np.isfinite(r.get("insample_sharpe", np.nan))]
if not valid:
return dict(name=name, chosen=None, earns_slot_honest=False, reason="no valid in-sample cell")
chosen = max(valid, key=lambda r: r["insample_sharpe"])
all_full = [r["full_sharpe"] for r in rows if r.get("full_sharpe") is not None]
j = build_joint(chosen["tf"])
pos = factory(tf=chosen["tf"], **chosen["params"])(j)
daily = chosen["daily"]
ev = eval_spread(j, pos, fee_side=fee_side)
# fee sweep (RT% = per-side ×2 legs? No: RT per leg = 2×side; on 2 legs the cost already
# ×2 in eval_spread. Report per-side grid -> "X%RT/leg".)
sweep = {}
for f in FEE_SWEEP:
sweep[f"{2*f*100:.2f}%RT/leg"] = round(eval_spread(j, pos, fee_side=f)["full"]["sharpe"], 3)
fee_survives = sweep.get(f"{2*0.0015*100:.2f}%RT/leg", -9) > 0
# marginal vs TP01
marg = al.marginal_vs_tp01(daily)
# deflated Sharpe over the WHOLE grid (come da istruzione)
dsr, sr0 = al.deflated_sharpe(_sh(daily), all_full, daily)
dsr_pass = bool(np.isfinite(dsr) and dsr >= dsr_min)
# SENSIBILITÀ: la griglia include sgn=+1 E sgn=-1 (specchi: +Sh e -Sh) -> raddoppia la
# dispersione dei trial e gonfia il null-max. DSR "same-sign" = deflazione sul SOLO verso
# scelto (conta i soli lookback davvero in competizione). È il limite ottimistico onesto.
same = [r["full_sharpe"] for r in rows
if r.get("full_sharpe") is not None and r["params"].get("sgn") == chosen["params"].get("sgn")]
dsr_ss, sr0_ss = al.deflated_sharpe(_sh(daily), same if len(same) >= 2 else all_full, daily)
dsr_ss_pass = bool(np.isfinite(dsr_ss) and dsr_ss >= dsr_min)
# corr/beta vs BOOK & MARKET
bbeta, bcorr = beta_to(daily, refs["book"])
mbeta, mcorr = beta_to(daily, refs["market"])
btcbeta, btccorr = beta_to(daily, refs["btc"])
ethbeta, ethcorr = beta_to(daily, refs["eth"])
# $600 executability (2 legs)
sc = eval_spread_smallcap(j, pos)
earns = bool(marg.get("marginal_verdict") == "ADDS" and marg.get("robust_oos", False)
and marg.get("has_insample_edge", False) and not marg.get("is_hedge", False)
and dsr_pass and fee_survives)
return dict(name=name, n_cells=len(all_full), chosen=chosen, rows=valid,
ev=ev, sweep=sweep, fee_survives=fee_survives, marginal=marg,
deflated_sharpe=round(dsr, 3) if np.isfinite(dsr) else None,
expected_null_max=round(sr0, 3) if np.isfinite(sr0) else None, dsr_pass=dsr_pass,
dsr_samesign=round(dsr_ss, 3) if np.isfinite(dsr_ss) else None,
expected_null_max_ss=round(sr0_ss, 3) if np.isfinite(sr0_ss) else None,
dsr_ss_pass=dsr_ss_pass, n_cells_samesign=len(same),
book=dict(beta=bbeta, corr=bcorr), market=dict(beta=mbeta, corr=mcorr),
btc=dict(beta=btcbeta, corr=btccorr), eth=dict(beta=ethbeta, corr=ethcorr),
smallcap=sc, earns_slot_honest=earns)
def verdict_for(rep: dict) -> tuple[str, str]:
if rep.get("chosen") is None:
return "SCARTATO", "nessuna cella valida"
m = rep["marginal"]
is_edge = m.get("has_insample_edge"); is_sh = m.get("cand_insample_sharpe")
dsr = rep.get("deflated_sharpe"); mv = m.get("marginal_verdict")
ss = rep.get("dsr_samesign"); ss_tag = f"same-sign {ss} {'pass' if rep.get('dsr_ss_pass') else 'fail'}"
if rep["earns_slot_honest"]:
return "SLEEVE-CANDIDATE-eseguibile", "ADDS + robust_oos + edge in-sample + DSR pass + fee/exec ok"
if mv == "DILUTES":
return "SCARTATO", "DILUTES (abbassa il blend del book)"
if m.get("is_hedge"):
return "SCARTATO", "HEDGE (paga solo quando TP01 è debole, non alpha)"
if not is_edge:
return "NOISE", f"no edge in-sample (Sharpe<2025 {is_sh} < 0.5) -> diversification-math"
# ha edge in-sample reale: il discriminante è se MIGLIORA il book (marginal ADDS)
if mv == "ADDS":
if not rep.get("dsr_pass"):
return "LEAD-forward-monitor", (f"ADDS + edge in-sample {is_sh} + executable, MA deflated-Sharpe "
f"full-grid {dsr} < 0.95 ({ss_tag}) -> multiple-testing")
if not m.get("robust_oos"):
return "LEAD-forward-monitor", "ADDS + edge ma non robust_oos (single-window / multi-cut debole)"
return "LEAD-forward-monitor", "ADDS ma blocco fee/exec"
# edge standalone reale ma marginal NEUTRAL/REDUNDANT: NON migliora il book -> niente slot
return "NEUTRAL-standalone", (f"edge in-sample {is_sh} reale ma marginal={mv}: non migliora il book "
f"(corr~0 senza uplift = diversification-math; hold-out debole)")
def print_family(rep: dict):
print("=" * 100)
print(f"### {rep['name']}")
if rep.get("chosen") is None:
print(" SCARTATO:", rep.get("reason")); return
ch = rep["chosen"]; ev = rep["ev"]; m = rep["marginal"]
print(f" best cell (IN-SAMPLE pick): tf={ch['tf']} params={ch['params']} "
f"[cercate {rep['n_cells']} celle]")
print(f" in-sample Sharpe (pre-2025) {ch['insample_sharpe']} | FULL Sharpe {ev['full']['sharpe']} "
f"DD {ev['full']['maxdd']*100:.1f}% ret {ev['full']['ret']*100:+.0f}% | "
f"HOLD Sharpe {ev['holdout'].get('sharpe')} ret {ev['holdout'].get('ret',0)*100:+.0f}%")
print(f" time-in-mkt {ev['tim']} turnover/yr {ev['turnover']}")
print(f" per-anno: " + " ".join(f"{y}:{d['ret']*100:+.0f}%(dd{d['dd']*100:.0f})"
for y, d in ev["yearly"].items()))
print(f" fee sweep (per-leg RT): {rep['sweep']} fee_survives={rep['fee_survives']}")
print(f" corr vs BOOK {rep['book']['corr']} (beta {rep['book']['beta']}) | "
f"beta vs MERCATO(50/50) {rep['market']['beta']} (corr {rep['market']['corr']})")
print(f" corr/beta vs BTC {rep['btc']['corr']}/{rep['btc']['beta']} "
f"vs ETH {rep['eth']['corr']}/{rep['eth']['beta']}")
print(f" MARGINAL vs TP01: verdict={m.get('marginal_verdict')} corr_full={m.get('corr_full')} "
f"corr_hold={m.get('corr_hold')}")
print(f" has_insample_edge={m.get('has_insample_edge')} (standalone Sh<2025 {m.get('cand_insample_sharpe')}) "
f"robust_oos={m.get('robust_oos')} multicut={m.get('multicut_persistent')} {m.get('multicut_uplift')}")
print(f" blend w25: full {m['blends']['w25']['full']} (uplift {m['blends']['w25']['uplift_full']:+.3f}) "
f"hold {m['blends']['w25']['hold']} (uplift {m['blends']['w25']['uplift_hold']}) is_hedge={m.get('is_hedge')}")
print(f" DEFLATED-Sharpe full-grid {rep['deflated_sharpe']} (null-max {rep['expected_null_max']}, "
f"{rep['n_cells']} celle) pass={rep['dsr_pass']} | same-sign {rep['dsr_samesign']} "
f"(null-max {rep['expected_null_max_ss']}, {rep['n_cells_samesign']} celle) pass={rep['dsr_ss_pass']}")
sc = rep["smallcap"]
print(f" EXEC $600 (2 gambe): modeled Sh {sc['modeled']['sharpe']} -> realistic {sc['realistic']['sharpe']} "
f"(haircut {sc['sharpe_haircut']}) trade eseguiti {sc['n_executed_trades']}")
v, why = verdict_for(rep)
print(f" >>> VERDETTO: {v}{why}")
print(f" earns_slot_honest = {rep['earns_slot_honest']}")
# ===========================================================================
# SECONDARIO — CRYPTO vs MACRO (GLD/QQQ/TLT) momentum relativo, merge_asof backward.
# ===========================================================================
def _eq_daily(sym: str) -> pd.Series:
p = _ROOT / "data" / "raw" / f"eq_{sym}_1d.parquet"
d = pd.read_parquet(p).sort_values("timestamp").reset_index(drop=True)
idx = pd.DatetimeIndex(pd.to_datetime(d["timestamp"], unit="ms", utc=True))
return pd.Series(d["close"].values, index=idx)
def study_macro_rv(hedge: str, L: int = 60, sgn: int = 1, fee_side: float = FEE_SIDE,
refs: dict | None = None) -> dict:
"""Long crypto (50/50 BTC+ETH) / short hedge ETF su momentum relativo L-giorni.
Allineamento: calendario dell'EQUITY (trading days); crypto allineata backward (no look-ahead).
Decisione <= close[i] del giorno di trading."""
eqc = _eq_daily(hedge)
# crypto close 50/50 (geometric proxy: media dei log-prezzi normalizzati)
cb = al.get("BTC", "1d"); ce = al.get("ETH", "1d")
cbi = pd.Series(cb["close"].values, index=pd.DatetimeIndex(pd.to_datetime(cb["datetime"], utc=True)))
cei = pd.Series(ce["close"].values, index=pd.DatetimeIndex(pd.to_datetime(ce["datetime"], utc=True)))
cj = pd.concat({"b": cbi, "e": cei}, axis=1, join="inner").dropna()
crypto = np.exp(0.5 * np.log(cj["b"]) + 0.5 * np.log(cj["e"])) # crypto index level
# merge_asof backward: per ogni giorno equity, l'ultimo close crypto <= quel giorno
L_ = pd.DataFrame({"ts": eqc.index, "eq": eqc.values}).sort_values("ts")
R_ = pd.DataFrame({"ts": crypto.index, "cr": crypto.values}).sort_values("ts")
mg = pd.merge_asof(L_, R_, on="ts", direction="backward").dropna()
eqv = mg["eq"].values; crv = mg["cr"].values; idx = pd.DatetimeIndex(mg["ts"])
r_eq = al.simple_returns(eqv); r_cr = al.simple_returns(crv)
s = r_cr - r_eq # long crypto / short hedge
lr = np.log(crv) - np.log(eqv) # log relative level
mom = lr - pd.Series(lr).shift(L).values
z = al.zscore(np.nan_to_num(mom), max(20, L))
d = sgn * np.tanh(np.nan_to_num(z))
# vol target on the macro spread
vol = al.realized_vol(s, 30, ANN)
scal = np.where((vol > 0) & np.isfinite(vol), 0.20 / vol, 0.0)
pos = np.clip(np.nan_to_num(d) * scal, -2.0, 2.0)
held = np.zeros(len(pos)); held[1:] = pos[:-1]
net = held * s - fee_side * 2.0 * np.abs(np.diff(held, prepend=0.0)); net[0] = 0.0
full = al._metrics_from_net(net, idx)
hmask = idx >= HOLDOUT
hold = al._metrics_from_net(net[hmask], idx[hmask]) if hmask.sum() > 3 else dict(sharpe=0.0, n=0)
daily = al._to_daily(pd.Series(net, index=idx))
ins = daily[daily.index < HOLDOUT]
is_sh = round(_sh(ins), 3) if len(ins) > 60 else None
bcorr = beta_to(daily, refs["book"])[1] if refs else None
mbeta, mcorr = beta_to(daily, refs["market"]) if refs else (None, None)
btcb = beta_to(daily, refs["btc"])[0] if refs else None
return dict(hedge=hedge, L=L, sgn=sgn, full=full, holdout=hold,
insample_sharpe=is_sh, book_corr=bcorr, mkt_beta=mbeta, mkt_corr=mcorr,
btc_beta=btcb, yearly=al._yearly(net, idx), daily=daily)
# ===========================================================================
# MAIN
# ===========================================================================
def main():
print("#" * 100)
print("# ORTHOGONAL SIGNALS — RELATIVE-VALUE ETH/BTC (dollar-neutral) + CRYPTO-vs-MACRO")
print("#" * 100)
j = build_joint("1d")
print(f"Joint BTC/ETH 1d: {len(j)} barre {j['datetime'].min().date()} -> {j['datetime'].max().date()} "
f"(hold-out {HOLDOUT.date()}+)")
s = spread_ret(j)
print(f"Spread r_eth-r_btc: vol annua {np.std(s)*math.sqrt(ANN)*100:.0f}% "
f"corr(r_eth,r_btc)={np.corrcoef(j['r_eth'].values[1:], j['r_btc'].values[1:])[0,1]:.2f}")
# references
print("\nCarico riferimenti BOOK / MERCATO ...")
refs = dict(book=book_daily(), market=market_daily())
cb = al.get("BTC", "1d"); ce = al.get("ETH", "1d")
refs["btc"] = pd.Series(al.simple_returns(cb["close"].values),
index=pd.DatetimeIndex(pd.to_datetime(cb["datetime"], utc=True)))
refs["eth"] = pd.Series(al.simple_returns(ce["close"].values),
index=pd.DatetimeIndex(pd.to_datetime(ce["datetime"], utc=True)))
print(f" BOOK 4-sleeve: {len(refs['book'])} giorni, Sharpe {_sh(refs['book']):.2f}")
# causality smoke-test on one cell per family
print("\n--- CAUSALITÀ (prefix-check sullo SPREAD, tol 1e-6) ---")
for name, (fac, grid, tfs) in FAMILIES.items():
p0 = grid[0]
ck = causality_spread(fac(tf=tfs[0], **p0), tf=tfs[0])
print(f" {name:14s} ok={ck['ok']} max_tail_diff={ck['max_tail_diff']} checked={ck['checked']}")
# study each family
print("\n" + "#" * 100)
print("# RELATIVE-VALUE ETH/BTC — selezione cella IN-SAMPLE, deflated-Sharpe su tutta la griglia")
print("#" * 100)
summary = []
for name, (fac, grid, tfs) in FAMILIES.items():
rep = study_spread_family(name, fac, grid, tfs, refs)
print_family(rep)
v, why = verdict_for(rep)
ch = rep.get("chosen")
summary.append(dict(name=name, verdict=v,
cell=(ch["params"] if ch else None),
full=rep["ev"]["full"]["sharpe"] if ch else None,
hold=rep["ev"]["holdout"].get("sharpe") if ch else None,
is_sh=rep["marginal"].get("cand_insample_sharpe") if ch else None,
book_corr=rep["book"]["corr"] if ch else None,
mkt_beta=rep["market"]["beta"] if ch else None,
dsr=rep.get("deflated_sharpe") if ch else None,
marginal=rep["marginal"].get("marginal_verdict") if ch else None,
earns=rep.get("earns_slot_honest") if ch else False,
haircut=rep["smallcap"]["sharpe_haircut"] if ch else None))
# secondary: crypto vs macro
print("\n" + "#" * 100)
print("# SECONDARIO — CRYPTO vs MACRO (relative momentum, merge_asof backward)")
print("#" * 100)
macro_rows = []
for hedge in ("gld", "qqq", "tlt"):
best = None
for L in (30, 60, 90):
for sgn in (1, -1):
r = study_macro_rv(hedge, L=L, sgn=sgn, refs=refs)
if best is None or (r["insample_sharpe"] or -9) > (best["insample_sharpe"] or -9):
best = r
macro_rows.append(best)
print(f" {hedge.upper():4s} bestIS L={best['L']} sgn={best['sgn']}: "
f"FULL Sh {best['full']['sharpe']} DD {best['full']['maxdd']*100:.0f}% "
f"HOLD Sh {best['holdout'].get('sharpe')} in-sample Sh {best['insample_sharpe']} "
f"corr->BOOK {best['book_corr']} beta->MERCATO {best['mkt_beta']} (corr {best['mkt_corr']}) "
f"beta->BTC {best['btc_beta']}")
print(" NOTA: la vol crypto (~47%) domina GLD/TLT/QQQ (~15%) -> r_cr-r_hedge ≈ r_cr: la 'relative"
" momentum' è di fatto MOMENTUM CRYPTO (la gamba hedge è troppo poco volatile per neutralizzare).")
print(" Beta-mercato ~0 solo perché il momentum entra/esce dall'esposizione; ma la corr->BOOK"
" 0.17-0.20 (vs ~0.02 degli spread ETH/BTC) tradisce l'overlap col trend di TP01 -> NON ortogonale.")
# marginal gate onesto sul migliore macro (per smentire il numero di Sharpe tentatore)
bm = max(macro_rows, key=lambda r: (r["insample_sharpe"] or -9))
mg = al.marginal_vs_tp01(bm["daily"])
print(f" MARGINAL gate sul migliore ({bm['hedge'].upper()}): verdict={mg.get('marginal_verdict')} "
f"corr_full={mg.get('corr_full')} blend-uplift w25 full {mg['blends']['w25']['uplift_full']:+.3f} "
f"hold {mg['blends']['w25']['uplift_hold']} robust_oos={mg.get('robust_oos')} "
f"-> {'overlap col trend (no slot)' if mg.get('marginal_verdict') in ('NEUTRAL','REDUNDANT','DILUTES') else 'da gate completo'}")
# final table
print("\n" + "#" * 100)
print("# SINTESI")
print("#" * 100)
print(f"{'segnale':14s} {'verdetto':28s} {'FULL':>5s} {'HOLD':>5s} {'IS-Sh':>5s} "
f"{'corrBK':>6s} {'':>5s} {'DSR':>5s} {'marg':>9s} {'cut':>6s} earns")
for r in summary:
print(f"{r['name']:14s} {r['verdict']:28s} "
f"{str(r['full']):>5s} {str(r['hold']):>5s} {str(r['is_sh']):>5s} "
f"{str(r['book_corr']):>6s} {str(r['mkt_beta']):>5s} {str(r['dsr']):>5s} "
f"{str(r['marginal']):>9s} {str(r['haircut']):>6s} {r['earns']}")
print("\nMACRO (secondario): " + " | ".join(
f"{m['hedge'].upper()} IS={m['insample_sharpe']} HOLD={m['holdout'].get('sharpe')} bkcorr={m['book_corr']}"
for m in macro_rows))
winners = [r for r in summary if r["earns"]]
leads = [r for r in summary if r["verdict"].startswith("LEAD")]
standalone = [r for r in summary if r["verdict"].startswith("NEUTRAL")]
print("\n" + "=" * 100)
print("CONCLUSIONE — c'è uno stream scorrelato CON edge reale ED eseguibile a 2 gambe?")
print("=" * 100)
if winners:
print("SI -> SLEEVE-CANDIDATE eseguibile a 2 gambe:", ", ".join(r["name"] for r in winners))
else:
print("NO sleeve pronto. L'ORTOGONALITÀ c'è (corr->BOOK ~0.02, beta-mercato ~0.01: il filone")
print("relative-value ETH/BTC è scorrelato per costruzione, ed è ESEGUIBILE a $600 — haircut ~0,")
print("fee-surviving a 0.30%RT/gamba). Ma l'EDGE non passa il deflated-Sharpe:")
if leads:
print(" LEAD (forward-monitor, ADDS + edge in-sample ma DSR<0.95):",
", ".join(r["name"] for r in leads))
if standalone:
print(" NEUTRAL-standalone (edge reale, NON migliora il book):",
", ".join(r["name"] for r in standalone))
print("Dettaglio nei verdetti per-segnale sopra.")
if __name__ == "__main__":
main()
+400
View File
@@ -0,0 +1,400 @@
"""signal_inout_1leg — 1-GAMBA a SEGNALI classici (entrata+uscita) su BTC/ETH (2026-06-29).
TESI
----
Un filone con un VANTAGGIO STRUTTURALE diverso dai book multi-gamba (XS01, CC01, basis,
short-vol) che restano STAT-MODE perche' a $600 non si eseguono: qui ogni strategia e' a
**1 SOLA GAMBA** (un singolo asset, BTC o ETH) con entrata E uscita gestite da SEGNALI
CLASSICI (MACD, RSI, Supertrend/ATR-trail, Donchian, Bollinger, EMA-cross, MACD+ADX).
Turnover basso => realmente ESEGUIBILE a $600 (cap $300/asset, min $5). Il vantaggio del
filone NON e' lo Sharpe assoluto — e' l'eseguibilita'.
MA la lezione del progetto e' brutale: un 1-gamba direzionale su BTC/ETH o e'
(a) TREND-FOLLOWING -> corr ~0.7-0.9 a TP01, marginal REDUNDANT (TP01 travestito), oppure
(b) MEAN-REVERSION -> morto sul feed reale (fade negativo anche a fee zero, v2.0.0), o
muore di fee a sub-daily.
Il soffitto direzionale BTC/ETH e' ~1.3 (= TP01). Quindi NON si giudica sullo Sharpe
assoluto ma sul MARGINALE vs TP01 (earns_slot), e la cella si sceglie IN-SAMPLE-ONLY
(no peeking del hold-out — il punto cieco del filone B / intraday-ERM).
GATE (tutti dall'harness condiviso altlib, leak-free by construction)
---------------------------------------------------------------------
1. study_family_honest(name, factory, grid, tfs):
- sceglie la cella per Sharpe IN-SAMPLE (pre-2025), MAI per max hold-out;
- study_marginal sulla cella scelta (corr vs TP01, blend uplift full/hold,
is_hedge, has_insample_edge, robust_oos multi-cut) -> earns_slot;
- deflated-Sharpe (Bailey & Lopez de Prado) su TUTTE le celle del grid (multiple-testing);
- earns_slot_honest = earns_slot AND DSR>=0.95.
2. causality_ok: la costruzione del segnale e' causale (max_tail_diff ~0, no peeking).
3. eval_weights_smallcap(capital=600, min_order=5): HAIRCUT $600 + n. trade eseguiti reali
(il punto FORTE del filone — turnover basso = eseguibile davvero).
4. fee-sweep 0.00-0.20% RT (dentro study_weights) a frequenza reale.
5. day_boundary_robust: per completezza (segnali di prezzo => INVARIANT atteso).
Decisione finale per ogni segnale:
SLEEVE-CANDIDATE-eseguibile -> earns_slot_honest=True AND haircut ~0 (executable). RARO.
LEAD-forward-monitor -> marginal ADDS ma DSR/hold corto, o edge esile: monitor.
REDUNDANT-vs-TP01 -> trend travestito (corr alta, uplift ~0).
SCARTATO -> abs FAIL (negativo), DILUTES, o morte per fee.
USO: cd /opt/docker/PythagorasGoal && uv run python scripts/research/signal_inout_1leg.py
Idempotente, solo stdout. NON committa (lo fa il coordinatore).
"""
from __future__ import annotations
import sys
import numpy as np
import pandas as pd
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt")
import altlib as al # noqa: E402
CERTIFIED = al.CERTIFIED
TFS = ("1d", "12h", "8h")
# ===========================================================================
# INDICATORI extra (causali) non gia' in altlib
# ===========================================================================
def macd_lines(c: np.ndarray, fast: int, slow: int, sig: int):
"""MACD = EMA(fast) - EMA(slow); signal = EMA(MACD, sig). EMA adjust=False => causale."""
macd = al.ema(c, fast) - al.ema(c, slow)
signal = al.ema(macd, sig)
return macd, signal
def supertrend_dir(df: pd.DataFrame, atr_win: int, mult: float) -> np.ndarray:
"""Supertrend classico -> direzione {+1 up, -1 down}. CAUSALE: dir[i] usa close[i] e le
bande finali calcolate fino a i-1 (mai high/low di i come prezzo d'ingresso)."""
h = df["high"].values.astype(float)
l = df["low"].values.astype(float)
c = df["close"].values.astype(float)
a = al.atr(df, atr_win)
hl2 = (h + l) / 2.0
upper = hl2 + mult * a
lower = hl2 - mult * a
n = len(c)
fu = np.zeros(n)
fl = np.zeros(n)
d = np.ones(n)
for i in range(n):
if i == 0 or not np.isfinite(a[i]):
fu[i], fl[i], d[i] = upper[i], lower[i], 1.0
continue
fu[i] = upper[i] if (upper[i] < fu[i - 1] or c[i - 1] > fu[i - 1]) else fu[i - 1]
fl[i] = lower[i] if (lower[i] > fl[i - 1] or c[i - 1] < fl[i - 1]) else fl[i - 1]
if c[i] > fu[i - 1]:
d[i] = 1.0
elif c[i] < fl[i - 1]:
d[i] = -1.0
else:
d[i] = d[i - 1]
return d
def adx(df: pd.DataFrame, win: int = 14) -> np.ndarray:
"""ADX di Wilder (forza di trend), causale via EWM. Usato come gate anti-whipsaw."""
h = df["high"].values.astype(float)
l = df["low"].values.astype(float)
up = np.zeros(len(h))
dn = np.zeros(len(h))
up[1:] = h[1:] - h[:-1]
dn[1:] = l[:-1] - l[1:]
plus_dm = np.where((up > dn) & (up > 0), up, 0.0)
minus_dm = np.where((dn > up) & (dn > 0), dn, 0.0)
atr_ = al.atr(df, win)
den = np.where(atr_ > 0, atr_, np.nan)
plus_di = 100 * pd.Series(plus_dm).ewm(alpha=1 / win, adjust=False).mean().values / den
minus_di = 100 * pd.Series(minus_dm).ewm(alpha=1 / win, adjust=False).mean().values / den
s = plus_di + minus_di
dx = 100 * np.abs(plus_di - minus_di) / np.where(s > 0, s, np.nan)
return pd.Series(dx).ewm(alpha=1 / win, adjust=False).mean().values
def _hold(pos_raw: np.ndarray) -> np.ndarray:
"""ffill dello stato fra i segnali (NaN = mantieni posizione precedente), start flat."""
return pd.Series(pos_raw).ffill().fillna(0.0).values
# ===========================================================================
# FACTORY dei segnali — ognuna: factory(tf, **params) -> target_fn(df)->posizione causale
# (tf passa solo per firma; la granularita' la sceglie candidate_daily caricando get(asset,tf))
# ===========================================================================
def make_macd(mode: str):
def factory(tf, fast, slow, sig):
def target(df):
c = df["close"].values.astype(float)
m, s = macd_lines(c, fast, slow, sig)
if mode == "LF":
return np.where(m > s, 1.0, 0.0)
return np.where(m > s, 1.0, -1.0)
return target
return factory
def make_rsi():
"""Mean-reversion long-flat: entra quando RSI<oversold, esce quando RSI>overbought, HOLD in mezzo."""
def factory(tf, win, oversold, overbought):
def target(df):
c = df["close"].values.astype(float)
ind = al.rsi(c, win)
raw = np.full(len(c), np.nan)
raw[ind < oversold] = 1.0
raw[ind > overbought] = 0.0
return _hold(raw)
return target
return factory
def make_supertrend(mode: str):
def factory(tf, atr_win, mult):
def target(df):
d = supertrend_dir(df, atr_win, mult)
return np.clip(d, 0, None) if mode == "LF" else d
return target
return factory
def make_donchian(mode: str):
"""Turtle: long su breakout del canale alto, esce/short sul canale basso, HOLD in mezzo."""
def factory(tf, win):
def target(df):
hi, lo = al.donchian(df, win)
c = df["close"].values.astype(float)
raw = np.full(len(c), np.nan)
raw[c > hi] = 1.0
raw[c < lo] = 0.0 if mode == "LF" else -1.0
return _hold(raw)
return target
return factory
def make_bbands(mode: str):
"""MR: long sotto banda bassa, esce al ritorno sopra la media. BO: long sopra banda alta,
flat/short sotto banda bassa."""
def factory(tf, win, k):
def target(df):
c = df["close"].values.astype(float)
up, mid, lo = al.bbands(c, win, k)
raw = np.full(len(c), np.nan)
if mode == "MR":
raw[c < lo] = 1.0
raw[c > mid] = 0.0
else: # breakout
raw[c > up] = 1.0
raw[c < lo] = 0.0
return _hold(raw)
return target
return factory
def make_ema(mode: str):
def factory(tf, fast, slow):
def target(df):
c = df["close"].values.astype(float)
ef, es = al.ema(c, fast), al.ema(c, slow)
if mode == "LF":
return np.where(ef > es, 1.0, 0.0)
return np.where(ef > es, 1.0, -1.0)
return target
return factory
def make_macd_adx():
"""MACD long-flat gated da ADX (entra long solo se MACD>signal E trend abbastanza forte)."""
def factory(tf, fast, slow, sig, adx_win, adx_thr):
def target(df):
c = df["close"].values.astype(float)
m, s = macd_lines(c, fast, slow, sig)
a = adx(df, adx_win)
return np.where((m > s) & (a > adx_thr), 1.0, 0.0)
return target
return factory
# ===========================================================================
# GRID
# ===========================================================================
def macd_grid():
g = []
for fast in (8, 12, 16):
for slow in (21, 26, 34):
for sig in (9,):
if fast < slow:
g.append(dict(fast=fast, slow=slow, sig=sig))
return g
RSI_GRID = [dict(win=14, oversold=os, overbought=ob)
for os in (25, 30, 35) for ob in (60, 65, 70)]
SUPER_GRID = [dict(atr_win=w, mult=m) for w in (10, 14, 20) for m in (2.0, 2.5, 3.0)]
DONCH_GRID = [dict(win=w) for w in (20, 30, 40, 55)]
BB_GRID = [dict(win=20, k=k) for k in (2.0, 2.5)]
EMA_GRID = [dict(fast=f, slow=s) for f in (10, 20, 30) for s in (50, 100, 200) if f < s]
MACD_ADX_GRID = [dict(fast=12, slow=26, sig=9, adx_win=14, adx_thr=t) for t in (15, 20, 25)]
# ===========================================================================
# DRIVER
# ===========================================================================
def _yr_line(absolute: dict) -> str:
"""Per-anno minimo-fra-asset dalla parte assoluta (cella scelta), TF best."""
cells = absolute.get("cells", [])
if not cells:
return ""
c = cells[0]
out = []
for a, pa in c["per_asset"].items():
yr = " ".join(f"{y}:{(d['ret'] if isinstance(d, dict) else d) * 100:+.0f}%"
for y, d in pa["yearly"].items())
out.append(f" {a}: {yr}")
return "\n".join(out)
def classify(rep: dict, haircut_ok: bool) -> tuple[str, str]:
"""Verdetto a 4 etichette + motivo di una riga."""
if rep.get("chosen") is None:
return "SCARTATO", "nessuna cella in-sample valida"
m = rep["marginal"]["marginal"]
mv = rep["marginal"]["marginal_verdict"]
abs_grade = rep["marginal"]["abs_grade"]
corr = m.get("corr_full")
uph = (m.get("blends", {}).get("w25", {}) or {}).get("uplift_hold")
hsh = m.get("cand_hold_sharpe")
trend_like = corr is not None and corr >= 0.5
if rep.get("earns_slot_honest"):
if haircut_ok:
return "SLEEVE-CANDIDATE-eseguibile", "earns_slot_honest=True + haircut $600 ~0 (extra-scettico: possibile selection/fee artifact)"
return "LEAD-forward-monitor", "earns_slot_honest=True ma haircut $600 non trascurabile"
if mv == "HEDGE":
return "LEAD-forward-monitor", "HEDGE: low-corr ma paga solo quando TP01 e' debole (non alpha standing); DSR/abs sotto soglia"
if mv == "DILUTES":
return "SCARTATO", "DILUTES: trascina giu' il blend TP01 (no edge marginale)"
if abs_grade == "FAIL":
if trend_like:
return "REDUNDANT-vs-TP01", f"trend = TP01 travestito (corr {corr}); la cella in-sample-best (sub-daily) overfitta -> hold-Sh {hsh} OOS"
return "SCARTATO", f"abs FAIL: la cella in-sample-best non generalizza OOS (hold-Sh {hsh})"
if mv == "REDUNDANT" or trend_like:
return "REDUNDANT-vs-TP01", f"trend travestito: corr {corr} a TP01, marginal {mv}, uplift-hold non persistente"
if mv == "ADDS":
return "LEAD-forward-monitor", "marginal ADDS ma deflated-Sharpe non passa (multiple-testing)"
return "REDUNDANT-vs-TP01", f"{mv}: nessun uplift marginale robusto (corr {corr})"
def run_family(name: str, factory, grid, tfs=TFS):
print("\n" + "=" * 100)
print(f"### {name} (grid {len(grid)} celle x {len(tfs)} TF = {len(grid) * len(tfs)} prove)")
print("=" * 100)
rep = al.study_family_honest(name, factory, grid, tfs)
ch = rep.get("chosen")
if ch is None:
print(f" -> {rep.get('reason', 'no chosen')}")
v, why = classify(rep, False)
print(f" VERDETTO: {v}{why}")
return rep, (name, v, why, None)
fn = factory(tf=ch["tf"], **ch["params"])
m = rep["marginal"]["marginal"]
mv = rep["marginal"]["marginal_verdict"]
absr = rep["marginal"]["absolute"]
bl = m.get("blends", {})
w25 = bl.get("w25", {})
w50 = bl.get("w50", {})
print(f" cella scelta IN-SAMPLE: tf={ch['tf']} {ch['params']} "
f"insample-Sh={ch['insample_sharpe']} full-Sh={ch['full_sharpe']}")
print(f" ABS grade={rep['marginal']['abs_grade']} | cand full-Sh={m.get('cand_full_sharpe')} "
f"hold-Sh={m.get('cand_hold_sharpe')} (TP01 full {m.get('tp01_full_sharpe')}/hold {m.get('tp01_hold_sharpe')})")
print(f" MARGINAL={mv} corr->TP01 full={m.get('corr_full')} hold={m.get('corr_hold')} "
f"is_hedge={m.get('is_hedge')} has_insample_edge={m.get('has_insample_edge')} robust_oos={m.get('robust_oos')}")
print(f" blend w25: full {w25.get('full')} (uplift {w25.get('uplift_full')}) "
f"hold {w25.get('hold')} (uplift {w25.get('uplift_hold')}) DD {w25.get('dd')}")
print(f" blend w50: full {w50.get('full')} (uplift {w50.get('uplift_full')}) "
f"hold {w50.get('hold')} (uplift {w50.get('uplift_hold')})")
print(f" multicut uplift {m.get('multicut_uplift')} persistent={m.get('multicut_persistent')}")
print(f" DEFLATED-SHARPE={rep.get('deflated_sharpe')} (null-max {rep.get('expected_null_max')}, "
f"n_cells {rep.get('n_cells')}) dsr_pass={rep.get('dsr_pass')}")
print(f" >>> earns_slot(marginal)={rep['earns_slot_marginal']} EARNS_SLOT_HONEST={rep['earns_slot_honest']}")
# causalita'
caus = al.causality_ok(fn, tf=ch["tf"])
print(f" causality_ok={caus['ok']} max_tail_diff={caus['max_tail_diff']}")
# HAIRCUT $600 + n. trade (il punto forte del filone)
print(" ESEGUIBILITA' $600 (haircut + n.trade eseguiti reali):")
haircut_ok = True
for a in CERTIFIED:
df = al.get(a, ch["tf"])
tgt = al._call_target(fn, df, a)
sc = al.eval_weights_smallcap(df, tgt, capital=600, min_order=5)
print(f" {a}: modeled Sh={sc['modeled']['sharpe']} real$600 Sh={sc['realistic']['sharpe']} "
f"haircut={sc['sharpe_haircut']} n_trade_eseguiti={sc['n_executed_trades']} "
f"turnover/yr={sc['executed_turnover_per_year']}")
if abs(sc["sharpe_haircut"]) > 0.25:
haircut_ok = False
# day-boundary (segnale di prezzo => INVARIANT atteso)
try:
db = al.day_boundary_robust(fn, tf=ch["tf"])
print(f" day_boundary: {db['verdict']} (spread {db.get('spread')})")
except Exception as e: # noqa
print(f" day_boundary: skip ({type(e).__name__})")
# per-anno
yr = _yr_line(absr)
if yr:
print(" per-anno (cella scelta):")
print(yr)
v, why = classify(rep, haircut_ok)
print(f" VERDETTO: {v}{why}")
return rep, (name, v, why, rep.get("earns_slot_honest"))
def main():
print("#" * 100)
print("# SIGNAL-IN/OUT 1-GAMBA — MACD/RSI/Supertrend/Donchian/Bollinger/EMA/MACD+ADX su BTC,ETH")
print(f"# TP01 baseline daily full Sharpe = {round(al._sh(al.tp01_baseline_daily()), 3)} (il soffitto da battere al MARGINE)")
print(f"# TF testati: {TFS} | fee 0.10% RT + sweep 0..0.20% | capitale reale $600")
print("#" * 100)
summary = []
families = [
("MACD-LF (long-flat)", make_macd("LF"), macd_grid()),
("MACD-LS (long-short)", make_macd("LS"), macd_grid()),
("RSI-MR (mean-rev long-flat)", make_rsi(), RSI_GRID),
("SUPERTREND-LF", make_supertrend("LF"), SUPER_GRID),
("SUPERTREND-LS", make_supertrend("LS"), SUPER_GRID),
("DONCHIAN-LF (turtle)", make_donchian("LF"), DONCH_GRID),
("DONCHIAN-LS (turtle)", make_donchian("LS"), DONCH_GRID),
("BBANDS-MR (mean-rev)", make_bbands("MR"), BB_GRID),
("BBANDS-BO (breakout)", make_bbands("BO"), BB_GRID),
("EMA-CROSS-LF", make_ema("LF"), EMA_GRID),
("EMA-CROSS-LS", make_ema("LS"), EMA_GRID),
("MACD+ADX-LF (gated)", make_macd_adx(), MACD_ADX_GRID),
]
for nm, fac, grid in families:
try:
_, row = run_family(nm, fac, grid)
summary.append(row)
except Exception as e: # noqa
import traceback
print(f"\n[ERRORE in {nm}] {type(e).__name__}: {e}")
traceback.print_exc()
summary.append((nm, "ERRORE", str(e), None))
print("\n" + "#" * 100)
print("# SOMMARIO FINALE")
print("#" * 100)
for nm, v, why, esh in summary:
flag = " <<< ESEGUIBILE+SCORRELATO" if esh else ""
print(f" {nm:32s} -> {v}{flag}")
print(f" {why}")
any_slot = any(esh for *_, esh in summary)
print("\nCONCLUSIONE: c'e' un 1-gamba a segnale che AGGIUNGE oltre TP01 ED e' eseguibile a $600?")
print(f" -> {'SI (verificare extra-scetticismo: selection/fee artifact)' if any_slot else 'NO — tutto REDUNDANT (trend=TP01) o SCARTATO (MR morta / fee). Risultato valido: base-rate confermata.'}")
if __name__ == "__main__":
main()
+387
View File
@@ -0,0 +1,387 @@
"""XSEC v3 — fattori cross-sectional "low-risk cousins" su ~51 alt Hyperliquid (1d, STAT-MODE).
TESI (filone C, terza ondata). I primi due filoni cross-sectional hanno coperto: momentum (XS01,
sleeve attivo), reversal/idio-reversal, TOTAL-low-vol (LOWVOL) e betting-against-beta (BAB).
Restano TRE anomalie "cugine del low-risk", documentate in equity ma MAI provate qui, che POTREBBERO
diversificare il portafoglio essendo strutturalmente diverse dal momentum e dal total-vol:
1. MAX (lottery-demand, Bali-Cakici-Whitelaw 2011). Gli asset col MASSIMO rendimento giornaliero
piu' alto nelle ultime B sedute attraggono domanda "da lotteria" e poi sottoperformano.
SHORT high-max / LONG low-max -> score = -max(daily_ret over B).
Diverso dal momentum (e' la coda destra recente, non il trend) e dal total-vol (un singolo
estremo, non la dispersione).
2. IVOL (idiosyncratic vol, Ang-Hodrick-Xing-Zhang 2006). SHORT alta vol del RESIDUO
(dopo aver tolto beta*mercato su finestra B) / LONG bassa. score = -ivol_residuo.
DIVERSO da LOWVOL (gia' provato in v2) che usa la vol TOTALE: qui si toglie prima il fattore
di mercato, isolando il rischio idiosincratico (ortogonale a BAB, che e' il beta sistematico).
3. AMIHUD (illiquidity, Amihud 2002). Ranking su |ret|/dollar_volume medio su B (dollar_volume =
volume_coin * close, perche' il volume HL e' in coin -> va dollarizzato per confrontare asset).
Tesi standard: premio di illiquidita' -> LONG illiquido / SHORT liquido. In crypto il segno e'
incerto (flight-to-quality verso i major liquidi), quindi si provano ENTRAMBI i segni e si tiene
quello con tesi economica + edge: AMIHUD_ILLIQ (score=+amihud) vs AMIHUD_LIQ (score=-amihud).
GATE OBBLIGATORI (CLAUDE.md + parita' con xsec_v2_nonmom):
- Griglia B in {20,30,60} x H in {5,10} x k in {5,8}, su ENTRAMBI gli universi (51-all, 19-major).
- CAUSALE: score a close[i], peso tenuto in i+1 (engine shifta W[i-1]*dret[i]); vol=0 gata.
Verifica prefix-consistency (xv.causality_prefix_check) sul best: ok=True, max_tail_diff~0.
- NETTO fee 0.10% RT su ogni gamba a ogni ribilancio (engine) + turnover/anno riportato.
- DEFLATED Sharpe (Bailey-Lopez de Prado) sul best, con TUTTI gli Sharpe FULL testati come trial
(multiple-testing): serve DSR>0.95 per un claim forte.
- corr vs XS01 e vs TP01 (vogliamo |corrXS|<0.6 per diversificare).
- Uplift del portafoglio 4->5 sleeve a 10% e 15% (active_sleeves, non modificati).
- Per-anno (breadth) + HOLD-OUT (2025-01-01+).
- ANTI-selection-on-holdout: il best e' scelto per HOLD massimo; si riporta ANCHE il best scelto
per Sharpe IN-SAMPLE (<2025) e si verifica che il deflated-Sharpe (che usa il FULL, in-sample
incluso) regga comunque.
CAVEAT immutabili: storia ~2.5 anni (deflated-Sharpe + multiple-testing), book a molte gambe NON
eseguibile a $600 -> STAT-MODE / forward-monitor, MAI deploy. Nessuno sleeve registrato.
uv run python scripts/research/xsec_v3_lowrisk.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))
sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research"))
import numpy as np
import pandas as pd
import xsec_v2_nonmom as xv # HARNESS collaudato (engine, metriche, statistica, portafoglio)
HOLDOUT = xv.HOLDOUT
metrics = xv.metrics
# ===========================================================================
# SCORE BUILDERS — "low-risk cousins". Tutti CAUSALI (dati <= i). Convenzione
# engine: long ALTO score / short BASSO score (vol=0 gata automaticamente).
# ===========================================================================
def make_max(PX, B):
"""MAX / lottery-demand: massimo rendimento giornaliero nelle ultime B sedute.
score = -max -> long low-max / short high-max (la 'lotteria' sottoperforma)."""
px, n, A, DR, m = xv._precompute(PX)
ROLLMAX = DR.rolling(B, min_periods=int(0.6 * B)).max().values
def score_at(i):
mx = ROLLMAX[i]
valid = np.isfinite(mx) & np.isfinite(px[i])
return -mx, valid
return score_at, B + 1
def make_ivol(PX, B):
"""IVOL: vol del RESIDUO dopo beta*mercato su finestra B (OLS in-window, esatto:
var_resid = var(y) - beta^2*var(m) con beta = cov/var, >= 0 per costruzione).
score = -ivol -> long bassa idio-vol / short alta (anomalia Ang et al.)."""
px, n, A, DR, m = xv._precompute(PX)
beta, varm = xv._rolling_beta(DR, m, B) # beta (n,A), varm (n,)
mp = int(0.6 * B)
ExDR = DR.rolling(B, min_periods=mp).mean()
ExDR2 = (DR * DR).rolling(B, min_periods=mp).mean()
varDR = (ExDR2 - ExDR ** 2).values # var population (coerente con _rolling_beta)
resid_var = varDR - (beta ** 2) * varm[:, None]
ivol = np.sqrt(np.clip(resid_var, 0.0, None))
def score_at(i):
iv = ivol[i]
valid = np.isfinite(iv) & np.isfinite(beta[i]) & np.isfinite(px[i])
return -iv, valid
return score_at, B + 1
def make_amihud(PX, VOL, B, sign):
"""AMIHUD illiquidity: media su B di |ret| / dollar_volume (volume_coin*close).
sign=+1 -> LONG illiquido (premio di illiquidita'); sign=-1 -> LONG liquido."""
px, n, A, DR, m = xv._precompute(PX)
dvol = (VOL * PX).replace(0, np.nan) # dollarizza il volume in coin
illiq = (DR.abs() / dvol).replace([np.inf, -np.inf], np.nan)
AMI = illiq.rolling(B, min_periods=int(0.6 * B)).mean().values
def score_at(i):
a = AMI[i]
valid = np.isfinite(a) & np.isfinite(px[i])
return sign * a, valid
return score_at, B + 1
def amihud_builder(VOL_full, sign):
"""Builder (PX,cfg) per AMIHUD che richiede il volume: chiude su VOL_full e lo RIALLINEA a
PX.index. Cosi' la causality_prefix_check (che tronca PX a PXc=PX[:cut] e chiama builder(PXc))
riceve automaticamente VOLc = VOL_full.reindex(PXc.index) -> nessun look-ahead dal volume."""
def builder(PX, p):
VOL = VOL_full.reindex(PX.index)
return make_amihud(PX, VOL, p["B"], sign)
return builder
# Griglia condivisa (parita' con i gate): B x H x k.
BHK = [dict(B=B, H=H, k=k) for B in (20, 30, 60) for H in (5, 10) for k in (5, 8)]
def build_mechanisms(VOL):
"""Catalogo per-universo: AMIHUD va legato al VOL dell'universo corrente."""
return {
"MAX": (lambda PX, p: make_max(PX, p["B"]), BHK),
"IVOL": (lambda PX, p: make_ivol(PX, p["B"]), BHK),
"AMIHUD_ILLIQ": (amihud_builder(VOL, +1), BHK), # long illiquido / short liquido
"AMIHUD_LIQ": (amihud_builder(VOL, -1), BHK), # long liquido / short illiquido
}
# Tesi economica per il verdetto AMIHUD (quale segno ha senso se mostra edge).
AMIHUD_THESIS = {
"AMIHUD_ILLIQ": "premio di illiquidita' (long illiquido / short liquido)",
"AMIHUD_LIQ": "flight-to-quality verso i major liquidi (long liquido / short illiquido)",
}
# ===========================================================================
# Helper
# ===========================================================================
def insample_sharpe(daily):
pre = daily[daily.index < HOLDOUT]
return metrics(pre)["sharpe"] if len(pre) > 30 else float("nan")
def per_year(daily):
_, yrs = xv.yr_breadth(daily)
years = [int(y) for y, _ in daily.groupby(daily.index.year)]
return [(y, round(v, 3)) for y, v in zip(years, yrs)]
def uplift_for(cand_daily, base, bf, bh, fractions=(0.10, 0.15)):
"""Uplift portafoglio 4->5 sleeve riusando le CACHE di `base` (Sleeve cached). Ritorna
{fr: (cf, ch, wgt)} e il best combinato (dFULL+dHOLD)."""
cand_fn = lambda: cand_daily
out, best = {}, None
for fr in fractions:
wraw = fr / (1.0 - fr) # cand_frac ~ fr (sum_base=1)
cand = xv.Sleeve("XSV3_cand", wraw, cand_fn)
pf1 = xv.StrategyPortfolio(base + [cand])
cf = metrics(pf1.combined_daily())
ch = metrics(pf1.combined_daily(lo=HOLDOUT))
wgt = pf1.weights().get("XSV3_cand", 0.0)
out[fr] = (cf, ch, wgt)
d = (cf["sharpe"] - bf["sharpe"]) + (ch["sharpe"] - bh["sharpe"])
best = d if best is None else max(best, d)
return out, best
INSAMPLE_EDGE = 0.5 # gate del progetto (scorer indurito): edge standalone PRE-holdout >=0.5
def robust_candidate(rows):
"""Candidato GIUDICATO: NON il best-by-HOLD nudo (che premia il holdout-fitting: una config
negativa in-sample con HOLD alto e' overfit alla finestra OOS, lezione dello scorer indurito),
ma il best fra le config con EDGE IN-SAMPLE (>=0.5) E HOLD>0, ordinate per Sharpe BILANCIATO
(insample+hold)/2. Se nessuna ha in-sample edge -> None (il meccanismo non ha edge reale,
qualunque HOLD alto e' artefatto di selezione)."""
elig = [r for r in rows if np.isfinite(r["insample"]) and r["insample"] >= INSAMPLE_EDGE and r["hold"] > 0]
if not elig:
return None
return max(elig, key=lambda r: 0.5 * (r["insample"] + r["hold"]))
def verdict(cand, dsr, caus_ok, uplift_best, has_isedge):
full, hold, corrXS = cand["full"], cand["hold"], cand["corrXS"]
diversifies = abs(corrXS) < 0.6
helps = (uplift_best is not None) and uplift_best > 0.10
if not has_isedge:
return "SCARTATO", "nessuna config con edge in-sample>=0.5 + HOLD>0 (qualunque HOLD alto e' selezione-su-holdout)"
strong = (dsr > 0.95) and (hold > 0.30) and (full > 0.70) and caus_ok and diversifies
if strong and helps:
return "SLEEVE-CANDIDATE", "edge robusto (DSR>0.95, in-sample+OOS, causale, diversifica, alza il portafoglio)"
if (full > 0.5 and hold > 0.0 and diversifies and caus_ok) and (helps or dsr > 0.50):
return "LEAD-forward-monitor", "edge in-sample E OOS coerente + diversifica, ma DSR<0.95 (96 trial, storia ~2.5y)"
if full > 0.3 and hold > 0.0:
return "DEBOLE", "segno giusto ma Sharpe/robustezza insufficienti"
return "SCARTATO", "no edge (full/hold non positivi o non diversifica)"
# ===========================================================================
# MAIN
# ===========================================================================
def main():
print("=" * 104)
print(" XSEC v3 — LOW-RISK COUSINS cross-sectional su Hyperliquid (MAX / IVOL / AMIHUD) — STAT-MODE")
print("=" * 104)
tp_daily = xv.tp01_sleeve().daily()
xs_daily = xv.xsec_sleeve().daily()
print(" riferimenti corr: TP01 (trend, deployable) e XS01 (momentum cross-sec, sleeve attivo).")
universes = {"51-all": None, "19-major": xv.XS_UNIVERSE}
mats = {}
for uname, u in universes.items():
PX, VOL = xv.load_matrix(u)
mats[uname] = (PX, VOL)
print(f" universo {uname:<9}: {PX.shape[1]:>2} asset, {PX.shape[0]} giorni "
f"[{PX.index[0].date()} -> {PX.index[-1].date()}]")
# ---- griglia completa: raccoglie tutte le righe + tutti gli Sharpe FULL (trial DSR) ----
MECHS = ("MAX", "IVOL", "AMIHUD_ILLIQ", "AMIHUD_LIQ")
rows_by_mech = {mn: [] for mn in MECHS}
all_sr = []
builders = {} # (uname, mech) -> builder (per causality)
for uname, (PX, VOL) in mats.items():
mechs = build_mechanisms(VOL)
print("\n" + "#" * 104)
print(f"# UNIVERSO {uname}")
print("#" * 104)
for mn in MECHS:
builder, cfgs = mechs[mn]
builders[(uname, mn)] = builder
rows = xv.run_grid(PX, VOL, mn, builder, cfgs, xs_daily, tp_daily, uname)
for r in rows:
r["uni"] = uname
r["mech"] = mn
r["insample"] = insample_sharpe(r["daily"])
rows_by_mech[mn].extend(rows)
all_sr.extend([r["full"] for r in rows])
if not rows:
print(f"\n [{mn}] nessuna config valida")
continue
pos_full = sum(r["full"] > 0 for r in rows)
pos_hold = sum(r["hold"] > 0 for r in rows)
print(f"\n [{mn}] {len(rows)} config | plateau FULL>0: {pos_full}/{len(rows)}"
f" | HOLD>0: {pos_hold}/{len(rows)}")
print(f" {'cfg':<18}{'FULL':>7}{'inS':>7}{'HOLD':>7}{'DD%':>6}{'ret%':>7}"
f"{'anni+':>7}{'corrXS':>8}{'corrTP':>8}{'turn/y':>8}")
for r in sorted(rows, key=lambda r: -r["hold"])[:3]:
print(f" {xv.tag(r['cfg']):<18}{r['full']:>7.2f}{r['insample']:>7.2f}{r['hold']:>7.2f}"
f"{r['dd']*100:>6.0f}{r['ret']*100:>+7.0f}{r['pct']*100:>6.0f}%"
f"{r['corrXS']:>+8.2f}{r['corrTP']:>+8.2f}{r['turn']:>8.0f}")
print(f"\n TRIAL TOTALI testati (per deflated-Sharpe): {len([s for s in all_sr if np.isfinite(s)])}")
# ---- base portafoglio una sola volta (Sleeve cached, riusati per ogni candidato) ----
base = xv.active_sleeves()
pf0 = xv.StrategyPortfolio(base); pf0.backtest()
bf = metrics(pf0.combined_daily()); bh = metrics(pf0.combined_daily(lo=HOLDOUT))
# ---- analisi per meccanismo ----
# CANDIDATO GIUDICATO = robust_candidate (edge in-sample>=0.5 E HOLD>0, best bilanciato): evita la
# trappola del best-by-HOLD nudo (che premia config negative in-sample = overfit alla finestra OOS).
# Si riportano comunque, per trasparenza/anti-cherry, anche il naive best-HOLD e il best-inSAMPLE.
summary = []
chosen_daily = {} # mech -> serie del candidato giudicato (per corr-matrix)
for mn in MECHS:
rows = rows_by_mech[mn]
print("\n" + "=" * 104)
print(f" MECCANISMO {mn}")
print("=" * 104)
if not rows:
print(" nessuna config valida -> SCARTATO")
summary.append((mn, "SCARTATO", "nessuna config valida", None))
continue
naive_hold = max(rows, key=lambda r: r["hold"])
best_is = max(rows, key=lambda r: (r["insample"] if np.isfinite(r["insample"]) else -9))
cand = robust_candidate(rows)
has_isedge = cand is not None
print(f" naive best-HOLD : [{naive_hold['uni']}] {xv.tag(naive_hold['cfg']):<16} "
f"FULL {naive_hold['full']:+.2f} inS {naive_hold['insample']:+.2f} HOLD {naive_hold['hold']:+.2f}"
f" {'(in-sample NEGATIVO -> holdout-fit)' if naive_hold['insample'] < INSAMPLE_EDGE else ''}")
print(f" best-inSAMPLE : [{best_is['uni']}] {xv.tag(best_is['cfg']):<16} "
f"FULL {best_is['full']:+.2f} inS {best_is['insample']:+.2f} HOLD {best_is['hold']:+.2f}"
f" (anti-selection-on-holdout)")
if not has_isedge:
print(f" CANDIDATO GIUDICATO: NESSUNA config con in-sample>=0.5 E HOLD>0 "
f"-> il meccanismo NON ha edge reale (ogni HOLD alto e' selezione-su-holdout).")
verd, why = verdict(naive_hold, float("nan"), True, None, False)
print(f"\n >>> VERDETTO {mn}: {verd}{why}." +
(f" tesi: {AMIHUD_THESIS[mn]}" if mn in AMIHUD_THESIS else ""))
summary.append((mn, verd, why, dict(uni=naive_hold["uni"], cfg=xv.tag(naive_hold["cfg"]),
full=naive_hold["full"], hold=naive_hold["hold"], dd=naive_hold["dd"],
dsr=float("nan"), corrXS=naive_hold["corrXS"], up=None, isedge=False)))
continue
daily = cand["daily"]
chosen_daily[mn] = daily
f, h, pct = xv.evalcfg(daily)
dsr, sr0 = xv.deflated_sharpe(f["sharpe"], all_sr, daily)
caus = xv.causality_prefix_check(*mats[cand["uni"]], builders[(cand["uni"], mn)], cand["cfg"])
ups, up_best = uplift_for(daily, base, bf, bh)
print(f" CANDIDATO GIUDICATO (in-sample>=0.5 & HOLD>0, best bilanciato):")
print(f" [{cand['uni']}] {xv.tag(cand['cfg']):<16} FULL {cand['full']:+.2f} "
f"inSAMPLE {cand['insample']:+.2f} HOLD {cand['hold']:+.2f} (in-sample EDGE = OK)")
print(f" standalone: DD {f['maxdd']*100:.0f}% ret {f['ret']*100:+.0f}% "
f"anni+ {pct*100:.0f}% turnover/y {cand['turn']:.0f}")
print(f" corr vs XS01 {cand['corrXS']:+.2f} | corr vs TP01 {cand['corrTP']:+.2f}")
print(f" CAUSALITA' prefix-check: ok={caus['ok']} max_tail_diff={caus['max_tail_diff']:.2e}")
print(f" DEFLATED Sharpe (N={len([s for s in all_sr if np.isfinite(s)])} trial): {dsr:.3f}"
f" | soglia Sharpe-max-null annualizz. {sr0:.2f} (serve DSR>0.95)")
print(f" per-anno: {per_year(daily)}")
print(f" UPLIFT portafoglio (base FULL {bf['sharpe']:.2f} / HOLD {bh['sharpe']:.2f}):")
for fr, (cf, ch, wgt) in ups.items():
print(f" +cand @{wgt*100:>4.1f}% FULL {cf['sharpe']:.2f} ({cf['sharpe']-bf['sharpe']:+.2f})"
f" DD {cf['maxdd']*100:.0f}% | HOLD {ch['sharpe']:.2f} ({ch['sharpe']-bh['sharpe']:+.2f})")
verd, why = verdict(cand, dsr, caus["ok"], up_best, has_isedge)
extra = f" tesi: {AMIHUD_THESIS[mn]}" if mn in AMIHUD_THESIS else ""
print(f"\n >>> VERDETTO {mn}: {verd}{why}.{extra}")
summary.append((mn, verd, why, dict(uni=cand["uni"], cfg=xv.tag(cand["cfg"]), full=cand["full"],
hold=cand["hold"], dd=f["maxdd"], dsr=dsr, corrXS=cand["corrXS"],
up=up_best, isedge=True)))
# ---- redundancy check: i 3 'low-risk cousins' sono UNA scommessa o TRE? ----
if len(chosen_daily) >= 2:
print("\n" + "=" * 104)
print(" RIDONDANZA — correlazione tra i candidati giudicati (sono lo stesso bet 'evita-speculativo'?)")
print("=" * 104)
names = list(chosen_daily.keys())
print(" " + "".join(f"{n[:8]:>10}" for n in names))
for a in names:
line = f" {a[:8]:<10}"
for b in names:
line += f"{xv._corr(chosen_daily[a], chosen_daily[b]):>10.2f}"
print(line)
print(" NB: corr alta tra i candidati = sono la stessa anomalia low-risk in tre vesti, non tre edge.")
# ---- AMIHUD: scegli il segno con tesi economica + edge ----
print("\n" + "=" * 104)
print(" AMIHUD — scelta del segno (tesi economica + edge)")
print("=" * 104)
a_ill = next((s for s in summary if s[0] == "AMIHUD_ILLIQ"), None)
a_liq = next((s for s in summary if s[0] == "AMIHUD_LIQ"), None)
for s in (a_ill, a_liq):
if s and s[3]:
print(f" {s[0]:<14} FULL {s[3]['full']:+.2f} HOLD {s[3]['hold']:+.2f} "
f"DSR {s[3]['dsr']:.2f} corrXS {s[3]['corrXS']:+.2f} -> {s[1]} ({AMIHUD_THESIS[s[0]]})")
cand_signs = [s for s in (a_ill, a_liq) if s and s[3] and s[3]["full"] > 0 and s[3]["hold"] > 0]
if cand_signs:
win = max(cand_signs, key=lambda s: 0.5 * (s[3]["full"] + s[3]["hold"]))
print(f" -> segno con edge+tesi: {win[0]} ({AMIHUD_THESIS[win[0]]})")
else:
print(" -> NESSUN segno mostra edge positivo (full>0 e hold>0): AMIHUD SCARTATO in entrambi i versi.")
# ---- sintesi finale ----
print("\n" + "=" * 104)
print(" SINTESI FINALE — c'e' un sopravvissuto reale?")
print("=" * 104)
for mn, verd, why, info in summary:
if info:
print(f" {mn:<14} {verd:<22} FULL {info['full']:+.2f} HOLD {info['hold']:+.2f} "
f"DSR {info['dsr']:.2f} corrXS {info['corrXS']:+.2f} upliftBest {info['up'] if info['up'] is not None else float('nan'):+.2f}")
else:
print(f" {mn:<14} {verd}")
survivors = [s for s in summary if s[1] == "SLEEVE-CANDIDATE"]
leads = [s for s in summary if s[1] == "LEAD-forward-monitor"]
if survivors:
print(f"\n SOPRAVVISSUTO: {', '.join(s[0] for s in survivors)} (sleeve-candidate, comunque STAT-MODE).")
elif leads:
print(f"\n Nessuno sleeve-candidate. LEAD da forward-monitor: {', '.join(s[0] for s in leads)}.")
else:
print("\n NESSUN sopravvissuto: tutti DEBOLE/SCARTATO. Risultato valido (la maggior parte muore).")
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: e' solo lavoro statistico (vincoli del filone C).")
if __name__ == "__main__":
main()
+417
View File
@@ -0,0 +1,417 @@
"""XSEC v3 — varianti STRUTTURALI di momentum cross-sectional su Hyperliquid (STAT-MODE).
TESI (filone XS). XS01 (sleeve attivo) e' momentum cross-sectional sui 19 major: blend di lookback
[30,90] (z-score cross-sectional mediato) + gate di dispersione, vol-target 20%. Lezione del
progetto (diari 2026-06-19/20): "i margini su XS sono nella STRUTTURA DEL SEGNALE, non nel numero
di asset". Quindi NON allarghiamo l'universo: testiamo 4 COSTRUZIONI di momentum STRUTTURALMENTE
diverse e chiediamo se MIGLIORANO o DIVERSIFICANO XS01 (o se sono solo XS01 travestito).
Varianti (tutte L/S dollar-neutral, top-k/bottom-k, CAUSALI; long alto score / short basso score):
RAMOM - RISK-ADJUSTED momentum: score = ritorno cumulato su L / vol realizzata su L
(momentum "Sharpe-like", non grezzo). Penalizza i trend rumorosi.
ACCEL - momentum ACCELERATION: score = mom(L_breve) - mom(L_lungo), la curvatura/2a differenza
del trend relativo (chi sta accelerando vs chi sta decelerando).
FIP - FROG-IN-THE-PAN / information discreteness: score = sign(mom) * ID, dove
ID = |%giorni-su - %giorni-giu| su L. Privilegia i trend LISCI (path consistente).
VOLSC - VOLATILITY-MANAGED momentum (Moreira-Muir): selezione = momentum, ma la LEVA del book
e' scalata dall'inverso della vol di MERCATO cross-section recente (rischia di piu' a
mercato calmo, meno in tempesta) invece del vol-target sulla vol della STRATEGIA.
GIUDIZIO = MARGINALE vs XS01, non assoluto. Una variant con corr~0.9 a XS01 e Sharpe simile NON
aggiunge nulla (e' XS01 travestito). Per ognuna calcolo: (a) corr vs XS01 e TP01; (b) uplift del
PORTAFOGLIO 4->5 sleeve a 10%/15%; (c) SOSTITUZIONE di XS01 con la variant a parita' di peso. Vince
solo se DIVERSIFICA (corr<0.7) E migliora l'hold-out aggiunta, OPPURE DOMINA XS01 a parita' di slot.
GATE (CLAUDE.md, metodologia obbligatoria):
1. griglia L in {30,60,90} (Ls/Ll per ACCEL), H in {5,10}, k in {5,8}, ENTRAMBI universi (51/19).
2. CAUSALE: score a close[i], peso tenuto in i+1 (engine shifta); vol=0 gata; prefix-check ok.
3. NETTO fee 0.10% RT su ogni gamba/ribilancio + turnover; sweep fee monotona (test).
4. DEFLATED Sharpe sul best con TUTTI gli Sharpe FULL come trial (multiple-testing; serve >0.95).
5. per-anno + HOLD-OUT 2025-01-01. ANTI selection-on-holdout: riporto best per IN-SAMPLE(<2025)
E best per HOLD, e verifico col deflated-Sharpe.
6. CAVEAT IMMUTABILE: book a molte gambe NON eseguibile a $600 -> STAT-MODE, MAI deploy.
uv run python scripts/research/xsec_v3_momstruct.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, xs_engine, evalcfg, ...)
from src.portfolio.sleeves import XS_UNIVERSE
DPY = xv.DPY
TV = xv.TV
FEE = xv.FEE
HOLDOUT = xv.HOLDOUT
# ===========================================================================
# SCORE BUILDERS — closure score_at(i)->(score[A], valid[A]) + warmup. CAUSALI (dati <= i).
# Modellati su make_mom/make_resid di xsec_v2_nonmom.py.
# ===========================================================================
def make_ramom(PX, L):
"""Risk-adjusted momentum: score = (px[i]/px[i-L]-1) / std(ritorni giornalieri su L)."""
px, n, A, DR, _ = xv._precompute(PX)
RVL = DR.rolling(L, min_periods=int(0.8 * L)).std().values
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
rv = RVL[i]
with np.errstate(invalid="ignore", divide="ignore"):
score = r / rv
valid = np.isfinite(score) & np.isfinite(px[i]) & np.isfinite(px[i - L]) & (rv > 0)
return score, valid
return score_at, L + 1
def make_accel(PX, Ls, Ll):
"""Acceleration: score = mom(Ls) - mom(Ll) (Ls<Ll), entrambi a close[i]."""
px, n, A, *_ = xv._precompute(PX)
def score_at(i):
if i - Ll < 0:
return np.full(A, np.nan), np.zeros(A, bool)
r_s = px[i] / px[i - Ls] - 1.0
r_l = px[i] / px[i - Ll] - 1.0
score = r_s - r_l
valid = np.isfinite(px[i]) & np.isfinite(px[i - Ls]) & np.isfinite(px[i - Ll])
return score, valid
return score_at, Ll + 1
def make_fip(PX, L):
"""Frog-in-the-pan / information discreteness: score = sign(mom_L) * |%up - %down| su L.
%up/%down calcolati sui SOLI giorni osservati (NaN pre-listing esclusi). Trend lisci -> |.| alto."""
px, n, A, DR, _ = xv._precompute(PX)
up = (DR > 0).astype(float).where(DR.notna())
dn = (DR < 0).astype(float).where(DR.notna())
mp = int(0.8 * L)
UPc = up.rolling(L, min_periods=mp).sum().values
DNc = dn.rolling(L, min_periods=mp).sum().values
CNT = DR.rolling(L, min_periods=mp).count().values
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
c = CNT[i]
with np.errstate(invalid="ignore", divide="ignore"):
pu = UPc[i] / c
pdn = DNc[i] / c
idd = np.abs(pu - pdn)
score = np.sign(r) * idd
valid = (np.isfinite(px[i]) & np.isfinite(px[i - L]) & np.isfinite(idd) & (c >= mp))
return score, valid
return score_at, L + 1
# ===========================================================================
# ENGINE volatility-managed (VOLSC): selezione momentum top-k/bottom-k IDENTICA a xs_engine, ma il
# vol-target NON e' sulla vol della STRATEGIA bensi' sull'inverso della vol di MERCATO cross-section
# (equal-weight) recente (Moreira-Muir). Distinzione strutturale unica da XS01. CAUSALE (shift(1)).
# ===========================================================================
def xs_engine_mktvol(PX, VOL, score_at, H, k, B_mkt=20, target_vol=TV, fee=FEE, min_assets=10,
warmup=0, cap=3.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])]
lo, hi = order[:kk], order[-kk:]
w = np.zeros(A)
w[hi] = 0.5 / kk
w[lo] = -0.5 / kk
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)
# vol-target sulla vol di MERCATO (equal-weight), causale (shift 1): leva alta a mercato calmo
mkt = PX.pct_change().mean(axis=1)
sig_mkt = mkt.rolling(B_mkt, min_periods=int(0.6 * B_mkt)).std().shift(1) * np.sqrt(DPY)
scale = np.clip(np.nan_to_num(target_vol / sig_mkt.replace(0, np.nan).values, nan=0.0), 0, cap)
turn_py = float(turn.sum() / (n / DPY)) if n else 0.0
return pd.Series(s.values * scale, index=PX.index), turn_py
def caus_check_mktvol(PX, VOL, builder, cfg, B_mkt=20, frac=0.85, tail=60, tol=1e-9):
"""Prefix-check di causalita' per il pipeline VOLSC (engine custom): ricostruisce su un prefisso
e confronta la coda con la run completa. Look-ahead -> divergenza."""
sa, warm = builder(PX, cfg)
full, _ = xs_engine_mktvol(PX, VOL, sa, cfg["H"], cfg["k"], B_mkt=B_mkt, warmup=warm)
cut = int(len(PX) * frac)
PXc, VOLc = PX.iloc[:cut], VOL.iloc[:cut]
sa2, warm2 = builder(PXc, cfg)
pre, _ = xs_engine_mktvol(PXc, VOLc, sa2, cfg["H"], cfg["k"], B_mkt=B_mkt, 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))
# ===========================================================================
# REGISTRY varianti: builder(PX,p)->(score_at,warm), griglia config, engine.
# ===========================================================================
def variants():
Lg = (30, 60, 90)
Hk = [dict(H=H, k=k) for H in (5, 10) for k in (5, 8)]
accel_pairs = [(30, 60), (30, 90), (60, 90)]
return {
"RAMOM": dict(
builder=lambda PX, p: make_ramom(PX, p["L"]),
cfgs=[dict(L=L, **hk) for L in Lg for hk in Hk],
engine="std", B_mkt=None),
"ACCEL": dict(
builder=lambda PX, p: make_accel(PX, p["Ls"], p["Ll"]),
cfgs=[dict(Ls=ls, Ll=ll, **hk) for (ls, ll) in accel_pairs for hk in Hk],
engine="std", B_mkt=None),
"FIP": dict(
builder=lambda PX, p: make_fip(PX, p["L"]),
cfgs=[dict(L=L, **hk) for L in Lg for hk in Hk],
engine="std", B_mkt=None),
"VOLSC": dict(
builder=lambda PX, p: xv.make_mom(PX, p["L"], +1),
cfgs=[dict(L=L, **hk) for L in Lg for hk in Hk],
engine="mktvol", B_mkt=20),
}
def run_variant_cfg(PX, VOL, v, p):
sa, warm = v["builder"](PX, p)
if v["engine"] == "mktvol":
s, turn = xs_engine_mktvol(PX, VOL, sa, p["H"], p["k"], B_mkt=v["B_mkt"], warmup=warm)
else:
s, turn = xv.xs_engine(PX, VOL, sa, p["H"], p["k"], warmup=warm)
return xv.to_daily(s), turn
def tag(p):
return " ".join(f"{kk}{vv}" for kk, vv in p.items())
def run_grid(PX, VOL, v, xs_daily, tp_daily, uname):
rows = []
for p in v["cfgs"]:
daily, turn = run_variant_cfg(PX, VOL, v, p)
if daily.std() == 0 or len(daily) < 60:
continue
f, h, pct = xv.evalcfg(daily)
ins = xv.metrics(daily[daily.index < HOLDOUT])["sharpe"]
rows.append(dict(cfg=p, uni=uname, daily=daily, full=f["sharpe"], hold=h["sharpe"],
ins=ins, dd=f["maxdd"], ret=f["ret"], pct=pct,
corrXS=xv._corr(daily, xs_daily), corrTP=xv._corr(daily, tp_daily),
turn=turn))
return rows
# ===========================================================================
# PORTAFOGLIO — base cablata una sola volta (cache sleeve riusate per uplift+sostituzione).
# ===========================================================================
_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() # warma le cache degli sleeve
_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("XSV3_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("XSV3_cand", 0.0))
def substitute_xs01(daily):
base, _ = _base()
sub = [xv.Sleeve("XSV3_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))
# ===========================================================================
# REPORT
# ===========================================================================
def per_year(daily):
out = []
for y, g in daily.groupby(daily.index.year):
out.append((int(y), round(float((1 + g).prod() - 1), 3)))
return out
def variant_verdict(pick, up_best, sub_full_d, sub_hold_d, caus_ok):
cx = abs(pick["corrXS"])
if not caus_ok:
return "SCARTATO", "non causale (prefix-check fallito)"
if pick["full"] <= 0.3 or pick["hold"] <= 0:
return "SCARTATO", f"standalone debole (FULL {pick['full']:+.2f}, HOLD {pick['hold']:+.2f})"
dominates = (sub_full_d > 0.02 and sub_hold_d > 0.05)
diversifies = (cx < 0.7) and (up_best[1] > 0.05) # up_best=(Δfull,Δhold)
if dominates:
return "MIGLIORA-XS01", f"sostituendo XS01 il book sale FULL {sub_full_d:+.2f} / HOLD {sub_hold_d:+.2f}"
if diversifies:
return "DIVERSIFICA", f"corrXS {pick['corrXS']:+.2f}<0.7 e uplift HOLD aggiunta {up_best[1]:+.2f}"
if cx >= 0.7:
return "REDUNDANT", f"corrXS {pick['corrXS']:+.2f} alta -> momentum XS01 travestito"
return "REDUNDANT", f"scorrelata (corrXS {pick['corrXS']:+.2f}) ma non additiva (uplift HOLD {up_best[1]:+.2f}, sub HOLD {sub_hold_d:+.2f})"
def main():
print("=" * 104)
print(" XSEC v3 — VARIANTI STRUTTURALI di momentum cross-sectional (RAMOM/ACCEL/FIP/VOLSC) — STAT-MODE")
print("=" * 104)
tp_daily = xv.tp01_sleeve().daily()
xs_daily = xv.xsec_sleeve().daily()
print(" riferimenti: XS01 (momentum blend+gate, sleeve attivo) e TP01 (trend BTC/ETH).")
xs_f = xv.metrics(xs_daily)
xs_h = xv.metrics(xs_daily[xs_daily.index >= HOLDOUT])
print(f" XS01 standalone: FULL Sh {xs_f['sharpe']:.2f} DD {xs_f['maxdd']*100:.0f}% | "
f"HOLD Sh {xs_h['sharpe']:.2f}")
universes = {"51-all": None, "19-major": XS_UNIVERSE}
mats = {}
for uname, u in universes.items():
PX, VOL = xv.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()}]")
vdefs = variants()
all_full = []
per_var_rows = {}
for vname, v in vdefs.items():
rows_all = []
for uname, (PX, VOL) in mats.items():
rows = run_grid(PX, VOL, v, xs_daily, tp_daily, uname)
rows_all += rows
all_full += [r["full"] for r in rows]
per_var_rows[vname] = rows_all
base, (bf, bh) = _base()
print(f"\n BASE portafoglio (4 sleeve attivi): FULL Sh {bf['sharpe']:.2f} DD {bf['maxdd']*100:.0f}%"
f" | HOLD Sh {bh['sharpe']:.2f} DD {bh['maxdd']*100:.0f}%")
summary = []
for vname, v in vdefs.items():
rows = per_var_rows[vname]
if not rows:
print(f"\n [{vname}] nessuna config valida.")
continue
n = len(rows)
pos_full = sum(r["full"] > 0 for r in rows)
pos_hold = sum(r["hold"] > 0 for r in rows)
pick_ins = max(rows, key=lambda r: (r["ins"], r["full"])) # selezione ONESTA (in-sample)
pick_hold = max(rows, key=lambda r: r["hold"]) # ceiling ottimistico
print("\n" + "#" * 104)
print(f"# {vname} | {n} config x2 universi | plateau FULL>0 {pos_full}/{n} | HOLD>0 {pos_hold}/{n}")
print("#" * 104)
print(f" {'pick':<12}{'cfg':<24}{'uni':<10}{'FULL':>6}{'INS':>6}{'HOLD':>6}{'DD%':>6}"
f"{'ret%':>7}{'an+':>6}{'crXS':>7}{'crTP':>7}{'t/y':>7}")
for lbl, r in (("by-INS<2025", pick_ins), ("by-HOLD", pick_hold)):
print(f" {lbl:<12}{tag(r['cfg']):<24}{r['uni']:<10}{r['full']:>6.2f}{r['ins']:>6.2f}"
f"{r['hold']:>6.2f}{r['dd']*100:>6.0f}{r['ret']*100:>+7.0f}{r['pct']*100:>5.0f}%"
f"{r['corrXS']:>+7.2f}{r['corrTP']:>+7.2f}{r['turn']:>7.0f}")
# top-3 per IN-SAMPLE per leggere il plateau
print(" --- top-3 by IN-SAMPLE Sharpe (plateau) ---")
for r in sorted(rows, key=lambda r: -r["ins"])[:3]:
print(f" {tag(r['cfg']):<24}{r['uni']:<10}FULL {r['full']:+.2f} INS {r['ins']:+.2f}"
f" HOLD {r['hold']:+.2f} corrXS {r['corrXS']:+.2f}")
# ---- gate sul pick_ins (selezione onesta) ----
pick = pick_ins
v_uni = pick["uni"]
PX, VOL = mats[v_uni]
if v["engine"] == "mktvol":
caus = caus_check_mktvol(PX, VOL, v["builder"], pick["cfg"], B_mkt=v["B_mkt"])
else:
caus = xv.causality_prefix_check(PX, VOL, v["builder"], pick["cfg"])
dsr, sr0 = xv.deflated_sharpe(pick["full"], all_full, pick["daily"])
print(f" CAUSALITA' (prefix-check) ok={caus['ok']} max_tail_diff={caus['max_tail_diff']:.2e}")
print(f" DEFLATED Sharpe (N={len([s for s in all_full if np.isfinite(s)])} trial GLOBALI): "
f"{dsr:.3f} | soglia Sharpe-max-null {sr0:.2f} (serve >0.95)")
print(f" per-anno (pick-INS): {per_year(pick['daily'])}")
# ---- portafoglio: uplift 4->5 e SOSTITUZIONE di XS01 (a parita' di peso) ----
print(" UPLIFT (aggiunta come 5o sleeve):")
up_best = (-9.0, -9.0)
for fr in (0.10, 0.15):
cf, ch, wgt = add_uplift(pick["daily"], fr)
df_, dh_ = cf["sharpe"] - bf["sharpe"], ch["sharpe"] - bh["sharpe"]
print(f" @{wgt*100:>4.1f}% FULL {cf['sharpe']:.2f} ({df_:+.2f}) DD {cf['maxdd']*100:.0f}%"
f" | HOLD {ch['sharpe']:.2f} ({dh_:+.2f})")
if (df_ + dh_) > (up_best[0] + up_best[1]):
up_best = (df_, dh_)
sf, sh = substitute_xs01(pick["daily"])
sub_full_d, sub_hold_d = sf["sharpe"] - bf["sharpe"], sh["sharpe"] - bh["sharpe"]
print(f" SOSTITUZIONE XS01->{vname} (peso {base[1].weight:.4f}): "
f"FULL {sf['sharpe']:.2f} ({sub_full_d:+.2f}) DD {sf['maxdd']*100:.0f}%"
f" | HOLD {sh['sharpe']:.2f} ({sub_hold_d:+.2f})")
verdict, why = variant_verdict(pick, up_best, sub_full_d, sub_hold_d, caus["ok"])
print(f" >>> VERDETTO {vname}: {verdict}{why}")
summary.append(dict(name=vname, pick=pick, dsr=dsr, caus=caus["ok"], up=up_best,
sub=(sub_full_d, sub_hold_d), verdict=verdict))
# ---- SINTESI ----
print("\n" + "=" * 104)
print(" SINTESI — giudizio MARGINALE vs XS01 (sleeve attivo)")
print("=" * 104)
print(f" {'variant':<8}{'FULL':>6}{'HOLD':>6}{'DD%':>6}{'corrXS':>8}{'corrTP':>8}{'DSR':>7}"
f"{'+upHOLD':>9}{'subHOLD':>9} verdetto")
for s in summary:
p = s["pick"]
print(f" {s['name']:<8}{p['full']:>6.2f}{p['hold']:>6.2f}{p['dd']*100:>6.0f}"
f"{p['corrXS']:>+8.2f}{p['corrTP']:>+8.2f}{s['dsr']:>7.3f}{s['up'][1]:>+9.2f}"
f"{s['sub'][1]:>+9.2f} {s['verdict']}")
winners = [s for s in summary if s["verdict"] in ("MIGLIORA-XS01", "DIVERSIFICA")]
print("\n CONCLUSIONE:")
if not winners:
print(" NESSUNA variante batte o diversifica davvero XS01. Tutte sono momentum-family ad")
print(" alta corr con XS01 e/o non additive al portafoglio -> REDUNDANT/SCARTATO. La")
print(" struttura del segnale (risk-adj/accel/smoothness/vol-timing) NON apre uno slot nuovo.")
else:
for s in winners:
print(f" {s['name']}: {s['verdict']} (forward-monitor). corrXS {s['pick']['corrXS']:+.2f}, "
f"+upHOLD {s['up'][1]:+.2f}, subHOLD {s['sub'][1]:+.2f}, DSR {s['dsr']:.3f}.")
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: e' lavoro statistico (vincoli del filone XS).")
if __name__ == "__main__":
main()