Files
PythagorasGoal/src/portfolio/sleeves.py
T
Adriano Dal Pastro fd5a0bd3cf feat(XS01): affina con blend di lookback [30,90] — FULL 0.80->1.10, portafoglio 1.41->1.48
Come TP01 fonde gli orizzonti, XS01 ora fonde 30g+90g del momentum cross-sectional (z-score per
lookback, mediato). Sweep: [30,90] e' il sweet spot (fonde i due singoli robusti, anti-overfit):
XS01 standalone FULL 0.80->1.10, DD 21%->14%, corr a TP01 -0.06->-0.12, 100% anni+. Portafoglio
TP01 70 + XS01 30: FULL Sh 1.41->1.48, DD 5.2%->4.6%, ~€/g 1.65->1.78; hold-out 1.15->1.06 (calo
marginale dentro il rumore). Piu' robusto (due orizzonti) + diversifica meglio -> promosso.

sleeves.XS_CFG lookbacks=(30,90), engine _xsec_returns usa lo score blended. 12 test ok.
Diario 2026-06-19-xsec-blend.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 22:19:12 +00:00

120 lines
6.1 KiB
Python

"""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 `_<nome>_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.
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.
# 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"]
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 # 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)
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)
# ----------------------------- 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."""
return [
tp01_sleeve(weight=0.70), # trend difensivo, BTC/ETH, dal 2019
xsec_sleeve(weight=0.30), # cross-sectional momentum Hyperliquid, dal 2024 (scorrelato, stat-mode)
]