9e1be75444
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>
174 lines
6.7 KiB
Python
174 lines
6.7 KiB
Python
"""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()
|