diff --git a/docs/diary/2026-08-07-piano-al-netto.md b/docs/diary/2026-08-07-piano-al-netto.md new file mode 100644 index 0000000..51c33e5 --- /dev/null +++ b/docs/diary/2026-08-07-piano-al-netto.md @@ -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. diff --git a/scripts/research/r0807_piano_netto.py b/scripts/research/r0807_piano_netto.py new file mode 100644 index 0000000..028c323 --- /dev/null +++ b/scripts/research/r0807_piano_netto.py @@ -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() diff --git a/tests/test_piano_netto.py b/tests/test_piano_netto.py new file mode 100644 index 0000000..ec9a324 --- /dev/null +++ b/tests/test_piano_netto.py @@ -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