#!/usr/bin/env python """r0807_best_strategy.py — a N anni, qual e' la scelta migliore fra book, ETF e un mix? DOMANDA DELL'OPERATORE, dopo il confronto book/S&P500/MSCI World (r0807_asset_compare.py). ⚠️ VINCOLO STRUTTURALE, scoperto sbagliando: **un mix esiste solo dove esistono ENTRAMBE le serie**. La prima stesura confrontava «storia piena» contro «stessa finestra» come due lenti alternative, ma calcolava i bersagli del mix sull'intersezione in tutti e due i casi — quindi la lente «storia piena» dichiarava trent'anni e ne usava sette (per w=0 dava rendita 8.63% invece del 4.16% misurato sui 30 anni veri). Il confronto fra finestre NON e' disponibile per un portafoglio misto, e fingere che lo sia produce numeri incoerenti dentro la stessa riga. L'IMPIANTO CORRETTO. Una sola finestra — quella comune, l'unica dove il mix e' definito — e la diversita' di scenario espressa come **spostamento del drift**, che si puo' applicare a entrambi i lati in modo simmetrico. Quattro scenari: base drift misurati sulla finestra comune equity lungo S&P 500 riportato al suo drift MISURATO su 30 anni; book invariato book dimezzato book a meta' drift; S&P 500 sulla finestra comune entrambi cauti S&P 500 a 30 anni E book a meta' ⚠️ L'asimmetria fra i due stress E' il risultato, non un difetto: lo scenario prudente sull'equity e' MISURATO (trent'anni di storia esistono), quello sul book e' GIUDIZIALE (7.4 anni sono tutta la storia che ha — non c'e' nessun dato con cui stressarlo). Si puo' sbagliare la taglia dello stress sul book; non si puo' toglierlo. In ogni scenario la VOLATILITA' e la forma del rischio restano quelle vere: si sposta solo il rendimento medio, come gia' fa la correzione d'ancora del progetto (`r - (1-f)*mean(r)`). MODELLO DEL MIX, dichiarato: i versamenti si dividono per peso fisso e i due salvadanai crescono SEPARATI, senza ribilanciamento (ribilanciare vorrebbe dire vendere e realizzare, e i due lati hanno regimi fiscali diversi: book 33% ogni anno, ETF 26% alla vendita). Il bersaglio del mix usa la serie a pesi fissi ribilanciata giornalmente, che e' il portafoglio della fase di PRELIEVO. uv run python scripts/research/r0807_best_strategy.py [--anni 12] """ from __future__ import annotations import argparse import json 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)) sys.path.insert(0, str(ROOT / "scripts" / "research")) import r0725_capcurve as CC # noqa: E402 import r0726_capwall_refresh as WR # noqa: E402 import r0727_lumpsum_split as LS # noqa: E402 import r0807_asset_compare as AC # noqa: E402 PESI = [0.0, 0.25, 0.50, 0.75, 1.00] # quota sul BOOK; il resto sull'ETF S&P 500 BOOK_HAIRCUT = 0.5 # scenario prudente sul book: meta' drift (GIUDIZIALE) N_PATHS = 4000 def set_drift(r: pd.Series, annuo: float) -> pd.Series: """Sposta il drift di `r` al valore annuo dato, lasciando INTATTA la volatilita'.""" return r - float(r.mean()) + annuo / 365.0 def perp_e_bersaglio(serie: np.ndarray, aliquota: float) -> tuple[float, float]: lordo = CC.TARGET_EUR_DAY * 365 * CC.EURUSD / (1 - aliquota) _, perp, cap = WR.perp_and_wall(serie, 1.0, lordo) return perp, float(cap) def accumulo_mix(rb: np.ndarray, re_: np.ndarray, w: float, lump: float, dep: float, anni: int) -> dict: """Due salvadanai separati, ciascuno col proprio fisco e i propri costi.""" parti = [] for r, quota, differita, aliq, comm in ( (rb, w, False, AC.TAX_CRYPTO, 0.0), (re_, 1 - w, True, AC.TAX_ETF, AC.COMM_EUR)): if quota <= 0: continue parti.append(AC.accumula(r, lump * quota, dep * quota, anni, differita=differita, aliquota=aliq, comm_eur=comm)) n = max(len(p) for p in parti) serie = [dict(anno=i + 1, med=sum(p[i]["med"] for p in parti), versato=sum(p[i]["versato"] for p in parti)) for i in range(n)] L = {k: sum(p[-1][k] for p in parti) for k in ("med", "p25", "p75", "versato")} # ⚠️ il conto Deribit di partenza ($600) e' dentro OGNI parte: con due salvadanai lo si # conterebbe due volte -> si toglie una volta per ogni parte oltre la prima extra = LS.START * (len(parti) - 1) for k in ("med", "p25", "p75", "versato"): L[k] -= extra for s in serie: s["med"] -= extra s["versato"] -= extra return dict(serie=serie, **{k: float(v) for k, v in L.items()}) def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--anni", type=int, default=12) ap.add_argument("--lump", type=float, default=5000.0) ap.add_argument("--dep", type=float, default=500.0) ap.add_argument("--json", type=str, default="") a = ap.parse_args() S = AC.carica_serie() rb_full, re_full = S["BOOK"], S["SP500"] lo = max(rb_full.index[0], re_full.index[0]) hi = min(rb_full.index[-1], re_full.index[-1]) j = pd.concat({"b": rb_full.loc[lo:hi], "e": re_full.loc[lo:hi]}, axis=1, sort=True).dropna() rb, re_ = j["b"], j["e"] d_book = float(rb.mean()) * 365 d_eq_com = float(re_.mean()) * 365 d_eq_30 = float(re_full.mean()) * 365 print("=" * 104) print(f" r0807 — QUALE STRATEGIA A {a.anni} ANNI: book, ETF, o un mix?") print("=" * 104) print(f" piano ${LS.START:,.0f} + €{a.lump:,.0f} + €{a.dep:,.0f}/mese · {N_PATHS:,} path") print(f" finestra unica {lo.date()} → {hi.date()} ({len(j)} giorni): e' l'unica dove un MIX " f"e' definito") print(f" drift misurati: book {d_book:.1%} · S&P 500 su questa finestra {d_eq_com:.1%} · " f"S&P 500 su {len(re_full)/365:.0f} anni {d_eq_30:.1%}") # correlazione (solo giorni di borsa aperta: gli zeri del weekend la diluiscono) ap_ = j[j["e"] != 0] giu = ap_[ap_["e"] < 0] coda = ap_[ap_["e"] <= ap_["e"].quantile(0.05)] print(f"\n CORRELAZIONE book↔S&P (giorni di borsa aperta): {float(ap_['b'].corr(ap_['e'])):+.3f}" f" · nei giorni di ribasso {float(giu['b'].corr(giu['e'])):+.3f}") print(f" nel 5% di giorni peggiori dell'indice (medio {float(coda['e'].mean()):+.2%}) " f"il book fa {float(coda['b'].mean()):+.2%}") scenari = { "base": (rb, re_), "equity lungo": (rb, set_drift(re_, d_eq_30)), "book dimezzato": (set_drift(rb, d_book * BOOK_HAIRCUT), re_), "entrambi cauti": (set_drift(rb, d_book * BOOK_HAIRCUT), set_drift(re_, d_eq_30)), } ris = {} for nome, (sb, se) in scenari.items(): print(f"\n SCENARIO «{nome}» — book {float(sb.mean())*365:.1%} · " f"S&P {float(se.mean())*365:.1%} (volatilita' invariate: " f"{float(sb.std())*np.sqrt(365):.1%} · {float(se.std())*np.sqrt(365):.1%})") print(f" {'w book':>8}{'rendita perp.':>15}{'bersaglio':>13}{'capitale':>13}" f"{'p25':>12}{'p75':>12}{'quota':>8}") riga = {} for w in PESI: mix = w * sb + (1 - w) * se aliq = w * AC.TAX_CRYPTO + (1 - w) * AC.TAX_ETF perp, cap = perp_e_bersaglio(mix.values.astype(float), aliq) acc = accumulo_mix(sb.values.astype(float), se.values.astype(float), w, a.lump, a.dep, a.anni) q = acc["med"] / cap if np.isfinite(cap) and cap > 0 else float("nan") riga[w] = dict(perp=perp, cap=cap, aliquota=aliq, quota=float(q), med=acc["med"], p25=acc["p25"], p75=acc["p75"], versato=acc["versato"], serie=acc["serie"]) cs = f"${cap:,.0f}" if np.isfinite(cap) else "n/d" print(f" {w:>7.0%}{perp:>14.2%}{cs:>13}{acc['med']:>12,.0f}$" f"{acc['p25']:>11,.0f}${acc['p75']:>11,.0f}${q:>8.0%}") ris[nome] = riga # ------------------------------------------------------------------ decisione nomi = list(scenari) print(f"\n DECISIONE — quota del bersaglio in ogni scenario") print(f" {'w book':>8}" + "".join(f"{n:>17}" for n in nomi) + f"{'peggiore':>11}") peggiori, rimpianti = {}, {} for w in PESI: qs = [ris[n][w]["quota"] for n in nomi] peggiori[w] = min(qs) print(f" {w:>7.0%}" + "".join(f"{q:>17.0%}" for q in qs) + f"{min(qs):>11.0%}") # rimpianto massimo: quanto si perde, in ogni scenario, rispetto alla scelta che li sarebbe # stata la migliore. Criterio piu' informativo del solo caso peggiore. for w in PESI: rimpianti[w] = max(max(ris[n][v]["quota"] for v in PESI) - ris[n][w]["quota"] for n in nomi) print(f"\n {'w book':>8}{'caso peggiore':>16}{'rimpianto massimo':>20}") for w in PESI: print(f" {w:>7.0%}{peggiori[w]:>16.0%}{rimpianti[w]:>20.0%}") b_max = max(peggiori, key=peggiori.get) b_reg = min(rimpianti, key=rimpianti.get) print(f"\n massimizza il caso peggiore : w = {b_max:.0%} sul book ({peggiori[b_max]:.0%})") print(f" minimizza il rimpianto : w = {b_reg:.0%} sul book ({rimpianti[b_reg]:.0%})") if a.json: Path(a.json).write_text(json.dumps(dict( piano=dict(anni=a.anni, lump=a.lump, dep=a.dep, start=LS.START, eurusd=CC.EURUSD, n_paths=N_PATHS, pesi=PESI, book_haircut=BOOK_HAIRCUT, tax_crypto=AC.TAX_CRYPTO, tax_etf=AC.TAX_ETF, comm=AC.COMM_EUR), finestra=dict(da=str(lo.date()), a=str(hi.date()), giorni=len(j)), drift=dict(book=d_book, eq_comune=d_eq_com, eq_30=d_eq_30), correlazione=dict(aperta=float(ap_["b"].corr(ap_["e"])), ribasso=float(giu["b"].corr(giu["e"])), coda_indice=float(coda["e"].mean()), coda_book=float(coda["b"].mean())), scenari={n: {str(w): ris[n][w] for w in PESI} for n in nomi}, peggiori={str(w): peggiori[w] for w in PESI}, rimpianti={str(w): rimpianti[w] for w in PESI}, minimax=b_max, minregret=b_reg), indent=1)) print(f"\n scritto {a.json}") if __name__ == "__main__": main()