research(capitale): il piano rifatto AL NETTO — il muro si sposta poco, i versamenti molto

Il 07/08 era stato misurato che l'accumulo composto al lordo sovrastima il capitale del
30% a 10 anni, ma i numeri del piano non erano stati rifatti. Qui lo sono.

Il muro usava una convenzione ASIMMETRICA: prelievo lordizzato (€50/g netti → $29.690
lordi) ma capitale che compone senza mai pagare imposte. Coerente (imposte annue dentro
il portafoglio, prelievo gia' netto): perpetua 10.91% → 7.70%, muro $272.061 → $258.338
(-5.0%). I due errori vanno in versi opposti e si compensano quasi — per caso, non per
costruzione.

E' sulle traiettorie che il fisco morde, e si legge nella PROBABILITA':
€250/mese P(entro 20a) 92% → 52%; €500/mese 100% → 99%. Il versamento necessario a P=90%
passa da €237 a €371/mese a 20 anni (+57%), da €509 a €672 a 15 (+32%), da €1.178 a
€1.323 a 10 (+12%): l'errore era composto, quindi cresce con l'orizzonte. Rendita a 20
anni con €250/mese: 91.30 → 46.61 €/g, P(€50/g) 90% → 42%.

Controllo di replica superato prima di guardare i numeri nuovi: a fisco spento la macchina
riproduce $272.061 al dollaro (implementazione separata) e la colonna LORDA riproduce 4
righe su 4 della tabella pubblicata. Trovato per strada: perp_and_wall gira a 2000 path e
a quella taglia da' $269.648 — la terza cifra del muro e' rumore Monte Carlo.

Errore mio catturato prima di pubblicare: la mediana degli anni calcolata sull'INTERO
vettore coi non-arrivi a -1 faceva risultare €250/mese PIU' VELOCE col fisco (15.7 →
15.3 anni) mentre P crollava. Un non-arrivo va codificato +inf, mai -1.

