research(portafoglio): book vs ETF — le due lenti si contraddicono, la scelta robusta e' 50/50
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>
This commit is contained in:
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user