From 56141b0ffe59ae1ef5bbf3d9cd85cf4c6eb5283d Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Fri, 7 Aug 2026 18:43:57 +0000 Subject: [PATCH] =?UTF-8?q?research(portafoglio):=20book=20vs=20ETF=20?= =?UTF-8?q?=E2=80=94=20le=20due=20lenti=20si=20contraddicono,=20la=20scelt?= =?UTF-8?q?a=20robusta=20e'=2050/50?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r0807_asset_compare.py (book / S&P 500 / MSCI World sullo stesso piano) e r0807_best_strategy.py (quale peso scegliere a 12 anni). Da "e se mettessi in MSCI World o ETF SP500?" e "quale e' la miglior strategia?". Tre cose rese comparabili: la GRIGLIA (azioni su calendario con 0.0 a borsa chiusa = convenzione GTAA01; senza, Sharpe x1.20), il FISCO (book realizza ogni anno al 33%, UCITS ad accumulazione paga il 26% alla vendita -> le curve ETF sono valori di liquidazione: il differimento e' un vantaggio strutturale dell'ETF e va nel modello), il BERSAGLIO (272.061$ vale per la rendita perpetua DEL BOOK e per il 33% -> ricalcolato per ciascuno). Il risultato non e' chi vince, e' che le due lenti si contraddicono: sulla storia piena il book arriva al 115% del proprio bersaglio e l'S&P al 32%; sulla stessa finestra 118% e 110%, e l'S&P ACCUMULA PIU' del book. Il divario e' tutto nei crolli 2000/2008 che la strategia non ha mai vissuto. E sulla stessa finestra il rendimento e' quasi identico (17.4% vs 16.8%): la differenza e' tutta nel rischio (vol 11.0 vs 19.6%, maxDD 10.5 vs 33.7%). Il book non guadagna di piu', perde di meno — conferma indipendente di cio' che il progetto scrive di TP01 dal 19/06, contro un'alternativa vera. Difetto corretto in sessione: un MIX esiste solo dove esistono ENTRAMBE le serie. La prima stesura confrontava "storia piena" contro "stessa finestra" ma calcolava i bersagli del mix sull'intersezione in tutti e due i casi -> dichiarava 30 anni e ne usava 7 (per l'ETF puro: rendita 8.63% invece del 4.16% vero). Rifatto su una finestra sola con gli scenari come spostamento del drift, simmetrico sui due lati. A 12 anni vince 50/50 sul RIMPIANTO massimo (20% contro 52% del book puro e 53% dell'ETF puro); il criterio del solo caso peggiore non distingue 25% da 50% (27.09 vs 26.99% = pareggio nel rumore). L'asimmetria fra i due stress e' il risultato: quello sull'equity e' misurato (30 anni esistono), quello sul book e' giudiziale (7.4 anni sono tutta la sua storia). MSCI World e' un PROXY 70% SPY + 30% EFA: URTH/ACWI/VT non sono nell'abbonamento dati del conto IB, e la nota lo dichiara invece di nasconderlo. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/research/r0807_asset_compare.py | 279 ++++++++++++++++++++++++ scripts/research/r0807_best_strategy.py | 208 ++++++++++++++++++ 2 files changed, 487 insertions(+) create mode 100644 scripts/research/r0807_asset_compare.py create mode 100644 scripts/research/r0807_best_strategy.py diff --git a/scripts/research/r0807_asset_compare.py b/scripts/research/r0807_asset_compare.py new file mode 100644 index 0000000..eedf737 --- /dev/null +++ b/scripts/research/r0807_asset_compare.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python +"""r0807_asset_compare.py — e se invece del book ci mettessi un ETF S&P 500 o MSCI World? + +DOMANDA DELL'OPERATORE. Confronto onesto fra tre motori per lo STESSO piano di accumulo: + + BOOK il book live Deribit (TP01 75% + SKH01 25%) alle ancore canoniche, de-luckato x0.89 + SP500 SPY, ADJUSTED_LAST (dividendi reinvestiti), 1996+ -> 30 anni + WORLD MSCI World: URTH se certificato, altrimenti proxy 70% SPY + 30% EFA + +Il confronto e' interessante SOLO se sono comparabili quattro cose, e tre di queste il progetto +le ha gia' sbagliate almeno una volta: + + (1) LA GRIGLIA. Le azioni hanno ~252 barre/anno, il book 365. Una serie di borsa passata a un + motore che annualizza a 365 esce con Sharpe x1.20 e CAGR x1.45 (lezione del 25/07 su GTAA01). + Qui le azioni vanno su griglia di CALENDARIO con 0.0 nei giorni di chiusura — la convenzione + gia' adottata per GTAA01: il capitale e' fermo, non riciclato. + + (2) LA FINESTRA. Il book ha 7.4 anni (2019-2026, due tori cripto); SPY ne ha 30 (2000, 2008, + 2020, 2022 dentro). Confrontare 7 anni scelti dalla storia recente contro 30 anni e' un + confronto truccato a favore del book -> si riporta ANCHE la finestra comune. + + (3) IL FISCO, che NON e' lo stesso e non e' un dettaglio: + book -> realizza in continuo, imposta ANNUA sulle plusvalenze (33% assunto) + 0.2% sul valore + ETF -> UCITS ad accumulazione: nessuna distribuzione, imposta DIFFERITA alla vendita (26%) + + 0.2% annuo sul valore + Il differimento e' un vantaggio strutturale dell'ETF che il book non ha, e va nel modello. + + (4) IL BERSAGLIO. $272.061 e' il capitale-rendita calcolato con la rendita perpetua DEL BOOK e + l'aliquota del 33%. Un ETF ha un'altra distribuzione e un'altra aliquota -> ha un ALTRO + bersaglio, che qui si ricalcola per ciascun motore invece di riusare il numero del book. + +⚠️ Le assunzioni fiscali sono dichiarate e NON sono un parere fiscale. + + uv run python scripts/research/r0807_asset_compare.py [--dep 500] [--lump 5000] [--anni 15] +""" +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 eqlib as EQ # noqa: E402 +import r0725_capcurve as CC # noqa: E402 +import r0726_venue_risk as VR # noqa: E402 +import r0726_capwall_refresh as WR # noqa: E402 +import r0727_lumpsum_split as LS # noqa: E402 +import r0807_growth_yearly as G # noqa: E402 + +TAX_CRYPTO = 0.33 # L.199/2025 (assunzione gia' cablata nel progetto) +TAX_ETF = 0.26 # redditi diversi di natura finanziaria +PATRIM = 0.002 # 0.2% annuo sul valore (IVAFE / imposta di bollo) +COMM_EUR = 2.0 # commissione per acquisto mensile su un broker retail europeo +N_PATHS = 4000 +SEED = 807 + + +# ------------------------------------------------------------------ serie +def _calendar_daily(close: pd.Series) -> pd.Series: + """Ritorni giornalieri su griglia di CALENDARIO: 0.0 quando la borsa e' chiusa. + + E' la convenzione di GTAA01 (capitale fermo nel weekend, non riciclato) ed e' cio' che rende + la serie confrontabile con quella del book, che ha 365 barre l'anno. + """ + r = close.astype(float).pct_change().dropna() + r.index = pd.to_datetime(r.index).tz_convert(None).normalize() + r = r[~r.index.duplicated(keep="last")] + idx = pd.date_range(r.index[0], r.index[-1], freq="D") + return r.reindex(idx).fillna(0.0) + + +def carica_serie() -> dict: + out = {} + b = VR.venue_series()["Deribit"] + b.index = pd.to_datetime(b.index).tz_localize(None).normalize() + out["BOOK"] = b.astype(float) + out["SP500"] = _calendar_daily(EQ.load_eq("SPY")["close"]) + + # MSCI World: il vero ETF se e' su disco, altrimenti il proxy dichiarato + world_path = ROOT / "data" / "raw" / "eq_urth_1d.parquet" + if world_path.exists(): + out["WORLD"] = _calendar_daily(EQ.load_eq("URTH")["close"]) + out["_world_fonte"] = "URTH (iShares MSCI World, reale)" + else: + spy, efa = _calendar_daily(EQ.load_eq("SPY")["close"]), _calendar_daily(EQ.load_eq("EFA")["close"]) + j = pd.concat({"SPY": spy, "EFA": efa}, axis=1).dropna() + out["WORLD"] = (0.70 * j["SPY"] + 0.30 * j["EFA"]) + out["_world_fonte"] = "PROXY 70% SPY + 30% EFA (ribil. giornaliero) — non e' l'indice" + return out + + +def metriche(r: pd.Series) -> dict: + eq = (1 + r).cumprod() + dd = float((eq / eq.cummax() - 1).min()) + mu, sd = float(r.mean()) * 365, float(r.std()) * np.sqrt(365) + return dict(n=len(r), da=str(r.index[0].date()), a=str(r.index[-1].date()), + anni=len(r) / 365.0, drift=mu, vol=sd, sharpe=mu / sd if sd > 0 else 0.0, maxdd=dd) + + +# ------------------------------------------------------------------ accumulo +def accumula(r: np.ndarray, lump_eur: float, dep_eur: float, anni: int, + differita: bool, aliquota: float, comm_eur: float) -> list[dict]: + """Traiettoria annuale con due regimi fiscali. + + * `differita=False` (book): imposta ANNUA sulle plusvalenze realizzate + patrimoniale. + E' esattamente `r0807_growth_yearly.traiettoria`, riusata senza riscriverla. + * `differita=True` (ETF ad accumulazione): nessuna imposta annua sul reddito, solo la + patrimoniale; l'imposta sul guadagno si paga alla VENDITA -> la curva riportata e' il + valore di LIQUIDAZIONE, `cap - aliquota * max(0, cap - versato)`, che e' l'unica cosa + confrontabile con un book gia' tassato ogni anno. + + La commissione d'acquisto si modella come deposito netto piu' piccolo, ma il `versato` + riportato resta quello USCITO DI TASCA: cosi' la commissione pesa sul guadagno, dov'e' giusto. + """ + dep_netto = max(0.0, dep_eur - comm_eur) if dep_eur > 0 else 0.0 + righe = G.traiettoria(r, lump_eur, dep_netto, anni, + 0.0 if differita else aliquota, PATRIM, + n_paths=N_PATHS, seed=SEED) + out = [] + for x in righe: + n_dep = round((x["versato"] - LS.START - lump_eur * CC.EURUSD) / (dep_netto * CC.EURUSD)) \ + if dep_netto > 0 else 0 + versato_reale = LS.START + lump_eur * CC.EURUSD + n_dep * dep_eur * CC.EURUSD + med, p25, p75 = x["capitale_med"], x["capitale_p25"], x["capitale_p75"] + if differita: + netto = lambda c: c - aliquota * max(0.0, c - versato_reale) # noqa: E731 + med, p25, p75 = netto(med), netto(p25), netto(p75) + out.append(dict(anno=x["anno"], versato=versato_reale, med=med, p25=p25, p75=p75, + guadagno=med - versato_reale, phit_book=x["p_traguardo"])) + return out + + +def bersaglio(r: np.ndarray, aliquota: float) -> tuple[float, float]: + """(rendita perpetua, capitale necessario) per €50/giorno NETTI con l'aliquota di quel motore.""" + lordo = CC.TARGET_EUR_DAY * 365 * CC.EURUSD / (1 - aliquota) + _, perp, cap = WR.perp_and_wall(r, 1.0, lordo) + return perp, cap + + +# ------------------------------------------------------------------ main +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--lump", type=float, default=5000.0) + ap.add_argument("--dep", type=float, default=500.0) + ap.add_argument("--anni", type=int, default=15) + ap.add_argument("--json", type=str, default="") + a = ap.parse_args() + + S = carica_serie() + motori = ["BOOK", "SP500", "WORLD"] + + print("=" * 104) + print(" r0807 — BOOK vs S&P 500 vs MSCI WORLD, stesso piano di accumulo") + print("=" * 104) + print(f" piano: ${LS.START:,.0f} + €{a.lump:,.0f} + €{a.dep:,.0f}/mese · {a.anni} anni · " + f"{N_PATHS:,} path, blocchi 20g") + print(f" MSCI World = {S['_world_fonte']}") + + print("\n (1) LE SERIE — griglia di calendario, 0.0 a borsa chiusa") + print(f" {'motore':8}{'barre':>8}{'da':>13}{'a':>13}{'anni':>7}{'drift':>9}{'vol':>8}" + f"{'Sharpe':>8}{'maxDD':>8}") + for m in motori: + x = metriche(S[m]) + print(f" {m:8}{x['n']:>8}{x['da']:>13}{x['a']:>13}{x['anni']:>7.1f}" + f"{x['drift']:>8.1%}{x['vol']:>8.1%}{x['sharpe']:>8.2f}{x['maxdd']:>8.1%}") + + # finestra comune: il confronto onesto sul REGIME + lo = max(S[m].index[0] for m in motori) + hi = min(S[m].index[-1] for m in motori) + print(f"\n (2) STESSA FINESTRA {lo.date()} → {hi.date()} — toglie il vantaggio di aver scelto " + f"gli anni") + print(f" {'motore':8}{'anni':>7}{'drift':>9}{'vol':>8}{'Sharpe':>8}{'maxDD':>8}") + comune = {} + for m in motori: + comune[m] = S[m].loc[lo:hi] + x = metriche(comune[m]) + print(f" {m:8}{x['anni']:>7.1f}{x['drift']:>8.1%}{x['vol']:>8.1%}{x['sharpe']:>8.2f}" + f"{x['maxdd']:>8.1%}") + + # bersagli propri + print("\n (3) IL BERSAGLIO E' DIVERSO PER OGNI MOTORE (rendita perpetua + aliquota propria)") + print(f" {'motore':8}{'aliquota':>10}{'rendita perpetua':>19}{'capitale per €50/g netti':>27}") + berg = {} + for m in motori: + al = TAX_CRYPTO if m == "BOOK" else TAX_ETF + perp, cap = bersaglio(S[m].values.astype(float), al) + berg[m] = dict(perp=perp, cap=cap, aliquota=al) + cs = f"${cap:,.0f}" if np.isfinite(cap) else "non sostenibile" + print(f" {m:8}{al:>9.0%}{perp:>18.2%}{cs:>27}") + + # bersaglio anche sulla finestra comune — altrimenti si confronta un accumulo di 7 anni + # con un bersaglio stimato su 30. ⚠️ Questa colonna e' la MENO affidabile delle due: un + # prelievo perpetuo e' una domanda a 20 anni e 7 anni di storia non la risolvono. + print("\n (3-bis) LO STESSO BERSAGLIO, ma stimato sulla sola finestra comune (poco " + "affidabile: 7 anni)") + print(f" {'motore':8}{'rendita perpetua':>19}{'capitale':>14}{'vs finestra piena':>20}") + berg_c = {} + for m in motori: + al = berg[m]["aliquota"] + perp, cap = bersaglio(comune[m].values.astype(float), al) + berg_c[m] = dict(perp=perp, cap=cap, aliquota=al) + rel = cap / berg[m]["cap"] - 1 if np.isfinite(cap) and np.isfinite(berg[m]["cap"]) else float("nan") + cs = f"${cap:,.0f}" if np.isfinite(cap) else "non sostenibile" + print(f" {m:8}{perp:>18.2%}{cs:>14}{rel:>19.0%}") + + # accumulo, ciascuno col proprio regime + print(f"\n (4) ACCUMULO a {a.anni} anni — ciascuno col PROPRIO fisco e i propri costi") + print(f" {'motore':8}{'versato':>13}{'capitale med':>15}{'p25':>13}{'p75':>13}" + f"{'guadagno':>13}{'% del cap.':>12}") + traj = {} + for m in motori: + t = accumula(S[m].values.astype(float), a.lump, a.dep, a.anni, + differita=(m != "BOOK"), + aliquota=TAX_CRYPTO if m == "BOOK" else TAX_ETF, + comm_eur=0.0 if m == "BOOK" else COMM_EUR) + traj[m] = t + L = t[-1] + print(f" {m:8}{L['versato']:>12,.0f}${L['med']:>14,.0f}${L['p25']:>12,.0f}$" + f"{L['p75']:>12,.0f}${L['guadagno']:>12,.0f}${L['guadagno']/L['med']:>11.0%}") + + # (4-bis) l'accumulo sulla STESSA finestra: e' il confronto che toglie il regime + print(f"\n (4-bis) LO STESSO ACCUMULO, ma ricampionando SOLO {lo.date()}→{hi.date()} " + f"per tutti e tre") + print(f" {'motore':8}{'capitale med':>15}{'p25':>13}{'p75':>13}{'guadagno':>13}" + f"{'vs finestra piena':>20}") + traj_c = {} + for m in motori: + t = accumula(comune[m].values.astype(float), a.lump, a.dep, a.anni, + differita=(m != "BOOK"), + aliquota=TAX_CRYPTO if m == "BOOK" else TAX_ETF, + comm_eur=0.0 if m == "BOOK" else COMM_EUR) + traj_c[m] = t + L, F = t[-1], traj[m][-1] + print(f" {m:8}{L['med']:>14,.0f}${L['p25']:>12,.0f}${L['p75']:>12,.0f}$" + f"{L['guadagno']:>12,.0f}${L['med']/F['med']-1:>19.0%}") + + print("\n (5) QUOTA DEL PROPRIO BERSAGLIO RAGGIUNTA (ognuno il suo)") + for m in motori: + L, c = traj[m][-1], berg[m]["cap"] + if np.isfinite(c) and c > 0: + print(f" {m:8} capitale ${L['med']:>10,.0f} · bersaglio ${c:>10,.0f}" + f" · {L['med'] / c:>6.0%}") + else: + print(f" {m:8} capitale ${L['med']:>10,.0f} · bersaglio non sostenibile") + + # sensibilita' del proxy World: la quota USA dell'indice e' cambiata molto nel tempo + if "PROXY" in S["_world_fonte"]: + spy, efa = _calendar_daily(EQ.load_eq("SPY")["close"]), _calendar_daily(EQ.load_eq("EFA")["close"]) + j = pd.concat({"SPY": spy, "EFA": efa}, axis=1).dropna() + print("\n (6) SENSIBILITA' DEL PROXY — la quota USA di MSCI World e' passata da ~50% " + "(2008) a ~72% (oggi)") + print(f" {'peso USA':>10}{'drift':>9}{'vol':>8}{'Sharpe':>8}{'maxDD':>8}") + for w in (0.5, 0.6, 0.7, 0.8): + x = metriche(w * j["SPY"] + (1 - w) * j["EFA"]) + print(f" {w:>9.0%}{x['drift']:>8.1%}{x['vol']:>8.1%}{x['sharpe']:>8.2f}{x['maxdd']:>8.1%}") + + if a.json: + Path(a.json).write_text(json.dumps(dict( + piano=dict(lump=a.lump, dep=a.dep, anni=a.anni, start=LS.START, eurusd=CC.EURUSD, + n_paths=N_PATHS, comm_eur=COMM_EUR, tax_crypto=TAX_CRYPTO, + tax_etf=TAX_ETF, patrim=PATRIM, world=S["_world_fonte"]), + metriche={m: metriche(S[m]) for m in motori}, + metriche_comune={m: metriche(comune[m]) for m in motori}, + finestra_comune=dict(da=str(lo.date()), a=str(hi.date())), + bersaglio={m: berg[m] for m in motori}, + bersaglio_comune={m: berg_c[m] for m in motori}, + traiettoria=traj, traiettoria_comune=traj_c), indent=1)) + print(f"\n scritto {a.json}") + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0807_best_strategy.py b/scripts/research/r0807_best_strategy.py new file mode 100644 index 0000000..2a63355 --- /dev/null +++ b/scripts/research/r0807_best_strategy.py @@ -0,0 +1,208 @@ +#!/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()