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:
@@ -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()
|
||||
Reference in New Issue
Block a user