56141b0ffe
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) <noreply@anthropic.com>
280 lines
14 KiB
Python
280 lines
14 KiB
Python
#!/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()
|