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) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 13:09:27 +02:00
parent e002968914
commit 9e1be75444
+173
View File
@@ -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()