"""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()