diff --git a/scripts/research/r0828_senza_gtaa.py b/scripts/research/r0828_senza_gtaa.py new file mode 100644 index 0000000..b3ccf53 --- /dev/null +++ b/scripts/research/r0828_senza_gtaa.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python +"""r0828_senza_gtaa.py — cosa cambia nel portafoglio di RICERCA se si toglie GTAA01. + +PERCHE'. Il 28/08 si e' misurato che il gate (A) della banda GTAA01 non ha risoluzione, e che +lo sleeve gira su una finestra che rotola da sola (r0828_gtaa_band_phase). Prima di decidere +cosa fare del gate conviene sapere quanto pesa lo sleeve: se togliendolo non cambia niente, la +domanda sul gate e' accademica. + +⚠️ QUESTO NON E' IL LIBRO LIVE. Il book che gira e' TP01+SKH01 75/25 su Deribit: GTAA01 non c'e'. +Qui si guarda il portafoglio a 5 sleeve di `sleeves.active_sleeves` (33/15/12/20/20), che e' la +serie su cui stanno quasi tutti i numeri di portafoglio del progetto. + +LE DUE LENTI, ed e' la seconda quella che decide (M6: un diversificatore a BASSO CAGR si giudica +a iso-rischio, mai a iso-nozionale; M5: ogni claim "meno drawdown" si testa a iso-rischio prima +di crederci, o si sta comprando de-levering e chiamandolo diversificazione): + · ISO-NOZIONALE — si toglie GTAA01 e si ridistribuisce il suo 20% agli altri quattro in + proporzione. E' cio' che succede materialmente. + · ISO-RISCHIO — la stessa serie, riscalata alla volatilita' del portafoglio a 5. Risponde alla + domanda giusta: a parita' di rischio preso, quanto rende in piu' o in meno? + +Si stampano FULL e HOLD-OUT (la data e' quella del progetto, `portfolio.HOLDOUT`), il per-anno +(M9: un contributo si scompone per anno prima di crederci) e la correlazione di GTAA01 col resto. +Verdetto calcolato a runtime coi criteri dichiarati qui sotto. + +CRITERIO, dichiarato prima: GTAA01 "porta qualcosa" se, A ISO-RISCHIO, toglierlo peggiora lo +Sharpe FULL di almeno SOGLIA_SHARPE **e** l'hold-out non lo smentisce. Sotto quella soglia il +contributo e' dentro lo spread fra le tre baseline che il progetto gia' si porta dietro (0.12 di +Sharpe, §2 del CLAUDE.md), quindi non e' distinguibile dalla scelta della lente. + + uv run python scripts/research/r0828_senza_gtaa.py +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from src.portfolio.portfolio import HOLDOUT, combine_outer, metrics, to_daily, yearly # noqa: E402 +from src.portfolio.sleeves import active_sleeves # noqa: E402 + +SOGLIA_SHARPE = 0.12 # spread fra le tre baseline "libro 75/25" (§2): sotto, non si vede +FUORI = "GTAA01_eq_trend" + + +def serie_sleeve() -> tuple[dict, dict]: + cols, w = {}, {} + for s in active_sleeves(): + cols[s.name] = s.daily().dropna() + w[s.name] = float(s.weight) + return cols, w + + +def senza(w: dict, fuori: str) -> dict: + """I pesi degli altri, rinormalizzati: il 20% di GTAA01 va agli altri in proporzione.""" + resto = {k: v for k, v in w.items() if k != fuori} + tot = sum(resto.values()) + return {k: v / tot for k, v in resto.items()} + + +def riga(nome: str, m: dict) -> str: + return (f" {nome:<22} Sh {m['sharpe']:>5.2f} CAGR {m['cagr']*100:>+6.1f}% " + f"maxDD {m['maxdd']*100:>5.1f}% vol {m.get('vol', float('nan'))*100:>5.1f}% n {m['n']}") + + +def con_vol(s: pd.Series) -> dict: + m = metrics(s) + m["vol"] = float(np.asarray(s.dropna().values, float).std() * np.sqrt(365.0)) + return m + + +def main() -> int: + print(__doc__.split("\n\n")[0]) + cols, w5 = serie_sleeve() + if FUORI not in cols: + print(f"!! {FUORI} non e' fra gli sleeve attivi: niente da togliere") + return 2 + w4 = senza(w5, FUORI) + cols4 = {k: v for k, v in cols.items() if k != FUORI} + + print(f"\n pesi CON : " + " ".join(f"{k.split('_')[0]} {v:.0%}" for k, v in w5.items())) + print(f" pesi SENZA: " + " ".join(f"{k.split('_')[0]} {v:.0%}" for k, v in w4.items())) + + con = combine_outer(cols, w5) + sen = combine_outer(cols4, w4) + # allineate sullo STESSO calendario: senza questo si confronterebbero due finestre diverse + J = pd.concat({"con": con, "senza": sen}, axis=1, join="inner").dropna() + con, sen = J["con"], J["senza"] + + m_con, m_sen = con_vol(con), con_vol(sen) + k = m_con["vol"] / m_sen["vol"] if m_sen["vol"] > 0 else 1.0 + sen_iso = sen * k + m_iso = con_vol(sen_iso) + + print("\n" + "=" * 96) + print(f" FULL ({con.index[0].date()} -> {con.index[-1].date()})") + print("=" * 96) + print(riga("CON GTAA01", m_con)) + print(riga("SENZA (iso-nozionale)", m_sen)) + print(riga(f"SENZA (iso-rischio ×{k:.2f})", m_iso)) + d_full = m_con["sharpe"] - m_iso["sharpe"] + print(f"\n Δ Sharpe a iso-rischio (con − senza): {d_full:+.3f} " + f"[soglia dichiarata {SOGLIA_SHARPE}]") + print(f" Δ maxDD a iso-rischio: {(m_con['maxdd']-m_iso['maxdd'])*100:+.2f} pp") + + print("\n" + "=" * 96) + print(f" HOLD-OUT ({HOLDOUT.date()}+)") + print("=" * 96) + hc, hs = con[con.index >= HOLDOUT], sen[sen.index >= HOLDOUT] + mhc, mhs = con_vol(hc), con_vol(hs) + kh = mhc["vol"] / mhs["vol"] if mhs["vol"] > 0 else 1.0 + mhi = con_vol(hs * kh) + print(riga("CON GTAA01", mhc)) + print(riga(f"SENZA (iso-rischio ×{kh:.2f})", mhi)) + d_hold = mhc["sharpe"] - mhi["sharpe"] + print(f"\n Δ Sharpe a iso-rischio: {d_hold:+.3f}") + + print("\n" + "=" * 96) + print(" PER ANNO — un contributo positivo si scompone prima di crederci (M9)") + print("=" * 96) + yc, yi = yearly(con), yearly(sen * k) + print(f" {'anno':<6} {'CON ret':>9} {'SENZA ret':>10} {'Δ ret':>8} {'CON DD':>7} {'SENZA DD':>9}") + pos = 0 + for a in sorted(set(yc) & set(yi)): + d = yc[a]["ret"] - yi[a]["ret"] + pos += d > 0 + print(f" {a:<6} {yc[a]['ret']*100:>+8.1f}% {yi[a]['ret']*100:>+9.1f}% {d*100:>+7.1f}pp " + f"{yc[a]['dd']*100:>6.1f}% {yi[a]['dd']*100:>8.1f}%") + n_anni = len(set(yc) & set(yi)) + print(f"\n anni in cui GTAA01 aiuta: {pos}/{n_anni}") + + print("\n" + "=" * 96) + print(" CORRELAZIONE — quanto e' davvero 'altro' rispetto al resto") + print("=" * 96) + g = cols[FUORI] + altro = combine_outer(cols4, w4) + Jg = pd.concat({"g": g, "altro": altro}, axis=1, join="inner").dropna() + print(f" GTAA01 vs resto del portafoglio: corr {Jg['g'].corr(Jg['altro']):+.3f} " + f"({len(Jg)} giorni in comune)") + print(f" GTAA01 standalone: " + riga("", con_vol(g)).strip()) + + # ---------------------------------------------------------------- la banda di FASE sul Δ + print("\n" + "=" * 96) + print(" LA FASCIA DI FASE SUL Δ — il Δ e' una misura, o una proprieta' della notte?") + print("=" * 96) + print(" GTAA01 ribilancia su `i % 5` a partire dalla PRIMA barra del file, e la prima barra") + print(" di SPY scivola ogni notte (finestra IB rotolante a 30 anni: r0828_gtaa_band_phase).") + print(" Quindi il Δ qui sopra va letto accanto alla sua escursione sulle 5 fasi.") + sys.path.insert(0, str(ROOT / "scripts" / "research")) + try: + from r0726_loo_deluck import gtaa01_at # replica ESATTA dello sleeve, per fase + except Exception as e: # noqa: BLE001 + print(f" non misurabile qui: {type(e).__name__}: {e}") + gtaa01_at = None + if gtaa01_at is not None: + base = gtaa01_at(0) + j0 = pd.concat({"a": base, "b": cols[FUORI]}, axis=1, join="inner").dropna() + d0 = float((j0["a"] - j0["b"]).abs().max()) + print(f"\n [SANITY] fase 0 == sleeve di produzione: max|Δ| = {d0:.2e} " + f"-> {'OK' if d0 < 1e-12 else 'DIVERGE (i numeri sotto non valgono)'}") + deltas = [] + for ph in range(5): + c = dict(cols); c[FUORI] = gtaa01_at(ph) + cc = combine_outer(c, w5) + Jp = pd.concat({"con": cc, "senza": sen}, axis=1, join="inner").dropna() + mc = con_vol(Jp["con"]) + ms = con_vol(Jp["senza"]) + kk = mc["vol"] / ms["vol"] if ms["vol"] > 0 else 1.0 + d = mc["sharpe"] - con_vol(Jp["senza"] * kk)["sharpe"] + deltas.append(d) + print(f" fase {ph}: Sharpe CON {mc['sharpe']:.3f} Δ a iso-rischio {d:+.3f}" + + (" <- la fase che gira stanotte" if ph == 0 else "")) + lo, hi = min(deltas), max(deltas) + print(f"\n Δ su 5 fasi: da {lo:+.3f} a {hi:+.3f} (ampiezza {hi-lo:.3f}, " + f"mediana {float(np.median(deltas)):+.3f})") + sopra = sum(d >= SOGLIA_SHARPE for d in deltas) + print(f" fasi in cui il Δ supera la soglia {SOGLIA_SHARPE}: {sopra}/5") + if 0 < sopra < 5: + print(" ⚠️ IL VERDETTO DIPENDE DALLA FASE: non e' una proprieta' di GTAA01,") + print(" e' una proprieta' della notte in cui lo si legge.") + + print("\n" + "=" * 96) + print(" VERDETTO — criterio dichiarato in testa, calcolato adesso") + print("=" * 96) + porta = d_full >= SOGLIA_SHARPE and d_hold > -SOGLIA_SHARPE + print(f" Δ Sharpe FULL a iso-rischio {d_full:+.3f} >= {SOGLIA_SHARPE}? " + f"{'SI' if d_full >= SOGLIA_SHARPE else 'NO'}") + print(f" l'hold-out non lo smentisce (Δ {d_hold:+.3f} > {-SOGLIA_SHARPE})? " + f"{'SI' if d_hold > -SOGLIA_SHARPE else 'NO'}") + if porta: + print("\n -> GTAA01 PORTA: toglierlo costa piu' dello spread fra le baseline.") + return 0 + print("\n -> il contributo di GTAA01 NON si distingue dalla scelta della lente.") + print(" Non e' 'GTAA01 e' inutile': e' 'con questi dati non si vede'. E cio' che") + print(" non si vede non giustifica di tenere in piedi un gate che non decide.") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main())