diff --git a/docs/diary/2026-06-19-xsec-blend.md b/docs/diary/2026-06-19-xsec-blend.md new file mode 100644 index 0000000..5677a35 --- /dev/null +++ b/docs/diary/2026-06-19-xsec-blend.md @@ -0,0 +1,35 @@ +# 2026-06-19 — Affinamento XS01: blend di lookback [30,90] + +Come TP01 fonde gli orizzonti 30/90/180, XS01 ora fonde piu' lookback del momentum cross-sectional +(z-score cross-sectional per lookback, mediato) invece del singolo L=30. `scripts/portfolio/xsec_blend.py`. + +## Sweep lookback (19 major, 899g) — FULL/OOS/DD/anni+/corrTP +| lookbacks | FULL | OOS25 | DD% | anni+ | corrTP | +|---|---|---|---|---|---| +| [30] (prima) | 0.80 | 1.20 | 21 | 100% | −0.06 | +| [90] | 0.88 | 0.90 | 17 | 100% | −0.05 | +| **[30,90]** | **1.10** | **1.03** | **14** | **100%** | **−0.12** | +| [20,40,90] | 0.51 | 0.67 | 25 | 100% | −0.12 | +| [30,60,120] | 0.68 | 0.74 | 16 | 100% | −0.13 | + +**[30,90] e' il sweet spot**: fonde i DUE singoli robusti (30 e 90), FULL Sh 0.80→1.10, DD 21→14%, +corr a TP01 −0.06→−0.12 (diversifica meglio), 100% anni+. Non e' un cell fortunato: e' la +combinazione dei due lookback gia' validati (anti-overfit, come il multi-orizzonte di TP01). + +## Effetto sul portafoglio (TP01 70% + XS01 30%) +| | XS01 [30] | XS01 blend [30,90] | +|---|---|---| +| XS01 standalone FULL / DD | 0.80 / 21% | **1.10 / 14%** | +| Portafoglio FULL Sharpe | 1.41 | **1.48** | +| Portafoglio HOLD-OUT Sharpe | 1.15 | 1.06 | +| Portafoglio DD | 5.2% | **4.6%** | +| ~€/giorno (2k) | +1.65 | +1.78 | + +Migliora FULL Sharpe + DD + robustezza (due orizzonti) al costo di un hold-out marginalmente piu' +basso (−0.09, dentro il rumore di una singola finestra). Giudizio: il blend e' piu' robusto +(meno dipendente da un singolo lookback) e diversifica meglio -> PROMOSSO. + +## Azione +`src/portfolio/sleeves.XS_CFG`: `L=30` -> `lookbacks=(30,90)`; engine `_xsec_returns` usa lo score +blended (media z-score cross-sectional per lookback). **Portafoglio attivo: TP01 70% + XS01 blend +30%, FULL Sh 1.48 / HOLD 1.06 / DD 4.6%.** 12 test ok. Sleeve sempre sui 19 major. diff --git a/scripts/portfolio/xsec_blend.py b/scripts/portfolio/xsec_blend.py new file mode 100644 index 0000000..38341e1 --- /dev/null +++ b/scripts/portfolio/xsec_blend.py @@ -0,0 +1,108 @@ +"""AFFINAMENTO XS01 — blend di LOOKBACK (multi-orizzonte cross-sectional). + +XS01 attuale usa un singolo lookback (L=30). Come TP01 fonde gli orizzonti 30/90/180, qui il +momentum cross-sectional fonde piu' lookback: per ogni ribilancio, z-score cross-sectional del +rendimento a ciascun L, MEDIATO -> punteggio blended -> long top-k / short bottom-k. Piu' liscio +e robusto (meno dipendente da un singolo orizzonte/regime). Causale, netto fee, vol-target. +Confronto vs singolo-L + contributo al portafoglio TP01+XS01. + + uv run python scripts/portfolio/xsec_blend.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, pandas as pd +from src.portfolio.portfolio import to_daily, metrics, HOLDOUT, Sleeve, StrategyPortfolio +from src.portfolio.sleeves import tp01_sleeve, XS_UNIVERSE + +RAW = PROJECT_ROOT / "data" / "raw" +FEE = 0.001 + + +def load_majors(): + cols = {} + for sym in XS_UNIVERSE: + p = RAW / f"hl_{sym.lower()}_1d.parquet" + if p.exists(): + d = pd.read_parquet(p) + cols[sym] = pd.Series(d["close"].values.astype(float), index=pd.to_datetime(d["timestamp"], unit="ms", utc=True)) + return pd.concat(cols, axis=1, join="inner").sort_index().dropna() + + +def xs_signal(C, lookbacks, H=10, k=5, mode="mom", tv=0.20): + """lookbacks = lista (blend) o singolo [L]. Score = media z-score cross-sectional dei ret_L.""" + px = C.values; n, A = px.shape + dret = np.vstack([np.zeros(A), px[1:] / px[:-1] - 1.0]) + W = np.zeros((n, A)); w = np.zeros(A) + for i in range(n): + if i >= max(lookbacks) and i % H == 0: + score = np.zeros(A); cnt = 0 + for L in lookbacks: + rL = px[i] / px[i - L] - 1.0 + 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 + 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) + s = pd.Series(gross - turn * (FEE / 2.0), 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(C, lbs, tp): + d = xs_signal(C, lbs) + 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 + corr = float(pd.concat({"a": tp, "b": d}, axis=1, join="inner").dropna().corr().iloc[0, 1]) + return d, f, o, pct, corr + + +def main(): + C = load_majors() + tp = tp01_sleeve().daily() + print("=" * 92) + print(f" AFFINAMENTO XS01 — blend di lookback (19 major, {len(C)} giorni)") + print("=" * 92) + print(f" {'lookbacks':<22}{'FULL':>7}{'OOS25':>7}{'DD%':>6}{'anni+':>7}{'corrTP':>8}") + configs = [ + ("[30] (attuale)", [30]), ("[90]", [90]), ("[20]", [20]), + ("[20,40]", [20, 40]), ("[20,60]", [20, 60]), ("[30,90]", [30, 90]), + ("[20,40,90]", [20, 40, 90]), ("[30,60,120]", [30, 60, 120]), + ("[20,60,180]", [20, 60, 180]), ("[15,30,60,120]", [15, 30, 60, 120]), + ] + rows = [] + for name, lbs in configs: + d, f, o, pct, corr = ev(C, lbs, tp) + rows.append((name, lbs, d, f, o, pct, corr)) + print(f" {name:<22}{f['sharpe']:>7.2f}{o['sharpe']:>7.2f}{f['maxdd']*100:>6.0f}{pct*100:>6.0f}%{corr:>+8.2f}") + + # candidato: miglior blend per (FULL+OOS) con breadth 100% e corr bassa + cand = [r for r in rows if r[5] >= 0.99 and r[6] < 0.4] + cand.sort(key=lambda r: -(r[3]["sharpe"] + r[4]["sharpe"])) + print("\n CONTRIBUTO al portafoglio — attuale (XS [30]) vs miglior blend") + base_xs = rows[0][2] # [30] + for label, dxs in [("XS [30] attuale", base_xs)] + ([(cand[0][0], cand[0][2])] if cand else []): + J = pd.concat({"tp": tp, "xs": dxs}, axis=1, join="inner").dropna() + for w in (0.3,): + comb = (1 - w) * J["tp"] + w * J["xs"] + cf, ch = metrics(comb), metrics(comb[comb.index >= HOLDOUT]) + xf = metrics(J["xs"]); xo = metrics(J["xs"][J["xs"].index >= HOLDOUT]) + print(f" {label:<22} XS-solo FULL {xf['sharpe']:.2f}/OOS {xo['sharpe']:.2f} | TP01 70+XS 30: FULL {cf['sharpe']:.2f} HOLD {ch['sharpe']:.2f}") + if cand: + print(f"\n -> blend migliore: {cand[0][0]} (lookbacks {cand[0][1]}). Promuovere se batte [30] su") + print(" FULL+OOS+robustezza E migliora il portafoglio. Sennò resta [30].") + + +if __name__ == "__main__": + main() diff --git a/src/portfolio/sleeves.py b/src/portfolio/sleeves.py index b633cf2..29c0d02 100644 --- a/src/portfolio/sleeves.py +++ b/src/portfolio/sleeves.py @@ -52,7 +52,10 @@ def tp01_sleeve(weight: float = 1.0) -> Sleeve: # 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 -XS_CFG = dict(L=30, H=10, k=5, mode="mom", target_vol=0.20) +# 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. +XS_CFG = dict(lookbacks=(30, 90), H=10, k=5, mode="mom", target_vol=0.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. @@ -75,15 +78,23 @@ def _xsec_returns() -> pd.Series: 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 - L, H, k, mode, tv = XS_CFG["L"], XS_CFG["H"], XS_CFG["k"], XS_CFG["mode"], XS_CFG["target_vol"] + lookbacks, H, k, mode, tv = XS_CFG["lookbacks"], XS_CFG["H"], XS_CFG["k"], XS_CFG["mode"], XS_CFG["target_vol"] dret = np.vstack([np.zeros(A), px[1:] / px[:-1] - 1.0]) W = np.zeros((n, A)); w = np.zeros(A) for i in range(n): - if i >= L and i % H == 0: - order = np.argsort(px[i] / px[i - L] - 1.0) - 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 + if i >= max(lookbacks) and i % H == 0: + score = np.zeros(A); cnt = 0 # blend: media z-score cross-sectional per lookback + for L in lookbacks: + rL = px[i] / px[i - L] - 1.0 + 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 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)