fd5a0bd3cf
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>
120 lines
6.1 KiB
Python
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)
|
|
]
|