Files
PythagorasGoal/scripts/research/r0701_portfolio_opt.py
T
Adriano Dal Pastro 491411ac77 research(wave-0701): 6 filoni multi-agente — 0 nuovi sleeve, pesi confermati, gate weights_tilt_null
Ondata onesta su angoli non coperti: funding-TS (chiude il filone funding su 3
lati), breadth alt (non-ridondante ma DSR 0.43, rivisitabile con storia),
XS-residmom (REDUNDANT), pesi+guardia-DD (EW-STR refutato dallo scettico come
selezione-sull'hold-out di 2° ordine, firma best-of-15), VRP-refine (filone
esaurito), stagionalità-XS (morta allo step statistico).

Lezione codificata: weights_tilt_null + combine_outer in src/portfolio
(ogni cambio-pesi vs null di tilt casuali cap-respecting + delta in-sample>=0);
5 test nuovi, suite 165/165.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:21:59 +00:00

473 lines
24 KiB
Python

"""r0701_portfolio_opt — MIGLIORARE IL PORTAFOGLIO ATTIVO SENZA NUOVE STRATEGIE (2026-07-01).
(A) RI-OTTIMIZZAZIONE DEI PESI STATICI dei 4 sleeve (TP01/XS01/VRP01/SKH01).
I pesi correnti (41.25/18.75/15/25) sono ad-hoc. Test onesto:
- ottimizza SOLO in-sample (pre-cut), valuta OOS (post-cut), multi-cut {2024-01, 2024-07, 2025-01};
- criteri: MAXSH (max-Sharpe simulato), RP (inverse-vol), ERC (equal-risk-contribution),
MINVAR-R (min-varianza con vincolo di ritorno >= ritorno dei pesi correnti in-sample);
- null di riferimento: EW (25x4) e CURRENT (pesi correnti);
- vincoli: 0.05 <= w_i <= 0.60, sum(w)=1;
- sleeve con < MIN_IS_DAYS giorni in-sample al cut (XS01 al 2024-01) -> PINNED al peso corrente
(l'ottimizzatore non ha dati per giudicarli), gli altri ottimizzati nel budget residuo;
- il combine replica ESATTAMENTE src/portfolio/portfolio.combined_daily (outer-join,
pesi rinormalizzati per-riga sugli sleeve attivi).
Verdetto: MIGLIORA solo se l'uplift OOS vs CURRENT e' PERSISTENTE (positivo a ogni cut),
non su una finestra sola.
(B) GUARDIA-DRAWDOWN a livello portafoglio (replica del soft-guard vincente del tail-hedge lab
2026-06-23, portato sul 4-sleeve). CAUSALE: l'esposizione del giorno D usa l'equity fino a D-1.
Trigger/re-entry sul NAV OMBRA (equity non-guardata: lezione stops_lab — sull'equity congelata
a expo=0 il DD non rientra mai). Isteresi: de-risk quando DD>X, re-risk quando DD<0.4*X.
Griglia modesta X in {3,4,5,6}%, derisk in {0, 0.5}. Selezione cella IN-SAMPLE (pre-cut,
criterio Calmar = CAGR/MaxDD), verifica OOS multi-cut.
NULL DECISIVO (lezione tp01-dvol-overlay): esposizione COSTANTE c calibrata in-sample per
pareggiare il MaxDD della guardia — lo Sharpe di c*r e' INVARIANTE, quindi la guardia "vale"
solo se a pari MaxDD trattiene piu' CAGR/Sharpe del semplice de-levering.
(C) Combinazione A+B se entrambe reggono.
NB (contesto): il thread meta-allocazione (2026-06-29) ha gia' mostrato che l'allocazione DINAMICA
fra sleeve perde vs pesi fissi — qui NON la rifacciamo: (A) e' statico, (B) e' un overlay di
esposizione aggregata, non riallocazione fra sleeve.
Solo ricerca: NON tocca src/ ne' scripts/live/. Propone pesi, non li applica.
uv run python scripts/research/r0701_portfolio_opt.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
import pandas as pd
from scipy.optimize import minimize
from src.portfolio.sleeves import active_sleeves
from src.portfolio.portfolio import HOLDOUT, DAYS_PER_YEAR
CUTS = [pd.Timestamp(c, tz="UTC") for c in ("2024-01-01", "2024-07-01", "2025-01-01")]
LO_W, HI_W = 0.05, 0.60 # vincoli pesi
MIN_IS_DAYS = 120 # sotto -> sleeve PINNED al peso corrente (niente dati per giudicarlo)
GUARD_TRIGGERS = (0.03, 0.04, 0.05, 0.06)
GUARD_DERISK = (0.0, 0.5)
GUARD_REC_FRAC = 0.4 # re-risk quando DD < 0.4*trigger (isteresi, come tail-hedge lab)
# --------------------------------------------------------------------------- dati
def load_matrix() -> tuple[pd.DataFrame, list[str], np.ndarray]:
"""Matrice daily OUTER-join dei 4 sleeve (NaN dove lo sleeve non e' ancora partito)."""
sl = active_sleeves()
names = [s.name for s in sl]
w_cur = np.array([s.weight for s in sl], float)
w_cur = w_cur / w_cur.sum()
J = pd.concat({s.name: s.daily() for s in sl}, axis=1, join="outer").sort_index()
J = J[J.notna().any(axis=1)]
return J, names, w_cur
def combo(J: pd.DataFrame, w: np.ndarray) -> pd.Series:
"""Replica ESATTA di portfolio.combined_daily: pesi rinormalizzati per-riga sugli attivi."""
active = J.notna().values * w
rowsum = active.sum(axis=1, keepdims=True)
wnorm = np.divide(active, rowsum, out=np.zeros_like(active), where=rowsum > 0)
return pd.Series(np.nansum(np.nan_to_num(J.values) * wnorm, axis=1), index=J.index)
def met(r: pd.Series | np.ndarray) -> dict:
v = np.asarray(pd.Series(r).dropna().values, float)
if len(v) < 20 or v.std() == 0:
return dict(sh=0.0, cagr=0.0, dd=0.0, calmar=0.0, n=len(v))
eq = np.cumprod(1 + v); pk = np.maximum.accumulate(eq)
yrs = len(v) / DAYS_PER_YEAR
cagr = float(eq[-1] ** (1 / yrs) - 1) if eq[-1] > 0 else -1.0
dd = float(np.max((pk - eq) / pk))
return dict(sh=float(v.mean() / v.std() * np.sqrt(DAYS_PER_YEAR)), cagr=cagr, dd=dd,
calmar=(cagr / dd if dd > 0 else 0.0), n=len(v))
def fmt_w(names, w):
return " ".join(f"{n.split('_')[0]} {x*100:.1f}%" for n, x in zip(names, w))
# --------------------------------------------------------------------------- (A) ottimizzatori
def _pinned_mask(J_is: pd.DataFrame, w_cur: np.ndarray) -> np.ndarray:
"""True = sleeve senza abbastanza storia in-sample -> peso fissato al corrente."""
return np.array([J_is[c].notna().sum() < MIN_IS_DAYS for c in J_is.columns])
def _solve(J_is, w_cur, objective, extra_cons=None, hi_bounds=None) -> np.ndarray:
"""SLSQP su pesi liberi (i pinned restano al corrente), bounds+somma, multi-start.
hi_bounds: array di upper-bound per-sleeve (default HI_W) — per i cap STRUTTURALI."""
k = len(w_cur)
hi = np.full(k, HI_W) if hi_bounds is None else np.asarray(hi_bounds, float)
pin = _pinned_mask(J_is, w_cur)
free = ~pin
budget = 1.0 - w_cur[pin].sum()
nf = int(free.sum())
def full_w(wf):
w = w_cur.copy(); w[free] = wf
return w
cons = [dict(type="eq", fun=lambda wf: wf.sum() - budget)]
if extra_cons:
cons += [dict(type="ineq", fun=lambda wf, f=f: f(full_w(wf))) for f in extra_cons]
bounds = [(LO_W, float(h)) for h in hi[free]]
hif = hi[free]
starts = [w_cur[free], np.full(nf, budget / nf)]
rng = np.random.default_rng(7)
for _ in range(4):
x = rng.uniform(LO_W, hif); starts.append(x / x.sum() * budget)
best, bval = w_cur.copy(), np.inf
for x0 in starts:
x0 = np.clip(x0, LO_W, hif); x0 = x0 / x0.sum() * budget
res = minimize(lambda wf: objective(full_w(wf)), x0, method="SLSQP",
bounds=bounds, constraints=cons,
options=dict(maxiter=300, ftol=1e-10))
if res.success and res.fun < bval:
bval, best = res.fun, full_w(res.x)
best = np.clip(best, LO_W, hi)
return best / best.sum()
def opt_maxsh(J_is, w_cur):
return _solve(J_is, w_cur, lambda w: -met(combo(J_is, w))["sh"])
def opt_minvar_ret(J_is, w_cur):
"""Min-varianza con vincolo: media in-sample >= media dei pesi correnti in-sample."""
mu_cur = float(combo(J_is, w_cur).mean())
return _solve(J_is, w_cur, lambda w: float(combo(J_is, w).var()),
extra_cons=[lambda w: float(combo(J_is, w).mean()) - mu_cur])
def opt_rp(J_is, w_cur):
"""Inverse-vol (risk-parity naive) sulla vol in-sample di ogni sleeve (giorni attivi propri)."""
pin = _pinned_mask(J_is, w_cur); free = ~pin
vol = np.array([J_is[c].dropna().std() for c in J_is.columns])
w = w_cur.copy()
iv = 1.0 / np.where(vol[free] > 0, vol[free], np.inf)
w[free] = iv / iv.sum() * (1.0 - w_cur[pin].sum())
for _ in range(50): # clip ai bounds + rinormalizza
w = np.clip(w, LO_W, HI_W)
if abs(w.sum() - 1.0) < 1e-9:
break
adj = ~np.isclose(w, LO_W) & ~np.isclose(w, HI_W)
if not adj.any():
break
w[adj] += (1.0 - w.sum()) * (w[adj] / w[adj].sum())
return w / w.sum()
def opt_erc(J_is, w_cur):
"""Equal-risk-contribution sulla cov in-sample pairwise-complete (submatrice dei non-pinned)."""
pin = _pinned_mask(J_is, w_cur)
cov = J_is.cov().values # pairwise-complete
idx = np.where(~pin)[0]
S = cov[np.ix_(idx, idx)]
S = np.nan_to_num(S, nan=0.0)
def obj(w):
wf = w[idx]
port = wf @ S @ wf
if port <= 0:
return 1e6
rc = wf * (S @ wf) / port
return float(np.sum((rc - 1.0 / len(idx)) ** 2))
return _solve(J_is, w_cur, obj)
def _struct_caps(J_is) -> np.ndarray:
"""Cap STRUTTURALI (prudenza, non statistica): VRP01 <= 15% (sleeve MODELLATO su DVOL ATM,
stress-f non catturato — 'niente short-vol da modello in deploy'), XS01 <= 25% (STAT-MODE,
storia ~2.5 anni). Gli ottimizzatori naive caricano VRP01 (vol 7.7% ma e' la vol del MODELLO)."""
return np.array([0.15 if c.startswith("VRP") else (0.25 if c.startswith("XS") else HI_W)
for c in J_is.columns])
def opt_maxsh_struct(J_is, w_cur):
return _solve(J_is, w_cur, lambda w: -met(combo(J_is, w))["sh"], hi_bounds=_struct_caps(J_is))
CRITERIA = [("MAXSH", opt_maxsh), ("RP-invvol", opt_rp), ("ERC", opt_erc), ("MINVAR-R", opt_minvar_ret),
("MAXSH-STR", opt_maxsh_struct)]
# --------------------------------------------------------------------------- (B) guardia-DD
def dd_guard(r: pd.Series, trigger: float, derisk: float, rec_frac: float = GUARD_REC_FRAC):
"""Soft-guard causale: expo[t] decisa dal DD del NAV OMBRA (non-guardato) fino a t-1.
DD>trigger -> expo=derisk; DD<rec_frac*trigger -> expo=1 (isteresi). Ritorna (serie, expo)."""
v = r.values
eq = np.cumprod(1 + v); pk = np.maximum.accumulate(eq)
n = len(v); expo = np.ones(n); on = True
for i in range(1, n):
ddi = (pk[i - 1] - eq[i - 1]) / pk[i - 1]
if ddi > trigger:
on = False
elif ddi < trigger * rec_frac:
on = True
expo[i] = 1.0 if on else derisk
return pd.Series(expo * v, index=r.index), expo
def const_expo_match_dd(r: pd.Series, target_dd: float) -> float:
"""Esposizione costante c (in-sample) che pareggia il MaxDD target — il null de-levering."""
lo, hi = 0.05, 1.0
if met(r)["dd"] <= target_dd:
return 1.0
for _ in range(40):
mid = 0.5 * (lo + hi)
if met(pd.Series(mid * r.values, index=r.index))["dd"] > target_dd:
hi = mid
else:
lo = mid
return lo
# --------------------------------------------------------------------------- report
def section_A(J, names, w_cur):
print("\n" + "=" * 108)
print(" (A) RI-OTTIMIZZAZIONE PESI STATICI — ottimizza IN-SAMPLE (pre-cut), valuta OOS (post-cut), multi-cut")
print("=" * 108)
# correlazioni e vol per contesto (full, pairwise)
C = J.corr()
print("\n corr pairwise (full):")
for i, a in enumerate(names):
print(" " + a.split("_")[0].ljust(6) + " ".join(f"{C.iloc[i, j]:+.2f}" for j in range(len(names))))
print(" vol annua per-sleeve (full): " +
" ".join(f"{n.split('_')[0]} {J[n].dropna().std()*np.sqrt(DAYS_PER_YEAR)*100:.1f}%" for n in names))
candidates = {} # nome -> pesi ottimizzati su pre-HOLDOUT (headline)
J_is_main = J[J.index < HOLDOUT]
for cname, fn in CRITERIA:
candidates[cname] = fn(J_is_main, w_cur)
candidates["EW"] = np.full(len(names), 1.0 / len(names))
caps = _struct_caps(J)
w_ewstr = np.full(len(names), 1.0 / len(names)) # EW sotto i cap strutturali (null senza fit)
for _ in range(20):
over = w_ewstr > caps + 1e-12
if not over.any():
break
excess = float((w_ewstr[over] - caps[over]).sum())
w_ewstr[over] = caps[over]
room = w_ewstr < caps - 1e-12 # redistribuisci SOLO a chi ha spazio
w_ewstr[room] += excess / max(int(room.sum()), 1)
candidates["EW-STR"] = w_ewstr
candidates["CURRENT"] = w_cur
print(f"\n PESI (ottimizzati su in-sample pre-{HOLDOUT.date()}):")
for cname, w in candidates.items():
print(f" {cname:<10s} {fmt_w(names, w)}")
# headline: FULL + HOLD-OUT
print(f"\n {'criterio':<10s} {'FULL Sh':>8s} {'FULL DD':>8s} {'FULL CAGR':>9s} | {'HOLD Sh':>8s} {'HOLD DD':>8s} {'HOLD CAGR':>9s}")
rows = {}
for cname, w in candidates.items():
f = met(combo(J, w)); h = met(combo(J[J.index >= HOLDOUT], w))
rows[cname] = (f, h)
print(f" {cname:<10s} {f['sh']:>8.2f} {f['dd']*100:>7.1f}% {f['cagr']*100:>+8.1f}% |"
f" {h['sh']:>8.2f} {h['dd']*100:>7.1f}% {h['cagr']*100:>+8.1f}%")
# multi-cut: ottimizza su <cut, valuta su >=cut, uplift vs CURRENT sulla stessa finestra OOS
print("\n MULTI-CUT (pesi ri-ottimizzati a ogni cut sul solo pre-cut; ΔSh OOS vs CURRENT):")
header = f" {'criterio':<10s}" + "".join(f" | {c.date()} Sh_oos ΔSh " for c in CUTS)
print(header)
persist = {}
fixed = {"EW": candidates["EW"], "EW-STR": candidates["EW-STR"]}
for cname, fn in CRITERIA + [("EW", None), ("EW-STR", None)]:
cells = []
ok = True
for cut in CUTS:
J_is, J_oos = J[J.index < cut], J[J.index >= cut]
w = fixed[cname] if cname in fixed else fn(J_is, w_cur)
sh_o = met(combo(J_oos, w))["sh"]
sh_c = met(combo(J_oos, w_cur))["sh"]
d = sh_o - sh_c
ok &= d > 0
cells.append(f" | {sh_o:>10.2f} {d:>+5.2f}")
persist[cname] = ok
print(f" {cname:<10s}" + "".join(cells) + (" <-- PERSISTENTE" if ok else ""))
print(" ⚠ caveat: le 3 finestre OOS sono ANNIDATE (tutte contengono il 2025-26) — non 3 conferme")
print(" indipendenti. Il test disgiunto vero e' il per-anno qui sotto.")
# per-anno (finestre DISGIUNTE): ritorno annuo del candidato vs CURRENT, pesi fissi pre-2025
print("\n PER-ANNO (pesi headline fissi; Δret vs CURRENT, finestre disgiunte):")
sel = ["MAXSH", "MINVAR-R", "MAXSH-STR", "EW", "EW-STR"]
years = sorted(set(J.index.year))
print(" " + "anno".ljust(6) + "".join(f"{c:>12s}" for c in ["CURRENT"] + sel))
wins = {c: 0 for c in sel}
for y in years:
Jy = J[J.index.year == y]
base_ret = float(np.prod(1 + combo(Jy, w_cur).values) - 1)
row = f" {y} {base_ret*100:>+10.1f}%"
for c in sel:
ry = float(np.prod(1 + combo(Jy, candidates[c]).values) - 1)
wins[c] += ry > base_ret
row += f"{(ry-base_ret)*100:>+11.1f}p"
print(row)
print(" anni vinti vs CURRENT: " + " ".join(f"{c} {wins[c]}/{len(years)}" for c in sel))
# verdetto: la persistenza statistica NON basta — i pesi devono rispettare i cap STRUTTURALI
# (VRP01<=15% perche' MODELLATO, XS01<=25% perche' STAT-MODE/storia corta), altrimenti l'uplift
# sta comprando rischio-modello che il backtest non vede (punto cieco tipo CC01).
winners = [c for c, ok in persist.items() if ok]
respects = {c: bool(np.all(candidates[c] <= caps + 1e-3)) for c in candidates} # tol 0.1pp (jitter clip/renorm)
struct_winners = [c for c in winners if respects[c]]
print("\n cap strutturali: " + fmt_w(names, caps))
if winners:
print(" persistenti: " + ", ".join(f"{c} ({'dentro i cap' if respects[c] else 'VIOLA i cap'})"
for c in winners))
if struct_winners:
wbest = candidates[struct_winners[0]]
fb, fc = met(combo(J, wbest)), met(combo(J, w_cur))
hb, hc = met(combo(J[J.index >= HOLDOUT], wbest)), met(combo(J[J.index >= HOLDOUT], w_cur))
verdict = (f"MIGLIORA (OOS) — {struct_winners} persistente E dentro i cap strutturali. "
f"Pesi candidati ({struct_winners[0]}): {fmt_w(names, wbest)}.\n"
f" TRADE-OFF onesto: HOLD Sh {hc['sh']:.2f}->{hb['sh']:.2f}, HOLD CAGR "
f"{hc['cagr']*100:+.1f}%->{hb['cagr']*100:+.1f}%, MA FULL Sh {fc['sh']:.2f}->{fb['sh']:.2f} "
f"e FULL DD {fc['dd']*100:.1f}%->{fb['dd']*100:.1f}% — l'uplift e' un TILT di ritorno verso "
f"gli sleeve research-grade (piu' SKH01/XS01, meno TP01), non un free lunch risk-adjusted. "
f"NB: nessun OTTIMIZZATORE aggiunge nulla oltre questo null parameter-free "
f"(MAXSH in-sample vince 1/8 anni: l'ottimizzazione qui overfitta, la de-concentrazione no).")
elif winners:
verdict = ("NON ADOTTABILE — l'uplift persistente esiste SOLO violando i cap strutturali "
"(tutti i vincitori caricano VRP01>15%, sleeve MODELLATO il cui rischio di coda "
"il backtest non cattura). Dentro i cap l'ottimizzatore converge ~ai pesi correnti "
"e non e' persistente -> per il book reale I PESI CORRENTI REGGONO.")
else:
verdict = "NON MIGLIORA — nessun criterio batte i pesi CORRENTI in modo persistente. I pesi correnti reggono."
print("\n VERDETTO (A): " + verdict)
return candidates, persist, struct_winners
def section_B(J, names, w_cur, label="CURRENT"):
print("\n" + "=" * 108)
print(f" (B) GUARDIA-DRAWDOWN a livello portafoglio (pesi {label}) — trigger X, de-risk d, isteresi 0.4X")
print("=" * 108)
r_full = combo(J, w_cur)
# griglia completa su FULL/IS/HOLD per trasparenza (selezione = solo in-sample)
r_is = r_full[r_full.index < HOLDOUT]
r_ho = r_full[r_full.index >= HOLDOUT]
b_is, b_ho, b_f = met(r_is), met(r_ho), met(r_full)
print(f"\n baseline IS Sh {b_is['sh']:.2f} DD {b_is['dd']*100:.1f}% CAGR {b_is['cagr']*100:+.1f}%"
f" | HOLD Sh {b_ho['sh']:.2f} DD {b_ho['dd']*100:.1f}% CAGR {b_ho['cagr']*100:+.1f}%"
f" | FULL Sh {b_f['sh']:.2f} DD {b_f['dd']*100:.1f}%")
print(f"\n {'cella':<16s} {'IS Sh':>6s} {'IS DD':>6s} {'IS CAGR':>8s} {'IS Calmar':>9s} {'%off':>5s} |"
f" {'HOLD Sh':>7s} {'HOLD DD':>7s} {'HOLD CAGR':>9s}")
grid = {}
for trig in GUARD_TRIGGERS:
for dr in GUARD_DERISK:
g_full, expo = dd_guard(r_full, trig, dr)
g_is = g_full[g_full.index < HOLDOUT]; g_ho = g_full[g_full.index >= HOLDOUT]
mi, mh = met(g_is), met(g_ho)
off = float((expo[: len(r_is)] < 1.0).mean())
grid[(trig, dr)] = dict(is_=mi, ho=mh, full=met(g_full))
print(f" X={trig*100:.0f}% d={dr:<4.1f} {mi['sh']:>6.2f} {mi['dd']*100:>5.1f}% {mi['cagr']*100:>+7.1f}%"
f" {mi['calmar']:>9.2f} {off*100:>4.0f}% | {mh['sh']:>7.2f} {mh['dd']*100:>6.1f}% {mh['cagr']*100:>+8.1f}%")
# selezione IN-SAMPLE (Calmar), poi null de-levering a pari MaxDD in-sample
best_cell = max(grid, key=lambda k: grid[k]["is_"]["calmar"])
trig, dr = best_cell
print(f"\n cella selezionata IN-SAMPLE (max Calmar): X={trig*100:.0f}% derisk={dr}")
tgt_dd = grid[best_cell]["is_"]["dd"]
c = const_expo_match_dd(r_is, tgt_dd)
cnull_full = pd.Series(c * r_full.values, index=r_full.index)
n_is, n_ho = met(cnull_full[cnull_full.index < HOLDOUT]), met(cnull_full[cnull_full.index >= HOLDOUT])
g = grid[best_cell]
print(f" NULL de-levering: expo costante c={c:.2f} pareggia il MaxDD IS della guardia ({tgt_dd*100:.1f}%)")
print(f" {'':<14s} {'IS Sh':>6s} {'IS CAGR':>8s} | {'HOLD Sh':>7s} {'HOLD DD':>7s} {'HOLD CAGR':>9s}")
print(f" guardia {g['is_']['sh']:>6.2f} {g['is_']['cagr']*100:>+7.1f}% | {g['ho']['sh']:>7.2f}"
f" {g['ho']['dd']*100:>6.1f}% {g['ho']['cagr']*100:>+8.1f}%")
print(f" const c={c:.2f} {n_is['sh']:>6.2f} {n_is['cagr']*100:>+7.1f}% | {n_ho['sh']:>7.2f}"
f" {n_ho['dd']*100:>6.1f}% {n_ho['cagr']*100:>+8.1f}%")
# multi-cut: selezione sul pre-cut, valutazione OOS post-cut (guardia vs baseline vs null)
inert = True
print("\n MULTI-CUT (cella riscelta a ogni cut sul solo pre-cut; OOS post-cut):")
print(f" {'cut':<12s} {'cella':<14s} {'OOS Sh(g)':>9s} {'OOS Sh(b)':>9s} {'ΔSh':>6s} {'OOS DD(g)':>9s}"
f" {'OOS DD(b)':>9s} {'ΔCAGR':>7s} {'null c: ΔSh':>11s}")
persist_sh, persist_dd = True, True
for cut in CUTS:
r_pre = r_full[r_full.index < cut]
cells = {}
for t in GUARD_TRIGGERS:
for d in GUARD_DERISK:
gpre, _ = dd_guard(r_pre, t, d)
cells[(t, d)] = met(gpre)["calmar"]
(t_, d_) = max(cells, key=cells.get)
g_full, _ = dd_guard(r_full, t_, d_) # guard sul path completo (stato causale)
g_oos = g_full[g_full.index >= cut]
b_oos = r_full[r_full.index >= cut]
gm, bm = met(g_oos), met(b_oos)
cc = const_expo_match_dd(r_pre, met(dd_guard(r_pre, t_, d_)[0])["dd"])
nm = met(pd.Series(cc * b_oos.values, index=b_oos.index))
dsh = gm["sh"] - bm["sh"]; ddd = gm["dd"] - bm["dd"]; dcagr = gm["cagr"] - bm["cagr"]
persist_sh &= dsh >= 0; persist_dd &= ddd < 0
inert &= abs(dsh) < 0.005 and abs(ddd) < 1e-4
print(f" {str(cut.date()):<12s} X={t_*100:.0f}% d={d_:<6.1f} {gm['sh']:>9.2f} {bm['sh']:>9.2f} {dsh:>+6.2f}"
f" {gm['dd']*100:>8.1f}% {bm['dd']*100:>8.1f}% {dcagr*100:>+6.1f}pp {gm['sh']-nm['sh']:>+11.2f}")
if inert:
verdict = ("NON MIGLIORA — INERTE OOS: la cella scelta in-sample non scatta MAI post-cut "
"(il DD OOS del 4-sleeve resta sotto il trigger: la diversificazione fa gia' il lavoro "
"della guardia). Trigger piu' stretti (X=3-4%) scattano ma COSTANO Sharpe su HOLD. "
"Coerente col thread meta-allocazione 2026-06-29 (drawdown-control ridondante).")
elif persist_sh and persist_dd:
verdict = f"MIGLIORA — la guardia X={trig*100:.0f}%/d={dr} taglia il DD OOS senza costo Sharpe persistente"
elif persist_dd:
verdict = ("TAGLIA IL DD MA COSTA — DD ridotto a ogni cut ma Sharpe/CAGR inferiori: "
"trade-off assicurativo, confrontare col null de-levering prima di adottarla")
else:
verdict = "NON MIGLIORA — la guardia non riduce il DD OOS in modo persistente (o e' solo de-levering)"
print("\n VERDETTO (B): " + verdict)
return grid, best_cell
def section_C(J, names, w_cur, candidates, struct_winners, grid, best_cell):
print("\n" + "=" * 108)
print(" (C) COMBINAZIONE A+B")
print("=" * 108)
wA = candidates[struct_winners[0]] if struct_winners else w_cur
labA = struct_winners[0] if struct_winners else "CURRENT (A non adottabile)"
trig, dr = best_cell
r = combo(J, wA)
g, _ = dd_guard(r, trig, dr)
for lbl, s in [("baseline CURRENT", combo(J, w_cur)),
(f"pesi {labA}", r),
(f"pesi {labA} + guard X={trig*100:.0f}%/d={dr}", g)]:
f = met(s); h = met(s[s.index >= HOLDOUT])
print(f" {lbl:<52s} FULL Sh {f['sh']:>5.2f} DD {f['dd']*100:>4.1f}% CAGR {f['cagr']*100:>+5.1f}%"
f" | HOLD Sh {h['sh']:>5.2f} DD {h['dd']*100:>4.1f}% CAGR {h['cagr']*100:>+5.1f}%")
if not struct_winners:
print(" -> (A) non adottabile e (B) inerte OOS: la combinazione non ha una cella da proporre.")
def main():
print("=" * 108)
print(" r0701_portfolio_opt — pesi statici + guardia-DD sul portafoglio attivo a 4 sleeve")
print(f" hold-out bloccato {HOLDOUT.date()}+ | vincoli pesi [{LO_W},{HI_W}] | cuts {[str(c.date()) for c in CUTS]}")
print("=" * 108)
J, names, w_cur = load_matrix()
print(f"\n sleeve: {names}")
print(f" pesi CORRENTI: {fmt_w(names, w_cur)}")
print(f" finestra: {J.index[0].date()} -> {J.index[-1].date()} ({len(J)} giorni)")
for n in names:
s = J[n].dropna()
print(f" {n:<16s} dal {s.index[0].date()} ({len(s)} giorni)")
b_f = met(combo(J, w_cur)); b_h = met(combo(J[J.index >= HOLDOUT], w_cur))
print(f"\n BASELINE (pesi correnti): FULL Sh {b_f['sh']:.2f} DD {b_f['dd']*100:.1f}% CAGR {b_f['cagr']*100:+.1f}%"
f" | HOLD Sh {b_h['sh']:.2f} DD {b_h['dd']*100:.1f}% CAGR {b_h['cagr']*100:+.1f}%")
candidates, persistA, struct_winners = section_A(J, names, w_cur)
grid, best_cell = section_B(J, names, w_cur)
section_C(J, names, w_cur, candidates, struct_winners, grid, best_cell)
if __name__ == "__main__":
main()