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
+164
View File
@@ -0,0 +1,164 @@
# 2026-08-07 — Il piano rifatto al netto: muro, traiettorie, versamenti
**Book, pesi, cron, config: INVARIATI.** Non tocca la produzione: rifa' i numeri del *piano di
accumulo*, che erano al lordo del fisco.
Script: `scripts/research/r0807_piano_netto.py`. Test: `tests/test_piano_netto.py` (16).
---
## Il debito
Il 07/08 (`r0727_tasse.py`, `r0807_growth_yearly.py`) era stato 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 era stato
scritto in memoria — *"TUTTE le tabelle a 15-20 anni pubblicate sopra sono al LORDO"* — ma i numeri
non erano stati rifatti. Sono numeri che sono serviti a decidere: la traiettoria da $600, la
tabella «quanto versare per un orizzonte dato», la rendita €/g.
**E il muro stesso non era neutro.** `$272.061` viene da una convenzione **asimmetrica**: il
prelievo viene lordizzato (€50/g netti → $29.690/anno lordi al 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.
---
## (0) Il controllo di replica, che ha trovato subito qualcosa
Prima regola: a fisco spento tutto deve riprodurre i numeri pubblicati, altrimenti un numero
diverso non si distingue da un bug.
| | perpetua | muro |
|---|---|---|
| costante pubblicata (`VR.TARGET`, citata ovunque) | — | **$272.061** |
| questa implementazione, fisco OFF, 4000 path | 10.9131% | **$272.061** |
| `r0726_capwall_refresh.perp_and_wall` ai suoi **2000** path | 11.0107% | $269.648 |
| lo stesso, portato a 4000 path | 10.9131% | **$272.061** |
Replica esatta al dollaro fra due implementazioni separate. ⚠️ **Ma trovato per strada:** la
funzione che ha prodotto il muro gira di default a **2000 path**, e a quella taglia da' $269.648 —
**0.9% di rumore Monte Carlo**. Il muro pubblicato e' corretto (fu calcolato a 4000), ma **la sua
terza cifra significativa non e' un'informazione**: si cita **$272k**, non $272.061.
---
## (1) Il muro, nella convenzione coerente
Imposte pagate ogni anno **dentro** il portafoglio (stessa meccanica dell'accumulo: plusvalenza
annua al netto dei movimenti di cassa, carry 4 anni, patrimoniale 0.2%), prelievo gia' netto:
| convenzione | prelievo/anno | perpetua | muro |
|---|---|---|---|
| (a) pubblicata — fisco solo sul prelievo | $29.690 | 10.91% | **$272.061** |
| (b) coerente, aliquota 33% | $19.892 | **7.70%** | **$258.338** |
| (b) coerente, aliquota 26% | $19.892 | 8.42% | $236.310 |
**5.0%.** I due errori della convenzione (a) vanno in versi opposti e **si compensano quasi**:
lordizzare il prelievo alza il muro, non tassare il capitale investito lo abbassa. Ma si
compensano *per caso*, non per costruzione — e' il tipo di errore che si vede solo rifacendo il
conto in modo coerente. Cio' che resta scoperto e' la patrimoniale e la non-linearita' della
rendita perpetua nel drift.
---
## (2) Le traiettorie — ed e' qui che il fisco morde
Orizzonte 25 anni, 3000 path, seed 725: **esattamente la macchina che ha prodotto la tabella
pubblicata**, quindi la colonna LORDA e' una replica di controllo.
| €/mese | versato in 20a | LORDO: anni | P(20a) | NETTO: anni | P(20a) |
|---|---|---|---|---|---|
| 0 | $600 | mai | 0% | mai | 0% |
| **250** | $66.818 | **16.3a** *(pubbl. 16.2)* | **92%** *(pubbl. 92%)* | **19.8a** | **52%** |
| 500 | $133.035 | 12.4a *(pubbl. 12.4)* | 100% | 14.7a | 99% |
| 800 | $212.496 | 10.0a | 100% | 11.4a | 100% |
| 1000 | $265.470 | 9.0a *(pubbl. 9.0)* | 100% | 10.0a | 100% |
| 2000 | $530.340 | 6.0a *(pubbl. 6.0)* | 100% | 6.4a | 100% |
**Il livello €250/mese — quello con cui il 26/07 il piano risultava «P(entro 20a) 92%» — al netto
diventa una moneta: 52%.**
⚠️ **La colonna «anni» e' la mediana CONDIZIONATA all'arrivo** (convenzione di
`r0726_capwall_refresh.trajectory`, quindi confrontabile con la tabella pubblicata) e va letta
insieme alla probabilita' accanto.
> **Errore mio, catturato prima di pubblicare.** La prima stesura calcolava la mediana
> sull'**intero** vettore, con i non-arrivi codificati `-1`. Risultato: €250/mese risultava passare
> da 15.7 a **15.3** anni col fisco — *piu' veloce* — mentre la probabilita' crollava da 91% a 53%.
> Con meta' dei path a `-1` la mediana del vettore cade sui **primi** arrivi. **Un non-arrivo va
> messo a +∞, non a 1: messo a 1 il numero migliora tanto piu' quanto peggio va la colonna**, ed
> era abbastanza plausibile da finire in un verdetto.
---
## (3) Quanto versare per un orizzonte dato — al netto
Bersaglio $258.338. Fra parentesi il numero pubblicato (lordo, bersaglio $272.061).
| orizzonte | P=50% | P=75% | P=90% | P=95% | tot. versato @P=90% |
|---|---|---|---|---|---|
| **10 anni** | €998/m | €1.162/m | **€1.323/m** *(€1.178)* | €1.424/m | **$175.058** *($155.923)* |
| 15 anni | €470/m | €570/m | **€672/m** *(€509)* | €725/m | $133.983 *($101.643)* |
| 20 anni | €245/m | €306/m | **€371/m** *(€237)* | €417/m | $98.762 *($63.406)* |
**Il fisco costa +12% al mese a 10 anni, +32% a 15, +57% a 20.** Cresce con l'orizzonte perche'
l'errore era composto: piu' tempo, piu' interessi mai maturati sulle imposte non pagate.
E la lettura del 26/07 si **rafforza**: a 10 anni versi **$175k** per arrivare a $258k (il
rendimento fa il 32%), a 20 anni ne versi **$99k** (il rendimento fa il 62%). *A orizzonti corti
non fai lavorare la strategia, compri il capitale coi bonifici* — al netto ancora di piu'.
---
## (4) Rendita €/g mediana — al netto
Le imposte sono gia' dentro la perpetua (7.70%): il numero e' netto e **non va lordizzato una
seconda volta**.
| €/mese | 5a | 10a | 15a | 20a | P(€50/g a 20a) |
|---|---|---|---|---|---|
| 0 | 0.20 | 0.34 | 0.59 | 1.01 | 0.0% |
| **250** | 4.38 | 11.82 | 24.61 | **46.61** *(pubbl. 91.30)* | **42.0%** *(pubbl. 90.0%)* |
| 500 | 8.56 | 23.31 | 48.65 | 92.11 | 97.6% |
| 800 | 13.58 | 37.08 | 77.47 | 146.78 | 100.0% |
| 1000 | 16.93 | 46.26 | 96.67 | 183.18 | 100.0% |
| 2000 | 33.66 | 92.18 | 192.79 | 365.24 | 100.0% |
A 20 anni la rendita di €250/mese si **dimezza** (91.30 → 46.61 €/g) e P(€50/g) passa da **90% a
42%**. La non-linearita' resta: da 15 a 20 anni la rendita raddoppia a ogni livello.
---
## Cosa cambia e cosa no
**Cambia:** il livello di versamento che il piano dichiarava sufficiente. €250/mese al lordo
sembrava «P 92%, il piano funziona»; al netto e' 52%, e per tornare a ~90% servono **€371/mese**
a 20 anni. Il muro si sposta poco (5%), i **versamenti** si spostano molto (+57% a 20 anni).
**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: il fisco colpisce tutte le colonne allo stesso modo;
- il rischio di venue resta fuori scala rispetto a tutto questo (a p=5% il risultato mediano e'
zero comunque).
⚠️ **Assunzioni fiscali dichiarate, NON un parere fiscale** (33%, sensibilita' a 26%, minusvalenze
in carry 4 anni, patrimoniale 0.2%/anno). Il modello tassa la variazione **annua** di valore =
**limite superiore** rispetto alla pura realizzazione, stretto perche' TP01 ribilancia ogni giorno
e SKH01 chiude round-trip discreti.
---
## Regole
1. **Un modello che tassa una meta' del conto e non l'altra non e' conservativo, e' incoerente**
e i due errori possono compensarsi quasi esattamente, il che li rende invisibili finche' non si
rifa' il conto in modo simmetrico.
2. **Un non-arrivo va codificato +∞, mai 1.** Con 1 la mediana *migliora* quanto piu' la colonna
peggiora.
3. **Una mediana condizionata va sempre stampata accanto alla sua probabilita'**, o dice il
contrario di quello che sembra.
4. **Prima di pubblicare un numero nuovo, far riprodurre alla macchina quello vecchio** — qui la
colonna LORDA riproduce 4 righe su 4 della tabella pubblicata, ed e' l'unica ragione per cui la
colonna NETTA e' leggibile.
5. **Un Monte Carlo ha una risoluzione, e va detta.** $272.061 e' esatto quanto $269.648: la
differenza fra i due e' la taglia del campione, non un'informazione sul piano.
+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()
+170
View File
@@ -0,0 +1,170 @@
"""Il piano di accumulo al NETTO del fisco (scripts/research/r0807_piano_netto.py).
Il numero che questo script deve poter cambiare — le traiettorie e i versamenti pubblicati — e'
stato usato per decidere. Percio' l'ordine dei test e' quello del metodo: prima si dimostra che
la macchina riproduce cio' che sostituisce (a fisco spento), poi si guarda cosa dice col fisco
acceso. Un numero nuovo prodotto da una macchina non validata e' indistinguibile da un bug.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
import r0725_capcurve as CC # noqa: E402
import r0727_lumpsum_split as LS # noqa: E402
import r0727_tasse as TX # noqa: E402
import r0807_piano_netto as PN # noqa: E402
def carry_vuoto(n: int) -> np.ndarray:
return np.zeros((n, TX.CARRY_ANNI))
# ===========================================================================
# il passo fiscale
# ===========================================================================
def test_una_plusvalenza_viene_tassata_all_aliquota():
cap = np.array([110.0])
imp = PN.passo_fiscale(cap, np.array([100.0]), np.array([0.0]), carry_vuoto(1), 0.33, 0.0)
assert imp[0] == pytest.approx(10 * 0.33)
assert cap[0] == pytest.approx(110 - 3.3)
def test_i_versamenti_dell_anno_non_sono_plusvalenza():
"""Chi versa €1.000 e finisce l'anno con €1.000 in piu' non ha guadagnato nulla."""
cap = np.array([1_100.0])
imp = PN.passo_fiscale(cap, np.array([100.0]), np.array([1_000.0]), carry_vuoto(1), 0.33, 0.0)
assert imp[0] == 0.0
assert cap[0] == 1_100.0
def test_i_prelievi_dell_anno_non_sono_minusvalenza():
"""Simmetrico del precedente, ed e' il motivo per cui la stessa funzione serve accumulo e
prelievo: chi preleva 100 e chiude pari ha guadagnato 100, non zero."""
cap = np.array([1_000.0])
imp = PN.passo_fiscale(cap, np.array([1_000.0]), np.array([-100.0]), carry_vuoto(1), 0.33, 0.0)
assert imp[0] == pytest.approx(100 * 0.33)
def test_una_minusvalenza_non_produce_credito_ma_carry():
cap = np.array([90.0])
c = carry_vuoto(1)
imp = PN.passo_fiscale(cap, np.array([100.0]), np.array([0.0]), c, 0.33, 0.0)
assert imp[0] == 0.0 and cap[0] == 90.0
assert c[0, 0] == pytest.approx(10.0)
def test_il_carry_abbatte_l_imponibile_dell_anno_dopo():
cap = np.array([90.0])
c = carry_vuoto(1)
PN.passo_fiscale(cap, np.array([100.0]), np.array([0.0]), c, 0.33, 0.0) # -10
cap[0] = 120.0
imp = PN.passo_fiscale(cap, np.array([90.0]), np.array([0.0]), c, 0.33, 0.0) # +30, -10 di carry
assert imp[0] == pytest.approx(20 * 0.33)
def test_il_carry_scade_dopo_quattro_anni():
c = carry_vuoto(1)
cap = np.array([90.0])
PN.passo_fiscale(cap, np.array([100.0]), np.array([0.0]), c, 0.33, 0.0) # minus di 10
for _ in range(TX.CARRY_ANNI): # 4 anni in pari
cap[0] = 100.0
PN.passo_fiscale(cap, np.array([100.0]), np.array([0.0]), c, 0.33, 0.0)
cap[0] = 130.0
imp = PN.passo_fiscale(cap, np.array([100.0]), np.array([0.0]), c, 0.33, 0.0)
assert imp[0] == pytest.approx(30 * 0.33), "la minusvalenza doveva essere scaduta"
def test_le_minusvalenze_piu_vecchie_si_usano_per_prime():
"""Se si consumassero le piu' recenti, quelle vecchie scadrebbero e il modello sarebbe
pessimista sul fisco senza dirlo."""
c = carry_vuoto(1)
c[0, 3] = 10.0 # vecchia, all'ultimo anno di utilizzo
c[0, 0] = 10.0 # recente
cap = np.array([105.0])
PN.passo_fiscale(cap, np.array([100.0]), np.array([0.0]), c, 0.33, 0.0) # +5 di plusvalenza
# a fine passo le colonne SLITTANO di un anno: la recente e' in posizione 1, intatta
assert c[0, 1] == pytest.approx(10.0), "doveva restare intatta la piu' recente"
# la vecchia e' stata consumata per 5 e il residuo e' scaduto insieme a lei: e' corretto,
# era al suo ultimo anno — ma va detto, perche' un residuo che sparisce sembra una perdita.
assert c[0].sum() == pytest.approx(10.0)
def test_la_patrimoniale_colpisce_il_valore_non_il_guadagno():
cap = np.array([1_000.0])
imp = PN.passo_fiscale(cap, np.array([1_000.0]), np.array([0.0]), carry_vuoto(1), 0.0, 0.002)
assert imp[0] == pytest.approx(2.0)
def test_le_imposte_non_portano_il_capitale_sotto_zero():
cap = np.array([1.0])
PN.passo_fiscale(cap, np.array([0.0]), np.array([0.0]), carry_vuoto(1), 0.33, 0.9)
assert cap[0] >= 0.0
# ===========================================================================
# le due macchine, validate contro cio' che sostituiscono
# ===========================================================================
def test_la_sopravvivenza_a_fisco_spento_e_quella_gia_in_uso():
"""Controllo di replica: `sopravvivenza(...,0,0)` deve essere `CC.survival` allo stesso seed.
E' l'unica cosa che rende leggibile la riga col fisco."""
r = np.array([0.001, -0.002, 0.003, 0.0, -0.001, 0.002] * 200)
mia = PN.sopravvivenza(r, 1e6, 60_000.0, 20, 0.0, 0.0, n_paths=400)
sua = CC.survival(r, 1e6, 60_000.0, 20, 1.0, n_paths=400, block=PN.BLOCK, seed=PN.SEED_MURO)
assert mia["p_survive"] == pytest.approx(sua["p_survive"], abs=0.0)
assert mia["p10_end"] == pytest.approx(sua["p10_end"], rel=1e-12)
def test_l_accumulo_a_fisco_spento_e_capitalizzazione_semplice():
paths = np.full((3, 400), 0.001)
out = PN.accumula(paths, 0.0, 1e12, 0.0, 0.0, start=1_000.0)
assert out["cap"][0] == pytest.approx(1_000.0 * 1.001 ** 400)
assert out["versato"] == 1_000.0
def test_il_fisco_non_puo_far_finire_con_PIU_soldi():
"""Direzione garantita: qualunque siano i path, tassare non arricchisce."""
rng = np.random.default_rng(0)
paths = rng.normal(0.0006, 0.006, (200, 365 * 3))
lordo = PN.accumula(paths, 250.0, 1e12, 0.0, 0.0)["cap"]
netto = PN.accumula(paths, 250.0, 1e12, 0.33, TX.PATRIMONIALE)["cap"]
assert (netto <= lordo + 1e-6).all()
def test_il_primo_passaggio_al_bersaglio_e_il_PRIMO():
"""`colpito` e' un primo passaggio: se il capitale tocca il bersaglio e poi scende, il
traguardo resta raggiunto — e non va aggiornato a un passaggio successivo."""
paths = np.concatenate([np.full((1, 10), 0.5), np.full((1, 10), -0.5)], axis=1)
out = PN.accumula(paths, 0.0, 2_000.0, 0.0, 0.0, start=1_000.0)
assert out["colpito"][0] == 1 # 1000 -> 1500 (t=0) -> 2250 (t=1), poi crolla e resta 1
def test_versamento_per_confidenza_e_monotono():
rng = np.random.default_rng(1)
paths = rng.normal(0.0006, 0.006, (300, 365 * 5))
v50 = PN.versamento_per_confidenza(paths, 60_000.0, 0.50, 0.33, TX.PATRIMONIALE)
v90 = PN.versamento_per_confidenza(paths, 60_000.0, 0.90, 0.33, TX.PATRIMONIALE)
assert v90 > v50 > 0
def test_un_bersaglio_irraggiungibile_ritorna_infinito_non_un_numero_grande():
"""Distinguere «serve tantissimo» da «non basta nemmeno il massimo cercato»: un numero
grande verrebbe letto come una risposta."""
paths = np.zeros((50, 365))
assert PN.versamento_per_confidenza(paths, 1e12, 0.9, 0.0, 0.0) == float("inf")
# ===========================================================================
# la costante pubblicata
# ===========================================================================
def test_il_bersaglio_citato_ovunque_e_quello_congelato():
"""Se qualcuno ricalcola il muro e aggiorna la costante, questo test dice che vanno rilette
tutte le tabelle che la usano — non e' un numero locale."""
assert LS.TARGET == 272_061.0