diff --git a/docs/diary/2026-06-19-xsec-universe-expansion.md b/docs/diary/2026-06-19-xsec-universe-expansion.md index eeda0f6..c11850d 100644 --- a/docs/diary/2026-06-19-xsec-universe-expansion.md +++ b/docs/diary/2026-06-19-xsec-universe-expansion.md @@ -35,5 +35,28 @@ funziona fra i MAJOR liquidi, non sul long tail. Allargare l'universo NON è gra ## Lezione "Più asset = più robusto" è FALSO per il cross-sectional momentum: il long tail di alt piccoli diluisce/inverte l'edge. La breadth utile è quella dei major liquidi (corr-strutturata), non il -numero grezzo. Per rafforzare XS01 servirebbe semmai una selezione per LIQUIDITÀ/qualità, non più -nomi. (Coerente con la disciplina: nessuna espansione senza che migliori il gauntlet.) +numero grezzo. + +## Tentativo 2: UNIVERSO TOP-LIQUIDITÀ DINAMICO (`xsec_dynuniverse.py`) — anch'esso PEGGIORE +Provato a selezionare a ogni ribilancio i top-N per dollar-volume 30g (causale) dai 52, poi XS +momentum fra quelli (adattivo, ragged-aware). Esito: + +| Universo | FULL Sh | OOS25 | anni+ | +|---|---|---|---| +| top12 dinamico (L30H10k5) | 0.65 | 0.54 | 67% (2026 −4%) | +| top15/20/25 dinamico | 0.14-0.38 | ≤0.30 | 33-67% | +| **fisso-19 major (L30H10k5)** | **0.80** | **1.20** | **100%** | +| fisso-19 major (L90H10k5) | 0.88 | 0.90 | 100% | +Contributo: TP01+DYN 70/30 = FULL 1.10 / HOLD 0.60 vs **TP01+XS19 = FULL 1.25 / HOLD 1.15**. + +**Perché fallisce:** la classifica per dollar-volume ammette comunque i MEMECOIN ad alto volume +(WIF, ORDI, JUP, PEPE...) che hanno volumi enormi ma momentum erratico/mean-reverting → +diluiscono. **Liquidità ≠ qualità** nelle crypto. I 19 major *curati* (established, corr-strutturati, +non solo alto volume) restano il sweet spot. + +## Conclusione +Né più nomi (52) né top-liquidità dinamico migliorano XS01. **XS01 resta sui 19 major curati** +(FULL 0.80 / OOS 1.20, 100% anni+). Portafoglio invariato: TP01 70% + XS01 30% (FULL 1.41/HOLD 1.15). +Per rafforzarlo davvero servirebbe una curatela di QUALITÀ (established majors), che è già ciò che i +19 sono. Coerente con la disciplina: nessuna espansione senza che migliori il gauntlet. I 52 parquet +certificati restano per ricerca futura (es. trend multi-asset, dove il long tail non diluisce). diff --git a/scripts/portfolio/xsec_dynuniverse.py b/scripts/portfolio/xsec_dynuniverse.py new file mode 100644 index 0000000..d9b7b1a --- /dev/null +++ b/scripts/portfolio/xsec_dynuniverse.py @@ -0,0 +1,133 @@ +"""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()