"""SLEEVE del portafoglio + REGISTRY degli sleeve attivi. Per AGGIUNGERE una strategia al portafoglio: 1. Validala col gauntlet onesto (scripts/analysis/research_lab.py + hold-out + cross-asset). 2. Scrivi una funzione `__returns() -> pd.Series` che ritorna i suoi rendimenti netti per-barra (datetime-indexed, CAUSALE, netto fee). Deve passare il guard di causalità. 3. Avvolgila in uno Sleeve(nome, peso, fn[, pos_fn]) e aggiungila a active_sleeves(). Niente sleeve non validati: il portafoglio è solo per edge che reggono il gauntlet. """ from __future__ import annotations import numpy as np import pandas as pd from src.data.downloader import load_data from src.strategies.trend_portfolio import TrendPortfolio, CANONICAL, resample_1d, simple_returns from src.portfolio.portfolio import Sleeve ASSETS = ("BTC", "ETH") # ----------------------------- TP01 (PORT LF1d) ----------------------------- def _tp01_returns() -> pd.Series: """TP01: TSMOM vol-target long-flat, 50/50 BTC+ETH, a 1d (>=12h: vedi nota look-ahead nel modulo). Rendimenti netti per-barra del portafoglio (causale: posizione decisa a close[i-1], tenuta in i).""" tp = TrendPortfolio(**CANONICAL) series = {} for a in ASSETS: df = resample_1d(load_data(a, "1h")) r = simple_returns(df["close"].values.astype(float)) tgt = tp.target_series(df) held = np.zeros(len(tgt)); held[1:] = tgt[:-1] net = held * r - tp.fee_side * np.abs(np.diff(held, prepend=0.0)); net[0] = 0.0 series[a] = pd.Series(np.clip(net, -0.99, None), index=pd.to_datetime(df["datetime"])) J = pd.concat(series, axis=1, join="inner").fillna(0.0) return pd.Series(0.5 * J["BTC"].values + 0.5 * J["ETH"].values, index=J.index) def _tp01_positions() -> dict: tp = TrendPortfolio(**CANONICAL) return {a: round(tp.current_target(resample_1d(load_data(a, "1h"))), 4) for a in ASSETS} def tp01_sleeve(weight: float = 1.0) -> Sleeve: return Sleeve("TP01_trend_1d", weight, _tp01_returns, pos_fn=_tp01_positions) # ----------------------------- XS01: Cross-Sectional Momentum (Hyperliquid) ----------------------------- # Universo certificato Hyperliquid (19 alt, 1d, dal 2024) in data/raw/hl_*_1d.parquet # (fetch+certify: scripts/analysis/fetch_hyperliquid.py). Market-neutral, scorrelato a TP01 (~-0.06). # CAVEAT ONESTI: storia corta (~2.5 anni, 2024-2026); STAT-MODE (book a 19 gambe market-neutral # non eseguibile a 2k, serve ~20k); l'edge e' nella DISPERSIONE cross-section (complementare al # trend di TP01: lavora quando TP01 e' in cash). Validato: scripts/portfolio/xsec_research.py. import glob as _glob from pathlib import Path as _Path # BLEND di lookback (2026-06-19): fonde 30g+90g del momentum cross-sectional (z-score per # lookback, mediato) come TP01 fonde gli orizzonti -> piu' robusto del singolo L=30: FULL Sh # 0.80->1.10, DD 21%->14%, corr a TP01 -0.06->-0.12, 100% anni+. Diario 2026-06-19-xsec-blend.md. # + GATE DI DISPERSIONE (2026-06-19): entra solo se la dispersione cross-section del momentum # supera il percentile ESPANDENTE causale disp_pct (altrimenti flat: in regime compatto XS e' # rumore). Plateau robusto p15-p35; a p30: portafoglio FULL 1.50->1.74, HOLD 1.06->1.56. # Diario 2026-06-19-xsec-dispgate.md. XS_CFG = dict(lookbacks=(30, 90), H=10, k=5, mode="mom", target_vol=0.20, disp_pct=30, disp_minhist=20) _HL_DIR = _Path(__file__).resolve().parents[2] / "data" / "raw" # UNIVERSO ESPLICITO = 19 ALT LIQUIDI MAJOR. NB (2026-06-19): allargare a 52 asset (incluso # small-cap WIF/JUP/ORDI/PYTH/TAO...) DILUISCE l'edge -> momentum cross-section NEGATIVO sui 52. # I major sono il sweet spot. NON usare glob-all (i parquet extra certificati servono ad altra # ricerca, non a XS01). Vedi diario 2026-06-19-xsec-universe-expansion.md. XS_UNIVERSE = ["BTC", "ETH", "SOL", "BNB", "XRP", "DOGE", "AVAX", "LINK", "LTC", "ADA", "ARB", "OP", "SUI", "APT", "INJ", "TIA", "SEI", "NEAR", "AAVE"] def _xsec_returns() -> pd.Series: 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)) if len(cols) < 10: raise FileNotFoundError("universo Hyperliquid XS01 incompleto: gira scripts/analysis/fetch_hyperliquid.py") C = pd.concat(cols, axis=1, join="inner").sort_index().dropna() px = C.values; n, A = px.shape lookbacks, H, k, mode, tv = XS_CFG["lookbacks"], XS_CFG["H"], XS_CFG["k"], XS_CFG["mode"], XS_CFG["target_vol"] disp_pct = XS_CFG.get("disp_pct", 0); minhist = XS_CFG.get("disp_minhist", 20) mlb = max(lookbacks) dret = np.vstack([np.zeros(A), px[1:] / px[:-1] - 1.0]) W = np.zeros((n, A)); w = np.zeros(A); disp_hist = [] for i in range(n): if i >= mlb and i % H == 0: rLs = [px[i] / px[i - L] - 1.0 for L in lookbacks] disp_i = float(np.mean([r.std() for r in rLs])) # dispersione cross-section del momentum thr = np.percentile(disp_hist, disp_pct) if (disp_pct > 0 and len(disp_hist) >= minhist) else -np.inf if disp_i >= thr: # gate: entra solo in regime disperso score = np.zeros(A); cnt = 0 # blend: media z-score cross-sectional for rL in rLs: sd = rL.std() if sd > 0: score += (rL - rL.mean()) / sd; cnt += 1 if cnt: score /= cnt order = np.argsort(score) w = np.zeros(A); lo, hi = order[:k], order[-k:] if mode == "mom": w[hi] = 0.5 / k; w[lo] = -0.5 / k else: w[lo] = 0.5 / k; w[hi] = -0.5 / k else: w = np.zeros(A) # regime compatto -> flat disp_hist.append(disp_i) W[i] = w gross = np.zeros(n); gross[1:] = np.sum(W[:-1] * 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 * (0.001 / 2.0) s = pd.Series(net, index=C.index) rv = s.rolling(30, min_periods=15).std().shift(1) * np.sqrt(365.25) scale = np.clip(np.nan_to_num(tv / rv.replace(0, np.nan).values, nan=0.0), 0, 3.0) return pd.Series(s.values * scale, index=C.index) def xsec_sleeve(weight: float = 0.3) -> Sleeve: return Sleeve("XS01_xsec_hl", weight, _xsec_returns) # ----------------------------- VRP01: Options Short-Vol (put credit spread + gate IV-rank) ----------------------------- # Income short-vol settimanale. Idee da FinanceOld/OptionsAgent (Bear Call Spread + gate d'ingresso) # portate sul VRP: (a) PUT CREDIT SPREAD rischio-definito (vendi put -0.28, compra put -0.10) che # CAPPA la coda (worst-week -16.6%->-7.4%, DD 33%->14%); (b) GATE IV-RANK>0.30 causale = vendi vol solo # quando ricca -> ribalta l'HOLD-OUT da -0.25 a +0.28 (e' l'alpha); (c) crash-skip IV-rank>0.90. # Premio BS su DVOL reale (data/raw/dvol_*.parquet via scripts/research/fetch_dvol.py), payoff sul path # certificato, fee opzioni Deribit (cap 12.5% del premio). CAVEAT ONESTI: premio MODELLATO su IV-ATM # (skew non esplicito), book a 1d, f di stress reale non catturato -> e' un LEAD robusto, non deploy # pieno. Scorrelato a TP01 (~+0.07). Ricerca: scripts/research/options_vrp_v2.py. # Diario 2026-06-20-financeold-analysis-vrp-v2.md. from scipy.stats import norm as _norm VRP_CFG = dict(short_delta=-0.28, long_delta=-0.10, f=1.0, tenor_d=7, gate_ivr=0.30, crash_skip=0.90, gate_vrp=True, fee_frac=0.125) def _bs_put(S, K, T, sig): if T <= 0 or sig <= 0: return max(K - S, 0.0) d1 = (np.log(S / K) + 0.5 * sig ** 2 * T) / (sig * np.sqrt(T)) return K * _norm.cdf(-(d1 - sig * np.sqrt(T))) - S * _norm.cdf(-d1) # r=0 def _strike_from_delta(S, T, sig, target_delta): return S * np.exp(0.5 * sig ** 2 * T - (-_norm.ppf(-target_delta)) * sig * np.sqrt(T)) def _vrp_weekly_asset(asset: str) -> pd.Series: """Put credit spread settimanale con gate causali. Ritorna rendimenti SETTIMANALI (su collaterale = strike corto, cash-secured) indicizzati alla data di scadenza. Causale: strike/premio/gate da dati <= sell-date; payoff a scadenza sui prezzi certificati.""" df = resample_1d(load_data(asset, "1h")) s = pd.Series(df["close"].values.astype(float), index=pd.to_datetime(df["datetime"])) if s.index.tz is None: s.index = s.index.tz_localize("UTC") dv = pd.read_parquet(_HL_DIR / f"dvol_{asset.lower()}.parquet") d = pd.Series(dv["close"].values.astype(float), index=pd.to_datetime(dv["timestamp"], unit="ms", utc=True)) J = pd.concat({"px": s, "dvol": d}, axis=1, join="inner").sort_index().dropna() px = J["px"].values; dvf = J["dvol"].values / 100.0; idx = J.index n = len(px); cfg = VRP_CFG; tn = cfg["tenor_d"]; T = tn / 365.25 rets = {} i = 60 while i + tn < n: S0 = px[i]; sig = dvf[i] skip = False if cfg["gate_vrp"] and i >= 31: # VRP>0: DVOL > RV30 causale rv = np.std(np.diff(np.log(px[i - 30:i + 1]))) * np.sqrt(365.25) if (sig - rv) <= 0: skip = True if not skip and (cfg["gate_ivr"] > 0 or cfg["crash_skip"] < 1.0) and i >= 60: ivr = float((dvf[:i] < dvf[i]).mean()) # IV-rank espandente causale if cfg["gate_ivr"] > 0 and ivr < cfg["gate_ivr"]: skip = True if cfg["crash_skip"] < 1.0 and ivr > cfg["crash_skip"]: skip = True if skip: rets[idx[i + tn]] = 0.0; i += tn; continue Ks = _strike_from_delta(S0, T, sig, cfg["short_delta"]) Kl = _strike_from_delta(S0, T, sig, cfg["long_delta"]) net_prem = (_bs_put(S0, Ks, T, sig) - _bs_put(S0, Kl, T, sig)) * cfg["f"] S1 = px[i + tn] payoff = max(0.0, Ks - S1) - max(0.0, Kl - S1) pnl = net_prem - payoff - cfg["fee_frac"] * abs(net_prem) rets[idx[i + tn]] = pnl / Ks i += tn return pd.Series(rets) def _vrp_combo_returns() -> pd.Series: """Sleeve VRP01: book 50/50 BTC+ETH del put credit spread gated, su griglia GIORNALIERA. Il rendimento settimanale e' piazzato sul giorno di scadenza, 0.0 sugli altri giorni (preserva lo Sharpe annualizzato senza smoothing): cosi' lo sleeve e' presente ogni giorno (peso costante).""" rB = _vrp_weekly_asset("BTC"); rE = _vrp_weekly_asset("ETH") wk = pd.concat({"B": rB, "E": rE}, axis=1, join="inner").mean(axis=1).sort_index() if wk.empty: return wk days = pd.date_range(wk.index.min().normalize(), wk.index.max().normalize(), freq="1D", tz="UTC") daily = pd.Series(0.0, index=days) daily.loc[wk.index.normalize()] = wk.values # lump settimanale sul giorno scadenza return daily def vrp_sleeve(weight: float = 0.20) -> Sleeve: return Sleeve("VRP01_shortvol", weight, _vrp_combo_returns) # ----------------------------- SKH01-V2-DD: Skyhook dual-TF regime+breakout (BTC/ETH) ----------------------------- # Sistema dual-timeframe (segnale 690m, exec 230m): entra solo quando coincidono REGIME # (BuzVola/BuzVolume tipo-Chande) E PATTERN (Donchian breakout). NON e' un trend-follower. # Vincitrice dell'onda DD-reduction (famiglia ASYM_LS): exit a percentuale fissa ASIMMETRICA # (long sl4%/tp10%, short sl2%/tp8% piu' stretto) -> taglia il DD standalone (BTC 21% / ETH 27%) # alzando hold-out (minHold +1.26) e valore di portafoglio. Quasi-ortogonale a TP01 (corr ~0.09): # blend 0.75*TP01+0.25*SKH -> hold-out Sharpe 0.31->1.17 (+0.87), DD full 14%->9%. Marginal ADDS, # has_insample_edge, robust_oos (multicut 7/7 anni), is_hedge=False. Verificato leak-free (causalita' # 0/400) + 2 scettici avversariali. Diario 2026-06-23-skyhook.md. # CAVEAT ONESTI: equity marcata a fine-trade (daily lumpy); ETH DD 27% ha margine sottile vs 30%; # il book opera a 230m -> ribilanciamento piu' frequente del resto (verificare costi reali a deploy). from src.strategies.skyhook import SKH01_V2_DD, build_frames, skyhook_entries from src.backtest.harness import backtest_signals def _skyhook_returns() -> pd.Series: """SKH01-V2-DD: book 50/50 BTC+ETH del sistema regime+breakout dual-TF, riportato su griglia GIORNALIERA. Causale (decide a close[i], exit intrabar TP/SL/max_bars, non-overlap), netto 0.10% RT.""" series = {} for a in ASSETS: ltf, htf = build_frames(load_data(a, "5m")) ent = skyhook_entries(ltf, htf, SKH01_V2_DD) m = backtest_signals(ltf, ent, fee_rt=0.001, leverage=1.0, asset=a, tf="230m") s = pd.Series(m.equity, index=pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True))) series[a] = s.resample("1D").last().ffill().pct_change().dropna() J = pd.concat(series, axis=1, join="inner").fillna(0.0) return pd.Series(0.5 * J["BTC"].values + 0.5 * J["ETH"].values, index=J.index) def _skyhook_positions() -> dict: """Stato corrente del book Skyhook per asset (introspezione live): se c'e' un trade APERTO ORA -> dir/entry/sl/tp/barre-trascorse; altrimenti 'flat'. Replica la logica non-overlap di entry+exit (TP/SL/max_bars) fino all'ultima barra 230m chiusa. Causale: usa solo barre chiuse.""" out = {} for a in ASSETS: ltf, htf = build_frames(load_data(a, "5m")) ent = skyhook_entries(ltf, htf, SKH01_V2_DD) H = ltf["high"].values; L = ltf["low"].values; Cc = ltf["close"].values n = len(ltf); i = 0; open_pos = "flat" while i < n: e = ent[i] if e is None: i += 1; continue d, sl, tp, mb = e["dir"], e["sl"], e["tp"], e["max_bars"] exit_idx = None for s in range(1, mb + 1): j = i + s if j >= n: # non ancora uscito: posizione APERTA ora break hit = (L[j] <= sl or H[j] >= tp) if d == 1 else (H[j] >= sl or L[j] <= tp) if hit or s == mb: exit_idx = j; break if exit_idx is None: open_pos = dict(dir="LONG" if d == 1 else "SHORT", entry=round(float(Cc[i]), 2), sl=round(float(sl), 2), tp=round(float(tp), 2), bars_in=int((n - 1) - i), max_bars=int(mb)) break i = exit_idx + 1 out[a] = open_pos return out def skyhook_sleeve(weight: float = 0.25) -> Sleeve: return Sleeve("SKH01_skyhook", weight, _skyhook_returns, pos_fn=_skyhook_positions) # ----------------------------- REGISTRY ----------------------------- def active_sleeves() -> list[Sleeve]: """Sleeve ATTIVI nel portafoglio (pesi rinormalizzati; sleeve a date diverse si attivano quando parte la loro storia). Aggiungere qui SOLO strategie validate col gauntlet. SKH01 cablato @0.25 effettivo: i tre sleeve preesistenti scalati nel restante 0.75 mantenendo il loro rapporto 55:25:20 (-> 41.25/18.75/15), cosi' Skyhook pesa esattamente 25% del book.""" return [ tp01_sleeve(weight=0.4125), # trend difensivo, BTC/ETH, dal 2019 (l'unico deployable pieno) xsec_sleeve(weight=0.1875), # cross-sectional momentum Hyperliquid, dal 2024 (scorrelato, stat-mode) vrp_sleeve(weight=0.15), # options short-vol (put credit spread + gate IV-rank), dal 2021 (lead modellato, scorrelato) skyhook_sleeve(weight=0.25), # dual-TF regime+breakout BTC/ETH, dal 2019 (quasi-ortogonale, exit %-asimmetrici, research) ] def deribit_book_sleeves() -> list[Sleeve]: """BOOK DERIBIT-ONLY realmente eseguibile (TP01 + SKH01, 75/25): le DUE gambe direzionali BTC/ETH sullo stesso venue (Deribit), entrambe dal 2019. Esclude XS01 (Hyperliquid, stat-mode) e VRP01 (opzioni modellate). FULL Sharpe ~1.78 / HOLD ~1.17 / DD ~9% (research; SKH01 daily-step). Pensato per il deploy reale a basso capitale: stesso conto, stesso feed, ribilanciamento periodico (vedi src.portfolio.portfolio.rebalance_sim + scripts/portfolio/run_deribit_book.py). TP01 e' gia' armato live; SKH01 e' il candidato 2a gamba (da validare codice d'esecuzione).""" return [ tp01_sleeve(weight=0.75), skyhook_sleeve(weight=0.25), ]