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>
209 lines
10 KiB
Python
209 lines
10 KiB
Python
#!/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()
|