"""XS cross-sectional con UNIVERSO TOP-LIQUIDITÀ DINAMICO (Hyperliquid 52 certificati). Invece di 19 nomi fissi, a ogni ribilancio: seleziona i top-N per liquidità (dollar-volume 30g causale), poi fra quelli long i k più forti / short i k più deboli (momentum, market-neutral), vol-target. Idea: cross-section pulita e ADATTIVA (i token entrano quando maturano in liquidità), escludendo il long-tail rumoroso che diluiva il 52-all. Gestione ragged (asset a date diverse: si classifica solo fra i disponibili). Causale. Confronto vs fisso-19 + 52-all + contributo TP01. uv run python scripts/portfolio/xsec_dynuniverse.py """ from __future__ import annotations import sys, glob from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(PROJECT_ROOT)) import numpy as np, pandas as pd from src.portfolio.portfolio import to_daily, metrics, HOLDOUT from src.portfolio.sleeves import tp01_sleeve, XS_UNIVERSE RAW = PROJECT_ROOT / "data" / "raw" FEE = 0.001 def load_close_vol(): close, vol = {}, {} for p in sorted(glob.glob(str(RAW / "hl_*_1d.parquet"))): sym = Path(p).stem.replace("hl_", "").replace("_1d", "").upper() d = pd.read_parquet(p) ix = pd.to_datetime(d["timestamp"], unit="ms", utc=True) close[sym] = pd.Series(d["close"].values.astype(float), index=ix) vol[sym] = pd.Series(d["volume"].values.astype(float), index=ix) C = pd.concat(close, axis=1, join="outer").sort_index() V = pd.concat(vol, axis=1, join="outer").sort_index().reindex(C.index) return C, V def xs_dynamic(C, V, N=20, lb=60, hold=10, k=5, mode="mom", tv=0.20, fixed=None): """fixed=lista simboli -> universo statico (ignora liquidità). Altrimenti top-N per liquidità.""" cols = list(C.columns); A = len(cols) px = C.values; n = len(px) dret = np.full((n, A), 0.0); dret[1:] = np.where(np.isfinite(px[1:]) & np.isfinite(px[:-1]), px[1:] / px[:-1] - 1.0, 0.0) dvol = V.values * px liq = pd.DataFrame(dvol, index=C.index, columns=cols).rolling(30, min_periods=15).mean().shift(1).values fixed_mask = np.array([c in fixed for c in cols]) if fixed else None W = np.zeros((n, A)); w = np.zeros(A) for i in range(n): if i >= lb and i % hold == 0: retlb = np.where(np.isfinite(px[i]) & np.isfinite(px[i - lb]), px[i] / px[i - lb] - 1.0, np.nan) avail = np.isfinite(retlb) & np.isfinite(px[i]) if fixed is not None: avail &= fixed_mask cand = np.where(avail)[0] else: avail &= np.isfinite(liq[i]) idx = np.where(avail)[0] if len(idx) > N: cand = idx[np.argsort(liq[i][idx])[-N:]] # top-N per liquidità else: cand = idx w = np.zeros(A) ke = min(k, len(cand) // 2) if ke >= 1: order = cand[np.argsort(retlb[cand])] lo, hi = order[:ke], order[-ke:] if mode == "mom": w[hi] = 0.5 / ke; w[lo] = -0.5 / ke else: w[lo] = 0.5 / ke; w[hi] = -0.5 / ke 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 * (FEE / 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 to_daily(pd.Series(s.values * scale, index=C.index)) def ev(d): f = metrics(d); o = metrics(d[d.index >= HOLDOUT]) yr = [float((1 + g).prod() - 1) for _, g in d.groupby(d.index.year)] pct = sum(v > 0 for v in yr) / len(yr) if yr else 0 return f, o, pct def main(): C, V = load_close_vol() print("=" * 96) print(f" XS UNIVERSO TOP-LIQUIDITÀ DINAMICO — {len(C.columns)} asset certificati [{C.index[0].date()} -> {C.index[-1].date()}]") print("=" * 96) tp = tp01_sleeve().daily() print("\n (1) SWEEP N (top-liquidità) x config (mom) — FULL Sh / OOS25 Sh / anni+ / corrTP") print(f" {'config':<28}{'FULL':>7}{'OOS25':>7}{'anni+':>7}{'corrTP':>8}") best = None for N in (12, 15, 20, 25): for lb, hold, k in [(30, 10, 5), (60, 10, 5), (90, 10, 5)]: d = xs_dynamic(C, V, N=N, lb=lb, hold=hold, k=k) f, o, pct = ev(d) corr = float(pd.concat({"a": tp, "b": d}, axis=1, join="inner").dropna().corr().iloc[0, 1]) tag = f"top{N} L{lb}H{hold}k{k}" print(f" {tag:<28}{f['sharpe']:>7.2f}{o['sharpe']:>7.2f}{pct*100:>6.0f}%{corr:>+8.2f}") if (best is None or f['sharpe'] > best[1]['sharpe']) and corr < 0.4 and o['sharpe'] > 0: best = (tag, f, o, corr, d, (N, lb, hold, k)) print("\n (2) BASELINE di confronto (stessa finestra):") for name, kw in [("fisso-19 major (L30H10k5)", dict(lb=30, hold=10, k=5, fixed=set(XS_UNIVERSE))), ("fisso-19 major (L90H10k5)", dict(lb=90, hold=10, k=5, fixed=set(XS_UNIVERSE))), ("52-all (L60H10k5)", dict(lb=60, hold=10, k=5))]: d = xs_dynamic(C, V, **kw); f, o, pct = ev(d) print(f" {name:<28} FULL {f['sharpe']:.2f} OOS25 {o['sharpe']:.2f} anni+ {pct*100:.0f}%") if best is None: print("\n Nessuna config dinamica scorrelata+positiva. Il top-liquidità non aiuta.") return tag, f, o, corr, d, cfg = best print(f"\n === MIGLIOR DINAMICO: {tag} | FULL {f['sharpe']:.2f} ret {f['ret']*100:+.0f}% DD {f['maxdd']*100:.0f}% | OOS25 {o['sharpe']:.2f} | corrTP {corr:+.2f} ===") per = [(int(y), round(float((1 + g).prod() - 1), 3)) for y, g in d.groupby(d.index.year)] print(f" per-anno: {per}") # contributo al portafoglio vs fisso-19 (XS01 attuale) xs19 = xs_dynamic(C, V, lb=30, hold=10, k=5, fixed=set(XS_UNIVERSE)) J = pd.concat({"tp": tp, "dyn": d, "x19": xs19}, axis=1, join="inner").dropna() print(f"\n CONTRIBUTO (finestra comune {J.index[0].date()}->{J.index[-1].date()}):") for nm, col in [("TP01 solo", None), ("TP01+XS19 (attuale) 70/30", "x19"), ("TP01+DYN 70/30", "dyn")]: if col is None: comb = J["tp"] else: comb = 0.7 * J["tp"] + 0.3 * J[col] mf = metrics(comb); mh = metrics(comb[comb.index >= HOLDOUT]) print(f" {nm:<28} FULL Sh {mf['sharpe']:.2f} DD {mf['maxdd']*100:.0f}% | HOLD Sh {mh['sharpe']:.2f}") print("\n -> DINAMICO meglio del fisso-19? guarda FULL/OOS + contributo. Sennò: fisso-19 resta.") if __name__ == "__main__": main()