From 9e1be75444920378a84ba1003ecaf6da9f44fac6 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 29 May 2026 13:09:27 +0200 Subject: [PATCH] analysis(portfolio): clustering sleeve per correlazione + contributo rischio I cluster naturali sono per ASSET/REGIME, non per famiglia (BTC-reversion, ETH-reversion, trend TR01+TSM01, shape, rotation ROT02). Ridondanza lieve (max corr 0.43). PAIRS = 47% del rischio a equal-weight -> conferma cap 30-35%. Equal-weight batte inverse-vol/risk-parity in OOS calmo (pairs corrono liberi). Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/analysis/sleeve_clustering.py | 173 ++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 scripts/analysis/sleeve_clustering.py diff --git a/scripts/analysis/sleeve_clustering.py b/scripts/analysis/sleeve_clustering.py new file mode 100644 index 0000000..0a334a4 --- /dev/null +++ b/scripts/analysis/sleeve_clustering.py @@ -0,0 +1,173 @@ +"""Analisi di ACCORPAMENTO degli sleeve: le strategie possono essere raggruppate +meglio o diversamente rispetto all'attuale "per famiglia"? + +Costruisce le 17 sleeve daily (FADE 6 + HONEST 3 + PAIRS 5 + TSM01 + SHAPE 2), +e risponde con evidenza a: + 1. CORRELAZIONE: matrice completa -> quali sleeve sono ridondanti (corr alta)? + 2. CLUSTER: clustering gerarchico sulla distanza 1-corr -> i gruppi NATURALI + coincidono con le famiglie o no? + 3. RISCHIO: contributo di ogni sleeve alla volatilita' del portafoglio equal-weight + -> chi domina il rischio (e va cappato)? + 4. PESI: confronto equal-weight vs inverse-vol vs risk-parity (per cluster) su + ritorno/DD/Sharpe FULL e OOS. + +Tutto netto fee, leva 3x, finestra comune 2021-2026, OOS = ultimo 30%. +Run: uv run python scripts/analysis/sleeve_clustering.py +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) + +from scipy.cluster.hierarchy import linkage, fcluster +from scipy.spatial.distance import squareform + +from scripts.analysis.report_families import build_everything +from scripts.analysis.combine_portfolio import port_returns, metrics, SPLIT + + +def daily_matrix(sleeves: dict) -> pd.DataFrame: + return pd.DataFrame({k: v.pct_change().fillna(0.0) for k, v in sleeves.items()}) + + +def risk_contributions(dr: pd.DataFrame, w: np.ndarray) -> np.ndarray: + """Contributo % di ogni sleeve alla varianza del portafoglio (w'Σ).""" + cov = dr.cov().values + port_var = float(w @ cov @ w) + mrc = cov @ w # marginal risk contribution + rc = w * mrc # risk contribution (somma = port_var) + return rc / port_var * 100 if port_var > 0 else rc + + +def inv_vol(dr: pd.DataFrame) -> np.ndarray: + v = dr.std().values + inv = np.where(v > 0, 1.0 / v, 0.0) + return inv / inv.sum() + + +def cluster_risk_parity(dr: pd.DataFrame, labels: np.ndarray) -> dict: + """Peso: equal fra i CLUSTER, poi inverse-vol DENTRO ogni cluster. + Diversifica per gruppo-naturale invece che per sleeve -> non sovrappesa cluster affollati.""" + cols = list(dr.columns) + w = np.zeros(len(cols)) + clusters = sorted(set(labels)) + per_cluster = 1.0 / len(clusters) + for cl in clusters: + idx = [i for i, lb in enumerate(labels) if lb == cl] + sub = dr.iloc[:, idx] + iv = inv_vol(sub) + for j, i in enumerate(idx): + w[i] = per_cluster * iv[j] + return {cols[i]: w[i] for i in range(len(cols))} + + +def main(): + print("Costruzione 17 sleeve (~2-3 min)...\n") + S, pairs, tsm, shape = build_everything() + all_sl = {**S, **pairs, **tsm, **shape} + dr = daily_matrix(all_sl) + cols = list(dr.columns) + n = len(cols) + + fam_of = {} + for k in cols: + if k.startswith("MR"): + fam_of[k] = "FADE" + elif k.startswith("PR_"): + fam_of[k] = "PAIRS" + elif k.startswith("SH_"): + fam_of[k] = "SHAPE" + elif k == "TSM01": + fam_of[k] = "TSM" + else: + fam_of[k] = "HONEST" + + # ---------- 1. correlazione ---------- + print("=" * 100) + print(" (1) MATRICE DI CORRELAZIONE daily fra sleeve") + print("=" * 100) + corr = dr.corr() + short = [c.replace("_", "")[:8] for c in cols] + print(" " + "".join(f"{s[:6]:>7s}" for s in short)) + for i, c in enumerate(cols): + print(f" {short[i]:<6s}" + "".join(f"{corr.iloc[i, j]:>7.2f}" for j in range(n))) + + # coppie piu' correlate (candidati all'accorpamento) + print("\n Coppie piu' correlate (>0.5 -> ridondanza potenziale):") + pairs_corr = [] + for i in range(n): + for j in range(i + 1, n): + pairs_corr.append((corr.iloc[i, j], cols[i], cols[j])) + pairs_corr.sort(reverse=True) + for cc, a, b in pairs_corr[:12]: + flag = " <-- stessa famiglia" if fam_of[a] == fam_of[b] else " <-- CROSS-famiglia" + print(f" {a:<11s} {b:<11s} {cc:+.2f}{flag if cc > 0.5 else ''}") + + # ---------- 2. cluster ---------- + print("\n" + "=" * 100) + print(" (2) CLUSTERING GERARCHICO (distanza = 1-corr) — i gruppi naturali") + print("=" * 100) + dist = 1.0 - corr.values + np.fill_diagonal(dist, 0.0) + dist = (dist + dist.T) / 2 + Z = linkage(squareform(dist, checks=False), method="average") + for thr in (0.85, 0.95): + labels = fcluster(Z, t=thr, criterion="distance") + groups: dict[int, list] = {} + for c, lb in zip(cols, labels): + groups.setdefault(lb, []).append(c) + print(f"\n taglio a distanza {thr} (corr>{1-thr:.2f}) -> {len(groups)} cluster:") + for lb, members in sorted(groups.items()): + fams = {fam_of[m] for m in members} + print(f" C{lb}: {', '.join(members)} [{'/'.join(sorted(fams))}]") + + # ---------- 3. rischio ---------- + print("\n" + "=" * 100) + print(" (3) CONTRIBUTO AL RISCHIO (equal-weight) — chi domina la volatilita'") + print("=" * 100) + w_eq = np.ones(n) / n + rc = risk_contributions(dr, w_eq) + order = np.argsort(rc)[::-1] + print(f" {'sleeve':<12s}{'peso%':>7s}{'risk%':>7s} famiglia") + for i in order: + print(f" {cols[i]:<12s}{w_eq[i]*100:>7.1f}{rc[i]:>7.1f} {fam_of[cols[i]]}") + # rischio per famiglia + print("\n contributo al rischio per FAMIGLIA (equal-weight sleeve):") + fam_rc: dict[str, float] = {} + for i, c in enumerate(cols): + fam_rc[fam_of[c]] = fam_rc.get(fam_of[c], 0.0) + rc[i] + for f, v in sorted(fam_rc.items(), key=lambda x: -x[1]): + print(f" {f:<8s} {v:>5.1f}%") + + # ---------- 4. schemi di peso ---------- + print("\n" + "=" * 100) + print(" (4) SCHEMI DI PESO a confronto | FULL ret/DD/Sharpe | OOS ret/DD/Sharpe") + print("=" * 100) + labels95 = fcluster(Z, t=0.95, criterion="distance") + + schemes = { + "equal-weight": {c: 1.0 / n for c in cols}, + "inverse-vol": {cols[i]: inv_vol(dr)[i] for i in range(n)}, + "cluster-risk-parity": cluster_risk_parity(dr, labels95), + } + print(f" {'schema':<22s}{'Ret%':>9s}{'DD%':>7s}{'Shrp':>7s} | {'oRet%':>9s}{'oDD%':>7s}{'oShrp':>7s}") + print(" " + "-" * 78) + for nm, w in schemes.items(): + dserved = port_returns(all_sl, w) + f, o = metrics(dserved), metrics(dserved, lo=SPLIT) + print(f" {nm:<22s}{f['ret']:>+9.0f}{f['dd']:>7.1f}{f['sharpe']:>7.2f} | " + f"{o['ret']:>+9.0f}{o['dd']:>7.1f}{o['sharpe']:>7.2f}") + + print("\n Lettura: se i cluster naturali != famiglie, conviene pesare per CLUSTER (rischio)") + print(" invece che per famiglia. Se inverse-vol/risk-parity battono equal-weight in OOS,") + print(" l'accorpamento attuale (equal-weight per sleeve) e' migliorabile.") + + +if __name__ == "__main__": + main()