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:
@@ -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
|
||||
Reference in New Issue
Block a user