Book/pesi/cron/config INVARIATI: non tocca la produzione.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-08-07 19:30:49 +00:00
parent 963776e5d2
commit 02e0cf775f
3 changed files with 695 additions and 0 deletions
+361
View File
@@ -0,0 +1,361 @@
#!/usr/bin/env python
"""r0807_piano_netto.py — il piano di accumulo rifatto AL NETTO: muro, traiettorie, versamenti.
IL DEBITO. Il 07/08 `r0727_tasse.py` / `r0807_growth_yearly.py` hanno misurato che l'accumulo
composto al lordo sovrastima il capitale del **15.7% a 5 anni, 29.8% a 10, 42.6% a 15**. Il fatto
e' stato scritto in memoria ma i NUMERI del piano non sono stati rifatti — e sono numeri che sono
serviti a decidere: la traiettoria da $600 (\"€250/mese -> 16.2 anni, P(entro 20a) 92%\"), la
tabella \"quanto versare per un orizzonte dato\", la rendita €/g per livello di versamento.
⚠️ E IL MURO STESSO NON E' NEUTRO. `$272.061` viene da una convenzione ASIMMETRICA: il prelievo
viene lordizzato (`€50/g netti -> $27.375/anno lordi` a fisco 33%) ma il capitale che resta
investito compone **senza mai pagare imposte**, in accumulo come in prelievo. Le due meta' del
conto non usano lo stesso fisco. Qui si misura la convenzione COERENTE — le imposte si pagano
dentro il portafoglio, ogni anno, e cio' che si preleva e' gia' netto — e si guarda di quanto il
muro si sposta. I due errori vanno in versi OPPOSTI (la lordizzazione alza il muro, l'assenza di
imposte in prelievo lo abbassa), quindi il segno del netto non e' deducibile: va misurato.
COSA C'E' QUI
(0) CONTROLLO DI REPLICA — a fisco spento tutto deve riprodurre i numeri pubblicati. Senza
questo, un numero diverso non si distingue da un bug.
(1) IL MURO, nelle due convenzioni.
(2) TRAIETTORIE da $600 per livello di versamento, lorde vs nette, sugli STESSI path.
(3) QUANTO VERSARE per un orizzonte dato, al netto.
(4) RENDITA €/g mediana per livello di versamento, al netto.
⚠️ Le assunzioni fiscali sono quelle DICHIARATE il 27/07 e NON sono un parere fiscale: 33%
(sensibilita' a 26%), minusvalenze in carry 4 anni, patrimoniale 0.2%/anno, e il modello tassa la
variazione ANNUA di valore — cioe' un LIMITE SUPERIORE rispetto alla pura realizzazione, vicino
al vero perche' TP01 ribilancia ogni giorno e SKH01 chiude round-trip discreti.
uv run python scripts/research/r0807_piano_netto.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
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 r0726_deposits as DP # noqa: E402
import r0727_lumpsum_split as LS # noqa: E402
import r0727_tasse as TX # noqa: E402
N_PATHS = 4000
BLOCK = 20
SEED = 20260807
SEED_MURO = 725 # lo stesso di CC.survival: consente la replica esatta
ALIQUOTA = CC.TAX_RATE # 0.33
VERSAMENTI = (0, 250, 500, 800, 1_000, 2_000)
ORIZZONTI = (10, 15, 20)
CONFIDENZE = (0.50, 0.75, 0.90, 0.95)
# ===========================================================================
# il passo fiscale, uno solo, riusato ovunque
# ===========================================================================
def passo_fiscale(cap: np.ndarray, anno_start: np.ndarray, flusso_anno: np.ndarray,
carry: np.ndarray, aliquota: float, patrimoniale: float) -> np.ndarray:
"""Imposte di fine anno, in loco su `cap`. Ritorna l'imposta pagata.
`flusso_anno` = versamenti MENO prelievi dell'anno: la plusvalenza e' la variazione di valore
depurata dai movimenti di cassa, quindi la stessa formula serve l'accumulo e il prelievo.
Identica meccanica a `r0807_growth_yearly.traiettoria` (carry a 4 anni, piu' vecchie prima).
"""
imposta = np.zeros(len(cap))
plus = cap - anno_start - flusso_anno
if aliquota > 0:
usate = np.minimum(np.maximum(plus, 0.0), carry.sum(axis=1))
imponibile = np.maximum(plus, 0.0) - usate
resid = usate.copy()
for k in range(carry.shape[1] - 1, -1, -1):
presa = np.minimum(carry[:, k], resid)
carry[:, k] -= presa
resid -= presa
dovuta = imponibile * aliquota
cap -= np.minimum(dovuta, cap)
imposta += dovuta
carry[:] = np.roll(carry, 1, axis=1)
carry[:, 0] = np.maximum(-plus, 0.0)
if patrimoniale > 0:
p = cap * patrimoniale
cap -= np.minimum(p, cap)
imposta += p
return imposta
# ===========================================================================
# (1) il muro: sopravvivenza in prelievo, con e senza fisco dentro
# ===========================================================================
def sopravvivenza(r: np.ndarray, cap0: float, prelievo_anno: float, anni: int,
aliquota: float, patrimoniale: float, n_paths: int = N_PATHS,
seed: int = SEED_MURO) -> dict:
"""Gemella di `CC.survival` con il fisco DENTRO il portafoglio.
Con aliquota=0 e patrimoniale=0 deve dare esattamente `CC.survival` — e' il controllo che
rende credibile la riga col fisco (test `test_piano_netto.py`).
"""
rng = np.random.default_rng(seed)
n_days = int(anni * 365)
paths = CC._boot_paths(r, n_paths, n_days, BLOCK, rng)
cap = np.full(n_paths, cap0, float)
vivo = np.ones(n_paths, bool)
giorno = prelievo_anno / 365.0
carry = np.zeros((n_paths, TX.CARRY_ANNI))
anno_start = cap.copy()
flusso = np.zeros(n_paths)
for t in range(n_days):
cap = np.where(vivo, cap * (1.0 + paths[:, t]) - giorno, cap)
flusso = np.where(vivo, flusso - giorno, flusso)
vivo &= cap > 0
if (t + 1) % 365 == 0 and (aliquota > 0 or patrimoniale > 0):
vivi = vivo.copy()
passo_fiscale(cap, anno_start, flusso, carry, aliquota, patrimoniale)
cap = np.where(vivi, cap, 0.0)
vivo &= cap > 0
anno_start = cap.copy()
flusso[:] = 0.0
fine = np.where(vivo, cap, 0.0)
return dict(p_survive=float(vivo.mean()), median_end=float(np.median(fine)),
p10_end=float(np.percentile(fine, 10)))
def muro(r: np.ndarray, prelievo_netto_anno: float, aliquota: float,
patrimoniale: float) -> tuple[float, float]:
"""(tasso di rendita perpetua, capitale necessario). Perpetua = il prelievo piu' alto con
P(capitale a 20 anni >= capitale iniziale) >= 90% — stessa definizione del 25/07."""
lo, hi = 0.0, 0.40
for _ in range(13):
mid = (lo + hi) / 2
s = sopravvivenza(r, 1e6, 1e6 * mid, 20, aliquota, patrimoniale)
if s["p10_end"] >= 1e6:
lo = mid
else:
hi = mid
return lo, (prelievo_netto_anno / lo if lo > 0.002 else float("inf"))
# ===========================================================================
# (2-4) accumulo
# ===========================================================================
def accumula(paths: np.ndarray, dep_eur: float, bersaglio: float, aliquota: float,
patrimoniale: float, start: float = LS.START,
lump_eur: float = 0.0) -> dict:
"""Accumulo su path GIA' ESTRATTI (confronto appaiato fra regimi fiscali e versamenti).
Ritorna il capitale finale, il giorno di primo passaggio al bersaglio e il totale versato.
"""
n_paths, n_days = paths.shape
dep = dep_eur * CC.EURUSD
cap = np.full(n_paths, start + lump_eur * CC.EURUSD)
versato = start + lump_eur * CC.EURUSD
carry = np.zeros((n_paths, TX.CARRY_ANNI))
anno_start = cap.copy()
flusso = np.zeros(n_paths)
colpito = np.full(n_paths, -1, int)
for t in range(n_days):
cap *= (1.0 + paths[:, t])
if t % 30 == 0 and t > 0 and dep > 0:
cap += dep
flusso += dep
versato += dep
nuovi = (colpito < 0) & (cap >= bersaglio)
colpito[nuovi] = t
if (t + 1) % 365 == 0 and (aliquota > 0 or patrimoniale > 0):
passo_fiscale(cap, anno_start, flusso, carry, aliquota, patrimoniale)
anno_start = cap.copy()
flusso[:] = 0.0
return dict(cap=cap, colpito=colpito, versato=versato)
def anni_al_traguardo(colpito: np.ndarray) -> tuple[float, float]:
"""(anni mediani al traguardo CONDIZIONATI all'arrivo, P(traguardo entro 20 anni)).
⚠️ La mediana e' condizionata — e' la convenzione di `r0726_capwall_refresh.trajectory`, quindi
i due numeri sono confrontabili con la tabella pubblicata. Va letta insieme alla probabilita':
«15 anni» su meta' dei path che arrivano non e' «15 anni». Prendere la mediana sull'INTERO
vettore (con i non-arrivi a -1) darebbe un numero piu' BASSO quanto peggio va la colonna, cioe'
esattamente il contrario di cio' che si vuole leggere.
"""
arrivati = colpito[colpito >= 0]
med = float(np.percentile(arrivati, 50)) / 365.0 if len(arrivati) > 100 else float("nan")
entro20 = float(((colpito >= 0) & (colpito <= 20 * 365)).mean())
return med, entro20
def versamento_per_confidenza(paths: np.ndarray, bersaglio: float, conf: float,
aliquota: float, patrimoniale: float) -> float:
"""Il versamento mensile minimo con P(traguardo entro l'orizzonte) >= `conf`.
Bisezione: P e' monotona nel versamento."""
lo, hi = 0.0, 6_000.0
if accumula(paths, hi, bersaglio, aliquota, patrimoniale)["colpito"].__ge__(0).mean() < conf:
return float("inf")
for _ in range(12):
mid = (lo + hi) / 2
p = float((accumula(paths, mid, bersaglio, aliquota, patrimoniale)["colpito"] >= 0).mean())
if p >= conf:
hi = mid
else:
lo = mid
return hi
def sezione(t: str) -> None:
print("\n" + "=" * 100)
print(f" {t}")
print("=" * 100)
def main() -> None:
print("=" * 100)
print(" r0807 — IL PIANO AL NETTO: muro, traiettorie e versamenti col fisco d'accumulo dentro")
print("=" * 100)
r = DP.deluck_returns()
netto_anno = CC.TARGET_EUR_DAY * 365 * CC.EURUSD # €50/g NETTI, senza lordizzare
lordo_anno = netto_anno / (1 - ALIQUOTA)
print(f"\n book live de-luckato ×{DP.DELUCK} · {len(r)} giorni · drift {r.mean() * 365:.1%} · "
f"vol {r.std() * 365 ** 0.5:.1%}")
print(f" bersaglio di rendita: €{CC.TARGET_EUR_DAY:.0f}/g netti = ${netto_anno:,.0f}/anno "
f"(${lordo_anno:,.0f} se lordizzati al {ALIQUOTA:.0%})")
# ------------------------------------------------------------------ (0)
sezione("(0) CONTROLLO DI REPLICA — a fisco spento deve uscire il numero pubblicato")
perp_mio, muro_mio = muro(r, lordo_anno, 0.0, 0.0)
n_orig = WR.N_PATHS
_, perp_2k, muro_2k = WR.perp_and_wall(r, 1.0, lordo_anno) # default dello script, 2000
try:
WR.N_PATHS = N_PATHS
_, perp_4k, muro_4k = WR.perp_and_wall(r, 1.0, lordo_anno) # alla taglia pubblicata
finally:
WR.N_PATHS = n_orig
print(f"\n costante pubblicata (VR.TARGET, citata ovunque) :"
f"{'':>13}muro ${LS.TARGET:,.0f}")
print(f" questa implementazione, fisco OFF, {N_PATHS} path : perpetua {perp_mio:.4%} · "
f"muro ${muro_mio:,.0f}")
print(f" r0726_capwall_refresh ai suoi {n_orig} path : perpetua {perp_2k:.4%} · "
f"muro ${muro_2k:,.0f}")
print(f" r0726_capwall_refresh portato a {N_PATHS} path : perpetua {perp_4k:.4%} · "
f"muro ${muro_4k:,.0f}")
ok = abs(muro_mio - LS.TARGET) < 1.0 and abs(perp_mio - perp_4k) < 1e-9
print(f" -> {'REPLICA ESATTA' if ok else 'NON REPLICA — fermarsi qui'}: due implementazioni "
"separate, stesso dollaro")
print(f"\n ⚠️ Trovato per strada: `perp_and_wall` gira a {n_orig} path e a quella taglia da'")
print(f" ${muro_2k:,.0f} invece di ${LS.TARGET:,.0f}{abs(muro_2k / LS.TARGET - 1):.1%} di rumore Monte Carlo.")
print(" Il muro pubblicato e' corretto (fu calcolato a 4000), ma la sua TERZA cifra")
print(" significativa non e' un'informazione: si cita $272k, non $272.061.")
if not ok:
raise SystemExit("la replica non torna: qualunque numero col fisco sarebbe indistinguibile da un bug")
muro_pub, perp_pub = LS.TARGET, perp_mio
# ------------------------------------------------------------------ (1)
sezione("(1) IL MURO — la convenzione pubblicata e quella coerente")
print("\n (a) PUBBLICATA: prelievo lordizzato, capitale che compone senza mai pagare imposte.")
print(" (b) COERENTE : imposte pagate ogni anno dentro il portafoglio, prelievo gia' netto.")
print(f"\n {'convenzione':<34}{'prelievo/anno':>15}{'perpetua':>11}{'muro':>14}")
print(f" {'(a) pubblicata (fisco solo sul prelievo)':<34}{lordo_anno:>15,.0f}"
f"{perp_pub:>10.2%}{muro_pub:>14,.0f}")
righe = {}
for aliq in (ALIQUOTA, 0.26):
p, w = muro(r, netto_anno, aliq, TX.PATRIMONIALE)
righe[aliq] = (p, w)
print(f" {f'(b) coerente, aliquota {aliq:.0%}':<34}{netto_anno:>15,.0f}{p:>10.2%}{w:>14,.0f}")
perp_n, muro_n = righe[ALIQUOTA]
print(f"\n Il muro coerente e' ${muro_n:,.0f} contro ${muro_pub:,.0f} pubblicati "
f"({muro_n / muro_pub - 1:+.1%}).")
print(" I due errori della convenzione (a) si compensano quasi: lordizzare il prelievo")
print(" ALZA il muro, non tassare il capitale investito lo ABBASSA. Restano scoperti la")
print(f" patrimoniale ({TX.PATRIMONIALE:.1%}/anno) e la non-linearita' della rendita perpetua")
print(" nel drift, che e' cio' che si vede nella differenza.")
# ------------------------------------------------------------------ (2)
sezione(f"(2) TRAIETTORIE DA ${LS.START:.0f} — anni al traguardo, lordo vs netto, sugli STESSI path")
anni = 25 # come `r0726_capwall_refresh.trajectory`: 20 troncherebbe la coda
rng = np.random.default_rng(725) # stesso seed della tabella
paths = CC._boot_paths(r, 3_000, anni * 365, BLOCK, rng) # e stessa taglia
print(f"\n orizzonte {anni} anni · 3000 path · seed 725 — cioe' ESATTAMENTE la macchina che")
print(" ha prodotto la tabella pubblicata: la colonna LORDA e' una replica di controllo.")
print(f" (LORDO usa il muro pubblicato ${muro_pub:,.0f}, NETTO quello coerente ${muro_n:,.0f})")
print(f"\n {'€/mese':>8}{'versato in 20a':>16} |{'LORDO: anni':>13}{'P(20a)':>9}"
f" |{'NETTO: anni':>13}{'P(20a)':>9}{'ritardo':>10}")
tab2 = []
for dep in VERSAMENTI:
L = accumula(paths, dep, muro_pub, 0.0, 0.0)
N = accumula(paths, dep, muro_n, ALIQUOTA, TX.PATRIMONIALE)
aL, pL = anni_al_traguardo(L["colpito"])
aN, pN = anni_al_traguardo(N["colpito"])
rit = f"{aN - aL:+.1f}a" if not (np.isnan(aL) or np.isnan(aN)) else ""
f = lambda a: f"{a:.1f}a" if not np.isnan(a) else "mai" # noqa: E731
versato_20a = LS.START + dep * CC.EURUSD * (20 * 365 // 30)
print(f" {dep:>8}{versato_20a:>16,.0f} |{f(aL):>13}{pL:>9.0%} "
f"|{f(aN):>13}{pN:>9.0%}{rit:>10}")
tab2.append(dict(dep=dep, anni_lordo=aL, p_lordo=pL, anni_netto=aN, p_netto=pN))
print("\n ⚠️ La colonna «anni» e' la mediana CONDIZIONATA all'arrivo: va letta insieme alla")
print(" probabilita' accanto. Dove P scende molto (€250/mese al netto) l'anno mediano")
print(" resta basso perche' descrive solo chi arriva — non e' un miglioramento.")
# ------------------------------------------------------------------ (3)
sezione("(3) QUANTO VERSARE PER UN ORIZZONTE DATO — al netto")
print(f"\n bersaglio ${muro_n:,.0f} · versamento mensile minimo per la confidenza indicata")
print(f"\n {'orizzonte':>10}" + "".join(f"{f'P={c:.0%}':>12}" for c in CONFIDENZE)
+ f"{'tot. versato @P=90%':>22}")
for anni_h in ORIZZONTI:
rng_h = np.random.default_rng(SEED)
ph = CC._boot_paths(r, N_PATHS, anni_h * 365, BLOCK, rng_h)
celle, tot90 = [], 0.0
for c in CONFIDENZE:
v = versamento_per_confidenza(ph, muro_n, c, ALIQUOTA, TX.PATRIMONIALE)
celle.append(f"{v:,.0f}/m" if np.isfinite(v) else "irraggiung.")
if c == 0.90 and np.isfinite(v):
tot90 = accumula(ph, v, muro_n, ALIQUOTA, TX.PATRIMONIALE)["versato"]
print(f" {f'{anni_h} anni':>10}" + "".join(f"{x:>12}" for x in celle)
+ f"{f'${tot90:,.0f}':>22}")
# ------------------------------------------------------------------ (4)
sezione("(4) RENDITA €/g MEDIANA — al netto, per livello di versamento")
print(f"\n rendita = capitale × perpetua({perp_n:.2%}); le imposte sono gia' dentro la")
print(" perpetua, quindi il numero e' NETTO e non va lordizzato una seconda volta.")
print(f"\n {'€/mese':>8}" + "".join(f"{f'{y}a':>12}" for y in (5, 10, 15, 20))
+ f"{'P(€50/g a 20a)':>17}")
for dep in VERSAMENTI:
celle, p50 = [], 0.0
for y in (5, 10, 15, 20):
rng_y = np.random.default_rng(SEED)
py = CC._boot_paths(r, N_PATHS, y * 365, BLOCK, rng_y)
cap = accumula(py, dep, muro_n, ALIQUOTA, TX.PATRIMONIALE)["cap"]
rend = cap * perp_n / 365.0 / CC.EURUSD
celle.append(f"{np.median(rend):,.2f}")
if y == 20:
p50 = float((rend >= CC.TARGET_EUR_DAY).mean())
print(f" {dep:>8}" + "".join(f"{x:>12}" for x in celle) + f"{p50:>17.1%}")
# ------------------------------------------------------------------ verdetto
sezione("VERDETTO — cosa cambia nelle conclusioni gia' usate per decidere")
d250 = next(x for x in tab2 if x["dep"] == 250)
d500 = next(x for x in tab2 if x["dep"] == 500)
print(f"""
1. Il MURO si sposta poco: ${muro_n:,.0f} contro ${muro_pub:,.0f} ({muro_n / muro_pub - 1:+.1%}).
La convenzione pubblicata sbagliava DUE volte in versi opposti e le due meta' quasi si
cancellano — ma per caso, non per costruzione: e' il tipo di errore che si scopre solo
rifacendo il conto in modo coerente.
2. E' sulle TRAIETTORIE che il fisco morde, e si legge nella PROBABILITA' piu' che negli anni:
€250/mese passa da P(entro 20a) {d250['p_lordo']:.0%} a {d250['p_netto']:.0%} (mediana condizionata
{d250['anni_lordo']:.1f} -> {d250['anni_netto']:.1f} anni); €500/mese da {d500['p_lordo']:.0%} a {d500['p_netto']:.0%}
({d500['anni_lordo']:.1f} -> {d500['anni_netto']:.1f} anni). Il livello di versamento che il 26/07 dava come
«P 92%, il piano funziona» e' quello che al netto diventa una moneta.
3. Cio' che NON cambia: senza versamenti il capitale-rendita non si raggiunge mai, a
qualunque lente fiscale. L'ordine di importanza delle leve (versare > quando > quanto
presto si smette > piatto vs crescente > frequenza) e' invariato, perche' il fisco
colpisce tutte le colonne allo stesso modo.
""")
if __name__ == "__main__":
main()