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>
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user