"""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()