Compare commits

4 Commits

Author SHA1 Message Date
Adriano Dal Pastro ba5ea2f5c3 docs: diario 07/08 e memoria — e un gate di GTAA01 che ORA FALLISCE
Diario 2026-08-07-crescita-fisco-etf-scelta.md + i bullet corrispondenti in
CLAUDE.md (fisco d'accumulo, confronto book/ETF, scelta 50/50, simulatore web).

Registra anche un buco trovato ricostruendo il piano: i QUATTRO risultati del
27/07 sera (r0727_3k_vs_5k, r0727_orizzonte10, r0727_tasse) non erano ne' in
CLAUDE.md ne' in un diario — vivevano solo nei messaggi di commit, e uno di essi
cambia il numero di testa del piano.

GATE GTAA01 CHE FALLISCE (test_gtaa_band_gate::test_la_proposta_non_e_selezionata
_sull_hold_out): la proposta e' 9a/30 in-sample e 8a/30 sull'hold-out contro il
4/30 e 5/30 registrato il 27/07 — cioe' migliora dove non doveva essere guardata.
Caratterizzato prima di riportarlo: i due ranghi distano 0.00116 di Sharpe su
un'ampiezza di griglia di 0.3124 (0.4%), quindi il criterio non ha mai avuto
margine; il calcolo e' deterministico (2 corse, max|diff| = 0.0) e il codice e'
invariato dal 27/07 -> e' cambiato il DATO, perche' data/raw/ e' gitignored e i
parquet equity sono riscritti ogni giorno dal cron con ADJUSTED_LAST di IB, che
e' retroattivo.

REGOLA NUOVA: un gate validato su dati sovrascritti ogni giorno non e'
ri-verificabile. Il lato cripto non ha il problema (rebuild_history.py ricostruisce
da sorgente deterministica), il lato equity si'.

Il test NON e' stato toccato: allentare una soglia perche' ha smesso di passare e'
proprio cio' che questo progetto vieta, e un xfail silenzierebbe il segnale.
Niente di operativo dipende da questo (GTAA01 non e' deployabile per il blocco
PRIIPs e non e' nel book live), ma l'affermazione "il rango NON migliora
sull'hold-out" oggi e' falsa e la decisione su cosa farne e' dell'operatore.

Book, pesi, cron, config/live.json e i gate pre-registrati: INVARIATI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 18:44:36 +00:00
Adriano Dal Pastro ed2532b991 feat(web): simulatore di accumulo nel browser, verificato contro il Python
scripts/web/ — motore in JavaScript (engine.js) sui ritorni VERI del book
esportati da export_series.py, pagine assemblate da build.py. Da "sistema
dinamico dove imposti valore iniziale, mensile e durata, e calcola la best curva
fissando il tempo": la simulazione gira nel browser, quindi il motore va
riscritto e VA PROVATO che dia la stessa risposta.

test_engine.js confronta mediane e probabilita' con r0807_growth_yearly.py su due
configurazioni, esige che il versato (deterministico) coincida al centesimo, e
include un controllo positivo — un motore col fisco spento DEVE risultare fuori
tolleranza, perche' un test che non sa fallire non e' un test. Il risolutore e'
validato contro dep_necessario di r0727_tasse.py: -0.4 / -0.6 / -1.1%.

smoke.js ESEGUE le pagine con un DOM finto. Serve perche' node --check valida solo
la sintassi: ho pubblicato una pagina che lo passava e moriva alla prima riga
utile (chiavi Python "0.0" ricostruite in JS come String(0) = "0"; i pesi
intermedi funzionavano per caso).

Altri due errori che questi strumenti hanno intercettato:
- due anni di dati di un grafico scritti A MEMORIA perche' tail aveva troncato
  l'output -> ora i dati si INIETTANO da JSON (build.py), il passaggio manuale
  non esiste piu';
- una "distorsione sistematica" del motore JS (+0.75%, 8 semi tutti positivi) che
  erano 8 estrazioni contro UN punto Python rumoroso. Misurato bene, 8 semi per
  parte: -0.01%, t = -0.04; ed entrambi i campionatori cadono entro 1.7 SE
  dall'atteso ANALITICO della media di blocco.

Scelta guidata da una misura: la banda del versamento suggerito resta +-1% da
1.200 a 3.000 percorsi -> non domina il Monte Carlo ma la granularita' della
bisezione (~5 EUR). Percorsi tenuti bassi e incertezza dichiarata, invece di
pagare tempo per una precisione che non arriva.

Nessun impatto sulla produzione: non tocca book, pesi, cron o config.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 18:44:13 +00:00
Adriano Dal Pastro 56141b0ffe research(portafoglio): book vs ETF — le due lenti si contraddicono, la scelta robusta e' 50/50
r0807_asset_compare.py (book / S&P 500 / MSCI World sullo stesso piano) e
r0807_best_strategy.py (quale peso scegliere a 12 anni). Da "e se mettessi in
MSCI World o ETF SP500?" e "quale e' la miglior strategia?".

Tre cose rese comparabili: la GRIGLIA (azioni su calendario con 0.0 a borsa
chiusa = convenzione GTAA01; senza, Sharpe x1.20), il FISCO (book realizza ogni
anno al 33%, UCITS ad accumulazione paga il 26% alla vendita -> le curve ETF sono
valori di liquidazione: il differimento e' un vantaggio strutturale dell'ETF e
va nel modello), il BERSAGLIO (272.061$ vale per la rendita perpetua DEL BOOK e
per il 33% -> ricalcolato per ciascuno).

Il risultato non e' chi vince, e' che le due lenti si contraddicono: sulla storia
piena il book arriva al 115% del proprio bersaglio e l'S&P al 32%; sulla stessa
finestra 118% e 110%, e l'S&P ACCUMULA PIU' del book. Il divario e' tutto nei
crolli 2000/2008 che la strategia non ha mai vissuto.

E sulla stessa finestra il rendimento e' quasi identico (17.4% vs 16.8%): la
differenza e' tutta nel rischio (vol 11.0 vs 19.6%, maxDD 10.5 vs 33.7%). Il book
non guadagna di piu', perde di meno — conferma indipendente di cio' che il
progetto scrive di TP01 dal 19/06, contro un'alternativa vera.

Difetto corretto in sessione: un MIX esiste solo dove esistono ENTRAMBE le serie.
La prima stesura confrontava "storia piena" contro "stessa finestra" ma calcolava
i bersagli del mix sull'intersezione in tutti e due i casi -> dichiarava 30 anni e
ne usava 7 (per l'ETF puro: rendita 8.63% invece del 4.16% vero). Rifatto su una
finestra sola con gli scenari come spostamento del drift, simmetrico sui due lati.

A 12 anni vince 50/50 sul RIMPIANTO massimo (20% contro 52% del book puro e 53%
dell'ETF puro); il criterio del solo caso peggiore non distingue 25% da 50%
(27.09 vs 26.99% = pareggio nel rumore). L'asimmetria fra i due stress e' il
risultato: quello sull'equity e' misurato (30 anni esistono), quello sul book e'
giudiziale (7.4 anni sono tutta la sua storia).

MSCI World e' un PROXY 70% SPY + 30% EFA: URTH/ACWI/VT non sono
nell'abbonamento dati del conto IB, e la nota lo dichiara invece di nasconderlo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 18:43:57 +00:00
Adriano Dal Pastro 68c6894d85 research(capitale): il fisco durante l'ACCUMULO non era mai stato contato
r0807_growth_yearly.py: crescita anno per anno separando versamenti e guadagno,
con e senza l'imposta d'accumulo. Nasce da "grafico della crescita per anno con
versamento e con guadagno".

TAX_RATE compariva in UN SOLO punto del progetto: la lordizzazione del bersaglio
in fase di PRELIEVO. L'accumulo componeva al lordo per dieci o vent'anni.
Costo (lump 5k + 500/mese): -15.7% a 5 anni, -29.8% a 10, -42.6% a 15 — replica
coerente col 27/07 su lump 10k (-16.8/-31/-44%). L'errore e' COMPOSTO.

Conseguenza: tutte le tabelle a 15-20 anni pubblicate in CLAUDE.md sono al lordo.

Contro-intuitivo e misurato: versare di piu' RITARDA il sorpasso (l'anno in cui
il guadagno cumulato supera il versato) — 7o anno a 500/mese, 8o a 800 — perche'
alza l'asticella. I 300 in piu' comprano il traguardo (12o anno invece del 15o,
P(bersaglio) a 15a da 73.2% a 99.0%), non il sorpasso.

Riusa senza riscriverle la contabilita' fiscale di r0727_tasse.accumula e il
block bootstrap di r0725_capcurve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 18:43:38 +00:00
14 changed files with 3447 additions and 0 deletions
+102
View File
@@ -1611,6 +1611,108 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis
deploy**, con questo gate come giustificazione. **REGOLA: un parametro d'ESECUZIONE scelto
guardando il risultato e' selezione come ogni altra** e passa per gli stessi gate, anche quando
"non tocca l'allocazione".
⚠️ **QUESTO GATE OGGI FALLISCE (2026-08-07) e la sua misura NON e' riproducibile.**
`test_la_proposta_non_e_selezionata_sull_hold_out`: la proposta e' **9ª/30 in-sample e 8ª/30
sull'hold-out** (migliora dove non doveva essere guardata) contro il **4/30 e 5/30** registrato
sopra. Caratterizzato: i due ranghi distano **0.00116 di Sharpe** su un'ampiezza di griglia di
0.3124 (**0.4%**) → *il criterio non ha mai avuto margine, era un rango*; il calcolo e'
**deterministico** (2 corse, `max|diff| = 0.0`) e il codice e' **invariato** dal 27/07 (nessun
commit su `r0727_gtaa_band_gate.py`, `gtaa.py`, `eqlib.py`, `eq_splits.py`) → **e' cambiato il
DATO**: `data/raw/` e' gitignored e i parquet equity sono riscritti ogni giorno dal cron con
`ADJUSTED_LAST` di IB, che e' retroattivo.
📌 **REGOLA NUOVA: un gate validato su dati SOVRASCRITTI ogni giorno non e' ri-verificabile.**
Il lato cripto non ha il problema (`rebuild_history.py` ricostruisce da sorgente deterministica),
il lato equity si'. **Test NON toccato** (allentare una soglia perche' ha smesso di passare e'
proprio cio' che questo progetto vieta; un `xfail` silenzierebbe il segnale). Niente di operativo
dipende da questo — GTAA01 non e' deployabile (PRIIPs) e non e' nel book live — ma
**l'affermazione "il rango NON migliora sull'hold-out" oggi e' falsa**: decisione dell'operatore
se correggerla, ripensare il gate, o congelare uno snapshot dei parquet equity.
Diario `2026-08-07-crescita-fisco-etf-scelta.md` §7.
- 💰 **IL FISCO DURANTE L'ACCUMULO — mai contato in nessuna traiettoria, vale 30% a 10 anni
(misurato 2026-07-27, registrato qui il 2026-08-07).** ⚠️ I quattro risultati del 27/07 sera
(`r0727_3k_vs_5k.py`, `r0727_orizzonte10.py`, `r0727_tasse.py`) erano **solo nei messaggi di
commit**, ne' in CLAUDE.md ne' in un diario — e uno cambia il numero di testa del piano.
`TAX_RATE` compariva in **un solo punto** del progetto: la lordizzazione del bersaglio in fase di
*prelievo*. L'accumulo componeva al **lordo** per dieci o vent'anni.
**Costo (lump €5.000 + €500/mese): 15.7% a 5 anni · 29.8% a 10 · 42.6% a 15**
(27/07 su lump €10k: 16.8 / 31 / 44% → replica coerente). **L'errore e' COMPOSTO.**
⚠️ **Conseguenza: TUTTE le tabelle a 15-20 anni pubblicate sopra sono al LORDO** del fisco
d'accumulo e vanno lette con questo sconto. Assunzioni dichiarate (33% plusvalenze, minusvalenze
in carry 4 anni, 0.2% annuo sul valore), **non un parere fiscale**; il modello tassa la variazione
ANNUA di valore = limite superiore, stretto perche' il book realizza quasi tutto entro l'anno.
**Vincolo dei 10 anni (operatore, 49 anni): il piano NON lo regge.** €5k+€500/m → P(entro 10a)
**19.7%** al lordo; €10k+€500/m → 33.9% lordo ma **3.8% col fisco**. Servono **€880/mese a P=50%**,
**€1.051 a P=75%** (lump €10k) → si versano oltre $119.000 per arrivare a $272.061: *a orizzonte
corto non fai lavorare la strategia, compri il capitale coi bonifici*.
📌 **Contro-intuitivo: versare di piu' RITARDA il sorpasso** (l'anno in cui il guadagno cumulato
supera tutto il versato): 7º anno a €500/m, **8º a €800/m** — alza l'asticella. I €300 in piu'
comprano il **traguardo**, non il sorpasso: mediana al bersaglio al 12º anno invece del 15º,
P(bersaglio) a 15a da 73.2% a **99.0%**. Lump €3k vs €5k = ~1.8 mesi ogni €1.000 → non e' una
decisione. Script `scripts/research/r0807_growth_yearly.py`.
- ⚖️ **BOOK vs ETF (S&P 500 / MSCI World) — le due lenti danno risposte OPPOSTE, e la scelta
robusta e' un MIX 50/50 (2026-08-07).** Script `r0807_asset_compare.py` + `r0807_best_strategy.py`;
diario `2026-08-07-crescita-fisco-etf-scelta.md`. **Book/pesi/cron/config INVARIATI.**
Tre cose rese comparabili: **griglia** (azioni su calendario con 0.0 a borsa chiusa = convenzione
GTAA01; senza, Sharpe ×1.20 — lezione 25/07), **fisco** (book realizza ogni anno al 33%; UCITS ad
accumulazione paga 26% **alla vendita** → le curve ETF sono valori di *liquidazione*: il
differimento e' un vantaggio strutturale dell'ETF ed e' nel modello), **bersaglio** ($272.061 vale
per la rendita perpetua *del book* e per il 33% → ricalcolato per ciascuno).
| a 15a, €5k+€500/m | book | S&P 500 | MSCI World (proxy) |
|---|---|---|---|
| rendita perpetua | 11.67% | 4.16% | 3.09% |
| capitale-rendita | $254.524 | $646.172 | $869.729 |
| lente A (storia piena) | $293.823 → **115%** | $204.663 → 32% | $188.417 → 22% |
| lente B (stessa finestra) | $295.540 → 118% | $343.805 → **110%** | $296.707 → 82% |
📌 **Il risultato non e' chi vince, e' che le due lenti si contraddicono:** sulla storia piena il
book stravince, **sulla stessa finestra l'S&P 500 accumula PIU' del book** — il divario della
lente A e' tutto nei crolli 2000/2008 che la strategia non ha mai vissuto.
📌 **E sulla stessa finestra il rendimento e' quasi identico — 17.4% contro 16.8%.** Tutta la
differenza e' nel RISCHIO (vol 11.0 vs 19.6%, maxDD 10.5 vs 33.7%): **il book non guadagna di
piu', perde di meno** — conferma indipendente di cio' che il progetto scrive di TP01 dal 19/06,
misurata contro un'alternativa vera. La rendita perpetua vive sul drawdown → **2.5× meno capitale**.
⚠️ **Un MIX esiste solo dove esistono ENTRAMBE le serie** (errore commesso e corretto: la lente
"storia piena" calcolava i bersagli del mix sull'intersezione → dichiarava 30 anni e ne usava 7).
Percio' la scelta si giudica su UNA finestra con gli scenari espressi come spostamento del
**drift**, simmetrico sui due lati. **A 12 anni, quota del proprio bersaglio:**
| w book | base | equity 30a | book/2 | entrambi cauti | peggiore | **rimpianto max** |
|---|---|---|---|---|---|---|
| 0% | 68% | 23% | 68% | 23% | 23% | 53% |
| 25% | 80% | 41% | 60% | 27% | 27% | 34% |
| **50%** | **86%** | 58% | 48% | 27% | 27% | **20%** |
| 75% | 84% | 69% | 32% | 23% | 23% | 35% |
| 100% | 75% | **75%** | 16% | 16% | 16% | 52% |
**Risposta: 50/50** — non perche' vinca (vince solo nel base) ma per il **rimpianto minimo**.
⚠️ Il criterio del solo caso peggiore **non** distingue 25% da 50% (27.09 vs 26.99% = pareggio
dentro il rumore): a separarli e' il rimpianto.
📌 **L'asimmetria fra i due stress E' il risultato:** quello sull'equity e' **MISURATO** (30 anni
esistono: 11.3% invece di 16.5%), quello sul book e' **GIUDIZIALE** (meta' del drift, a mano,
perche' 7.4 anni sono tutta la storia che ha e non c'e' nulla con cui stressarlo). Conseguenza
brutale: col drift dimezzato il capitale-rendita del book puro passa da $254k a **$771.646**
(rendita 11.67% → 3.85%).
**Perche' il mix non e' un compromesso:** corr book↔S&P **+0.082** (borsa aperta), **+0.046** nei
ribassi; nel 5% di giornate peggiori dell'indice (media 2.94%) il book fa **0.11%** → il
capitale-rendita del 50/50 e' **$235.769**, piu' basso sia del solo ETF ($311.567) sia del solo
book ($254.524): **due motori scorrelati abbassano l'asticella**.
⚠️ MSCI World e' un **PROXY** 70% SPY + 30% EFA (URTH/ACWI/VT non sono nell'abbonamento dati IB);
fra 50 e 80% di quota USA il drift si muove di 0.7pt e il Sharpe di 0.05. Il rischio di venue NON
e' nel conto (spingerebbe ancora verso il mix) e la decisione del 26/07 tiene tutto su Deribit
fino a $20k → **questa analisi e' materiale per quella soglia, non un'indicazione di agire ora**.
- 🖥️ **SIMULATORE NEL BROWSER — `scripts/web/` (2026-08-07).** Motore di accumulo in JavaScript
(`engine.js`) sui ritorni veri del book esportati da `export_series.py`; pagine assemblate da
`build.py` (dati **iniettati** da JSON, mai trascritti); `test_engine.js` prova che il motore
riproduce `r0807_growth_yearly.py` e `dep_necessario`; `smoke.js` ESEGUE le pagine con un DOM
finto. **REGOLE (quattro errori miei, tutti trovati da un controllo e non a occhio):**
(a) i dati di una pagina si **iniettano da un file**, mai si trascrivono — due anni di una serie
erano stati scritti *a memoria* perche' `tail` aveva troncato l'output;
(b) **un confronto punto-contro-distribuzione non prova una distorsione**: "8 semi JS tutti sopra
il Python, +0.75%" erano 8 estrazioni contro UN punto rumoroso → misurato bene (8 semi per parte)
**0.01%, t = 0.04**, ed entrambi i campionatori entro 1.7 SE dall'atteso ANALITICO;
(c) le chiavi di un dizionario Python si **leggono dal JSON**, non si ricostruiscono
(`"0.0"` vs `String(0)`=`"0"` → pagina pubblicata rotta, e i pesi intermedi funzionavano *per caso*);
(d) **`node --check` valida solo la SINTASSI**: una pagina puo' passarlo e morire alla prima riga.
Misura che ha guidato una scelta: la banda del versamento suggerito resta **±1% da 1.200 a 3.000
percorsi** → non domina il Monte Carlo ma la **granularita' della bisezione** (~€5) → percorsi
tenuti bassi e incertezza **dichiarata**.
- **Soffitto strutturale BTC/ETH-direzionale ~1.3** superato SOLO espandendo a un meccanismo diverso:
cross-sectional su universo Hyperliquid certificato (XS01) → portafoglio Sharpe ~1.55.
- **Sweep "strategie alternative" (2026-06-20) — 104 ipotesi / 153 agenti / NIENTE di nuovo regge.**
@@ -0,0 +1,224 @@
# 2026-08-07 — La crescita del capitale, il fisco che nessuno aveva contato, e se invece fosse un ETF
**Richieste dell'operatore, in sequenza:** *«stato progetto»**«riepiloga piano»* → *«grafico della
crescita per anno con versamento e con guadagno»* → *«con 800 al mese»* → *«sistema dinamico dove
imposti valore iniziale, mensile e durata, e calcola la best curva fissando il tempo»* → *«il
guadagno come viene calcolato? e se mettessi in MSCI World o ETF SP500?»* → *«quale è la miglior
strategia?»*.
**Esito: book, pesi, cron, config INVARIATI.** Nessuna decisione operativa cambia. Cambiano tre
numeri pubblicati e cade un'affermazione su GTAA01.
---
## 0. Il buco trovato per primo: quattro risultati mai registrati
Ricostruendo il piano per l'operatore ho scoperto che **quattro commit del 27/07 sera non sono né
in CLAUDE.md né in un diario**: vivono solo nei messaggi di commit e negli script
(`r0727_3k_vs_5k.py`, `r0727_orizzonte10.py`, `r0727_tasse.py`). Uno di questi cambia il numero di
testa del piano — vedi §2. È il motivo per cui questo diario esiste prima ancora del lavoro nuovo.
---
## 1. Come si calcola il «guadagno» — la domanda che andava fatta
Nessuna traiettoria del progetto ipotizza un rendimento. Il meccanismo, ora scritto:
1. si parte dai ritorni giornalieri **veri** del book live (TP01 75% + SKH01 25%), 2.692 giorni dal
2019-03-14, già netti di commissioni;
2. si toglie la fortuna d'ancora: `r (10.89)·media(r)`;
3. si **rimescolano a blocchi di 20 giorni** (block bootstrap): blocchi, non giorni singoli, per
non distruggere il raggruppamento della volatilità;
4. si compone: `cap *= (1+r)` ogni giorno, deposito ogni 30, imposta a fine anno, su 4.000 percorsi;
5. **`guadagno = mediana(capitale) versato`**.
Drift implicito: **17,1% annuo, vol 10,9%**. Non è un'assunzione sul rendimento — è l'assunzione
che *il futuro somigli a quei 7 anni*. Che è esattamente ciò che §3 mette alla prova.
Script: `scripts/research/r0807_growth_yearly.py` (`--lump`, `--dep`, `--anni`, `--json`).
---
## 2. ⚠️ Il fisco durante l'ACCUMULO — la voce mai contata
`TAX_RATE` compariva in **un solo punto** di tutto il progetto: la lordizzazione del bersaglio in
fase di *prelievo*. L'accumulo componeva al lordo per dieci o vent'anni. Misurato il 27/07
(`r0727_tasse.py`) e riprodotto oggi su lump €5.000 + €500/mese:
| | 5 anni | 10 anni | 15 anni |
|---|---|---|---|
| costo del fisco d'accumulo | **15,7%** | **29,8%** | **42,6%** |
(27/07, lump €10k: 16,8% / 31% / 44% → replica coerente su lump diverso.)
**L'errore è composto, non una tantum.** Conseguenza da tenere presente: *tutte* le tabelle a 15-20
anni pubblicate in CLAUDE.md sono al lordo del fisco d'accumulo e vanno lette con questo sconto
sopra. Assunzioni dichiarate (33% plusvalenze, minusvalenze in carry 4 anni, 0,2% annuo sul valore),
**non un parere fiscale**.
Piano a €800/mese, 15 anni: capitale mediano **$450.107** netto contro $776.366 lordo.
📌 **Risultato contro-intuitivo:** versare di più **ritarda il sorpasso** (l'anno in cui il guadagno
cumulato supera tutto il versato): 7º anno a €500/mese, 8º a €800, perché si alza l'asticella che
il guadagno deve superare. I €300 in più non comprano il sorpasso, comprano il **traguardo**: la
mediana tocca il bersaglio al 12º anno invece che al 15º, e P(bersaglio) a 15 anni passa da 73,2% a
99,0%.
⚠️ **€800/mese non bastano per il vincolo dei 10 anni**: P(entro 10 anni) col fisco = **25,5%**.
Servono ~€880-1.050/mese (27/07), e a quel punto si versano oltre $119.000 per arrivare a $272.061.
---
## 3. E se invece fosse un ETF S&P 500 o MSCI World
Script `scripts/research/r0807_asset_compare.py`. Tre cose andavano rese comparabili:
- **la griglia** — le azioni hanno ~252 barre/anno, il book 365: portate su calendario con **0,0 a
borsa chiusa** (convenzione GTAA01). Senza, il Sharpe esce ×1,20 (lezione 25/07);
- **il fisco** — il book realizza ogni anno al 33%; un UCITS ad accumulazione paga il 26% **alla
vendita**. Il differimento è un vantaggio strutturale dell'ETF, ora nel modello (le curve ETF
sono valori di *liquidazione*, già al netto dell'imposta latente);
- **il bersaglio** — $272.061 vale per la rendita perpetua *del book* e per il 33%. Ricalcolato per
ciascuno.
| a 15 anni, €5.000 + €500/mese | book | S&P 500 | MSCI World (proxy) |
|---|---|---|---|
| rendita perpetua | 11,67% | 4,16% | 3,09% |
| capitale-rendita necessario | $254.524 | $646.172 | $869.729 |
| **lente A** — storia piena di ciascuno | $293.823 → **115%** | $204.663 → **32%** | $188.417 → 22% |
| **lente B** — stessa finestra 2019-26 | $295.540 → **118%** | $343.805 → **110%** | $296.707 → 82% |
**Il risultato che conta non è quale vince, è che le due lenti danno risposte opposte.** Sulla
storia piena il book stravince; sulla stessa finestra **l'S&P 500 accumula più del book**. Il
divario della lente A viene interamente dal fatto che i 30 anni dell'indice contengono il 2000 e il
2008, crolli che la strategia non ha mai vissuto.
📌 **E in una riga della tabella delle serie c'è il fatto più importante:** sulla stessa finestra il
rendimento è quasi identico — **17,4% contro 16,8%**. Tutta la differenza è nel rischio: vol 11,0%
contro 19,6%, maxDD 10,5% contro 33,7%. **Il book non guadagna di più, perde di meno** — che è
precisamente ciò che il progetto scrive di TP01 dal 19/06, qui misurato contro un'alternativa vera.
E la rendita perpetua vive sul drawdown: per questo il book ha bisogno di **2,5× meno capitale**.
⚠️ MSCI World è un **proxy** (70% SPY + 30% EFA): URTH/ACWI/VT non sono nell'abbonamento dati del
conto IB. Fra 50% e 80% di quota USA il drift si muove di 0,7 punti e il Sharpe di 0,05.
---
## 4. Quale strategia: book, ETF o un mix
Script `scripts/research/r0807_best_strategy.py`.
⚠️ **Difetto della prima stesura, corretto: un mix esiste solo dove esistono ENTRAMBE le serie.**
Confrontavo «storia piena» contro «stessa finestra» come due lenti, ma calcolavo i bersagli del mix
sull'intersezione in entrambe — quindi la lente «storia piena» dichiarava trent'anni e ne usava
sette (per l'ETF puro dava rendita 8,63% invece del 4,16% vero). **Il confronto fra finestre non è
disponibile per un portafoglio misto.** Rifatto su una finestra sola, con gli scenari espressi come
spostamento del **drift**, applicabile simmetricamente ai due lati.
| quota sul book | base | equity a 30 anni | book dimezzato | entrambi cauti | peggiore | **rimpianto max** |
|---|---|---|---|---|---|---|
| solo ETF | 68% | 23% | 68% | 23% | 23% | 53% |
| 25% book | 80% | 41% | 60% | 27% | 27% | 34% |
| **50% book** | **86%** | 58% | 48% | 27% | 27% | **20%** |
| 75% book | 84% | 69% | 32% | 23% | 23% | 35% |
| solo book | 75% | **75%** | 16% | 16% | 16% | 52% |
**Risposta: 50/50**, non perché vinca (vince solo nello scenario base) ma perché ha il **rimpianto
massimo più basso**. ⚠️ Il criterio del solo caso peggiore **non** distingue 25% da 50% (27,09%
contro 26,99% = pareggio dentro il rumore): a separarli è il rimpianto.
📌 **L'asimmetria fra i due stress È il risultato.** Quello sull'equity è **misurato** — trent'anni
esistono e dicono 11,3% invece di 16,5%. Quello sul book è **giudiziale** — metà del drift, scelto a
mano, perché 7,4 anni sono tutta la storia che ha e *non c'è nulla con cui stressarlo*. Si può
discutere la taglia del taglio, non lo si può togliere. La sua conseguenza è brutale: col drift
dimezzato il capitale-rendita del book puro passa da $254k a **$771.646** (rendita 11,67% → 3,85%).
**Perché il mix non è un compromesso:** correlazione book↔S&P **+0,082** nei giorni di borsa aperta,
**+0,046** nei ribassi; nel 5% di giornate peggiori dell'indice (media 2,94%) il book fa **0,11%**.
Il capitale-rendita del 50/50 è **$235.769**, più basso sia del solo ETF ($311.567) sia del solo
book ($254.524): due motori scorrelati *abbassano l'asticella*.
⚠️ Non c'è il rischio di venue (spingerebbe ancora verso il mix), e la decisione del 26/07 tiene
tutto su Deribit fino a $20k: questa analisi è **materiale per quella soglia**, non un'indicazione
di agire adesso.
---
## 5. Il simulatore nel browser, e la sua verifica
`scripts/web/` — motore di accumulo in JavaScript (`engine.js`), serie esportata da
`export_series.py`, pagine assemblate da `build.py`.
**Il motore JS deve dare la stessa risposta del Python, e lo si prova:** `test_engine.js` confronta
mediane e probabilità su due configurazioni già calcolate, verifica che il *versato* (deterministico)
coincida al centesimo, e include un **controllo positivo** (un motore col fisco spento dev'essere
rilevato fuori tolleranza — un test che non sa fallire non è un test). Il risolutore («quanto serve
al mese per centrare il bersaglio in N anni») è validato contro `dep_necessario` di `r0727_tasse.py`:
scarti 0,4% / 0,6% / 1,1% su tre configurazioni.
Misura che ha cambiato una scelta: la banda del versamento suggerito resta **±1% da 1.200 a 3.000
percorsi** → non domina il Monte Carlo ma la granularità della bisezione (~€5). Percorsi tenuti
bassi e **incertezza dichiarata**, invece di pagare tempo per una precisione che non arriva.
---
## 6. Quattro errori miei, tutti trovati da un controllo e non a occhio
Sono la parte riutilizzabile del diario.
**(a) Dati inventati in un grafico.** Le righe degli anni 1-2 di una serie erano scritte *a memoria*
perché l'output del terminale era troncato da `tail`. Le ha trovate il confronto riga-per-riga fra
il JSON e i dati incollati nella pagina. **Regola: i dati di una pagina si INIETTANO da un file, mai
si trascrivono** — ora è `build.py` a farlo, e il passaggio manuale non esiste più.
**(b) «Distorsione sistematica» che non esisteva.** Il motore JS sembrava +0,75% sopra il Python su
8 semi, tutti positivi. Ma erano **8 estrazioni contro UN solo punto Python** (seme 807), che stava
in basso nella propria distribuzione: 8 segni concordi rispetto a un punto rumoroso non sono 8
osservazioni indipendenti. Misurato bene (8 semi per parte): **0,01%, t = 0,04**; e i due
campionatori cadono entrambi entro 1,7 SE dall'**atteso analitico** della media di blocco.
**Regola: un confronto punto-contro-distribuzione non produce evidenza di distorsione.**
**(c) Un confronto impossibile per costruzione** — §4, il mix sull'intersezione.
**(d) Una pagina pubblicata rotta.** Python scrive le chiavi dei pesi come `"0.0"`/`"1.0"`, la pagina
le ricostruiva con `String(0)``"0"`: ogni lookup `undefined`, script morto alla prima riga utile.
I pesi intermedi funzionavano *per caso*. **Il controllo che usavo (`node --check`) valida solo la
SINTASSI**: ora c'è `smoke.js`, che esegue le pagine con un DOM finto e segnala sia le eccezioni sia
gli elementi riempiti con `"undefined"`. **Regola: le chiavi di un dizionario Python si LEGGONO dal
JSON, non si ricostruiscono.**
---
## 7. ⚠️ Effetto collaterale del check: un gate di GTAA01 ora FALLISCE
`tests/test_gtaa_band_gate.py::test_la_proposta_non_e_selezionata_sull_hold_out` — la proposta
(banda 25%, cadenza 5g) risulta **9ª/30 in-sample e 8ª/30 sull'hold-out**: migliora passando
all'hold-out, cioè la firma che il test intercetta. CLAUDE.md registra **4/30 e 5/30** (27/07).
Caratterizzato prima di riportarlo:
- i due ranghi sono separati da **0,00116 di Sharpe** su un'ampiezza di griglia di 0,3124 = lo
**0,4%**: il criterio non ha mai avuto margine, era una differenza di un rango;
- il calcolo è **deterministico** (due corse, `max|diff| = 0.0`) e il codice del gate è **invariato**
dal 27/07 (nessun commit su `r0727_gtaa_band_gate.py`, `gtaa.py`, `eqlib.py`, `eq_splits.py`);
- quindi è cambiato **il dato**: `data/raw/` è gitignored e i parquet equity vengono riscritti ogni
giorno dal cron con `ADJUSTED_LAST` di IB, che è retroattivo.
📌 **La lezione vale più del test: un gate validato su dati sovrascritti ogni giorno non è
ri-verificabile.** Il lato cripto non ha questo problema (`rebuild_history.py` ricostruisce da una
sorgente deterministica); il lato equity sì.
**Il test NON è stato toccato** — allentare una soglia perché ha smesso di passare è ciò che questo
progetto vieta, e un `xfail` silenzierebbe esattamente il segnale. Nulla di operativo dipende da
questo (GTAA01 non è deployabile per il blocco PRIIPs e non è nel book live), ma l'affermazione
*«il rango NON migliora sull'hold-out»* oggi è falsa: **decisione dell'operatore** se correggere
l'affermazione, ripensare il gate, o congelare uno snapshot dei parquet equity per renderlo
riproducibile.
---
## Cosa NON cambia
Book, pesi, cron, `config/live.json`, i gate pre-registrati (STATARB 27/09, XSR01 23/10,
DVOLSPREAD 24/10), la decisione di venue (100% Deribit fino a $20k), il muro pubblicato di
$272.061. Gli script di questo diario non toccano la produzione.
+279
View File
@@ -0,0 +1,279 @@
#!/usr/bin/env python
"""r0807_asset_compare.py — e se invece del book ci mettessi un ETF S&P 500 o MSCI World?
DOMANDA DELL'OPERATORE. Confronto onesto fra tre motori per lo STESSO piano di accumulo:
BOOK il book live Deribit (TP01 75% + SKH01 25%) alle ancore canoniche, de-luckato x0.89
SP500 SPY, ADJUSTED_LAST (dividendi reinvestiti), 1996+ -> 30 anni
WORLD MSCI World: URTH se certificato, altrimenti proxy 70% SPY + 30% EFA
Il confronto e' interessante SOLO se sono comparabili quattro cose, e tre di queste il progetto
le ha gia' sbagliate almeno una volta:
(1) LA GRIGLIA. Le azioni hanno ~252 barre/anno, il book 365. Una serie di borsa passata a un
motore che annualizza a 365 esce con Sharpe x1.20 e CAGR x1.45 (lezione del 25/07 su GTAA01).
Qui le azioni vanno su griglia di CALENDARIO con 0.0 nei giorni di chiusura — la convenzione
gia' adottata per GTAA01: il capitale e' fermo, non riciclato.
(2) LA FINESTRA. Il book ha 7.4 anni (2019-2026, due tori cripto); SPY ne ha 30 (2000, 2008,
2020, 2022 dentro). Confrontare 7 anni scelti dalla storia recente contro 30 anni e' un
confronto truccato a favore del book -> si riporta ANCHE la finestra comune.
(3) IL FISCO, che NON e' lo stesso e non e' un dettaglio:
book -> realizza in continuo, imposta ANNUA sulle plusvalenze (33% assunto) + 0.2% sul valore
ETF -> UCITS ad accumulazione: nessuna distribuzione, imposta DIFFERITA alla vendita (26%)
+ 0.2% annuo sul valore
Il differimento e' un vantaggio strutturale dell'ETF che il book non ha, e va nel modello.
(4) IL BERSAGLIO. $272.061 e' il capitale-rendita calcolato con la rendita perpetua DEL BOOK e
l'aliquota del 33%. Un ETF ha un'altra distribuzione e un'altra aliquota -> ha un ALTRO
bersaglio, che qui si ricalcola per ciascun motore invece di riusare il numero del book.
⚠️ Le assunzioni fiscali sono dichiarate e NON sono un parere fiscale.
uv run python scripts/research/r0807_asset_compare.py [--dep 500] [--lump 5000] [--anni 15]
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
import eqlib as EQ # noqa: E402
import r0725_capcurve as CC # noqa: E402
import r0726_venue_risk as VR # noqa: E402
import r0726_capwall_refresh as WR # noqa: E402
import r0727_lumpsum_split as LS # noqa: E402
import r0807_growth_yearly as G # noqa: E402
TAX_CRYPTO = 0.33 # L.199/2025 (assunzione gia' cablata nel progetto)
TAX_ETF = 0.26 # redditi diversi di natura finanziaria
PATRIM = 0.002 # 0.2% annuo sul valore (IVAFE / imposta di bollo)
COMM_EUR = 2.0 # commissione per acquisto mensile su un broker retail europeo
N_PATHS = 4000
SEED = 807
# ------------------------------------------------------------------ serie
def _calendar_daily(close: pd.Series) -> pd.Series:
"""Ritorni giornalieri su griglia di CALENDARIO: 0.0 quando la borsa e' chiusa.
E' la convenzione di GTAA01 (capitale fermo nel weekend, non riciclato) ed e' cio' che rende
la serie confrontabile con quella del book, che ha 365 barre l'anno.
"""
r = close.astype(float).pct_change().dropna()
r.index = pd.to_datetime(r.index).tz_convert(None).normalize()
r = r[~r.index.duplicated(keep="last")]
idx = pd.date_range(r.index[0], r.index[-1], freq="D")
return r.reindex(idx).fillna(0.0)
def carica_serie() -> dict:
out = {}
b = VR.venue_series()["Deribit"]
b.index = pd.to_datetime(b.index).tz_localize(None).normalize()
out["BOOK"] = b.astype(float)
out["SP500"] = _calendar_daily(EQ.load_eq("SPY")["close"])
# MSCI World: il vero ETF se e' su disco, altrimenti il proxy dichiarato
world_path = ROOT / "data" / "raw" / "eq_urth_1d.parquet"
if world_path.exists():
out["WORLD"] = _calendar_daily(EQ.load_eq("URTH")["close"])
out["_world_fonte"] = "URTH (iShares MSCI World, reale)"
else:
spy, efa = _calendar_daily(EQ.load_eq("SPY")["close"]), _calendar_daily(EQ.load_eq("EFA")["close"])
j = pd.concat({"SPY": spy, "EFA": efa}, axis=1).dropna()
out["WORLD"] = (0.70 * j["SPY"] + 0.30 * j["EFA"])
out["_world_fonte"] = "PROXY 70% SPY + 30% EFA (ribil. giornaliero) — non e' l'indice"
return out
def metriche(r: pd.Series) -> dict:
eq = (1 + r).cumprod()
dd = float((eq / eq.cummax() - 1).min())
mu, sd = float(r.mean()) * 365, float(r.std()) * np.sqrt(365)
return dict(n=len(r), da=str(r.index[0].date()), a=str(r.index[-1].date()),
anni=len(r) / 365.0, drift=mu, vol=sd, sharpe=mu / sd if sd > 0 else 0.0, maxdd=dd)
# ------------------------------------------------------------------ accumulo
def accumula(r: np.ndarray, lump_eur: float, dep_eur: float, anni: int,
differita: bool, aliquota: float, comm_eur: float) -> list[dict]:
"""Traiettoria annuale con due regimi fiscali.
* `differita=False` (book): imposta ANNUA sulle plusvalenze realizzate + patrimoniale.
E' esattamente `r0807_growth_yearly.traiettoria`, riusata senza riscriverla.
* `differita=True` (ETF ad accumulazione): nessuna imposta annua sul reddito, solo la
patrimoniale; l'imposta sul guadagno si paga alla VENDITA -> la curva riportata e' il
valore di LIQUIDAZIONE, `cap - aliquota * max(0, cap - versato)`, che e' l'unica cosa
confrontabile con un book gia' tassato ogni anno.
La commissione d'acquisto si modella come deposito netto piu' piccolo, ma il `versato`
riportato resta quello USCITO DI TASCA: cosi' la commissione pesa sul guadagno, dov'e' giusto.
"""
dep_netto = max(0.0, dep_eur - comm_eur) if dep_eur > 0 else 0.0
righe = G.traiettoria(r, lump_eur, dep_netto, anni,
0.0 if differita else aliquota, PATRIM,
n_paths=N_PATHS, seed=SEED)
out = []
for x in righe:
n_dep = round((x["versato"] - LS.START - lump_eur * CC.EURUSD) / (dep_netto * CC.EURUSD)) \
if dep_netto > 0 else 0
versato_reale = LS.START + lump_eur * CC.EURUSD + n_dep * dep_eur * CC.EURUSD
med, p25, p75 = x["capitale_med"], x["capitale_p25"], x["capitale_p75"]
if differita:
netto = lambda c: c - aliquota * max(0.0, c - versato_reale) # noqa: E731
med, p25, p75 = netto(med), netto(p25), netto(p75)
out.append(dict(anno=x["anno"], versato=versato_reale, med=med, p25=p25, p75=p75,
guadagno=med - versato_reale, phit_book=x["p_traguardo"]))
return out
def bersaglio(r: np.ndarray, aliquota: float) -> tuple[float, float]:
"""(rendita perpetua, capitale necessario) per €50/giorno NETTI con l'aliquota di quel motore."""
lordo = CC.TARGET_EUR_DAY * 365 * CC.EURUSD / (1 - aliquota)
_, perp, cap = WR.perp_and_wall(r, 1.0, lordo)
return perp, cap
# ------------------------------------------------------------------ main
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--lump", type=float, default=5000.0)
ap.add_argument("--dep", type=float, default=500.0)
ap.add_argument("--anni", type=int, default=15)
ap.add_argument("--json", type=str, default="")
a = ap.parse_args()
S = carica_serie()
motori = ["BOOK", "SP500", "WORLD"]
print("=" * 104)
print(" r0807 — BOOK vs S&P 500 vs MSCI WORLD, stesso piano di accumulo")
print("=" * 104)
print(f" piano: ${LS.START:,.0f} + €{a.lump:,.0f} + €{a.dep:,.0f}/mese · {a.anni} anni · "
f"{N_PATHS:,} path, blocchi 20g")
print(f" MSCI World = {S['_world_fonte']}")
print("\n (1) LE SERIE — griglia di calendario, 0.0 a borsa chiusa")
print(f" {'motore':8}{'barre':>8}{'da':>13}{'a':>13}{'anni':>7}{'drift':>9}{'vol':>8}"
f"{'Sharpe':>8}{'maxDD':>8}")
for m in motori:
x = metriche(S[m])
print(f" {m:8}{x['n']:>8}{x['da']:>13}{x['a']:>13}{x['anni']:>7.1f}"
f"{x['drift']:>8.1%}{x['vol']:>8.1%}{x['sharpe']:>8.2f}{x['maxdd']:>8.1%}")
# finestra comune: il confronto onesto sul REGIME
lo = max(S[m].index[0] for m in motori)
hi = min(S[m].index[-1] for m in motori)
print(f"\n (2) STESSA FINESTRA {lo.date()}{hi.date()} — toglie il vantaggio di aver scelto "
f"gli anni")
print(f" {'motore':8}{'anni':>7}{'drift':>9}{'vol':>8}{'Sharpe':>8}{'maxDD':>8}")
comune = {}
for m in motori:
comune[m] = S[m].loc[lo:hi]
x = metriche(comune[m])
print(f" {m:8}{x['anni']:>7.1f}{x['drift']:>8.1%}{x['vol']:>8.1%}{x['sharpe']:>8.2f}"
f"{x['maxdd']:>8.1%}")
# bersagli propri
print("\n (3) IL BERSAGLIO E' DIVERSO PER OGNI MOTORE (rendita perpetua + aliquota propria)")
print(f" {'motore':8}{'aliquota':>10}{'rendita perpetua':>19}{'capitale per €50/g netti':>27}")
berg = {}
for m in motori:
al = TAX_CRYPTO if m == "BOOK" else TAX_ETF
perp, cap = bersaglio(S[m].values.astype(float), al)
berg[m] = dict(perp=perp, cap=cap, aliquota=al)
cs = f"${cap:,.0f}" if np.isfinite(cap) else "non sostenibile"
print(f" {m:8}{al:>9.0%}{perp:>18.2%}{cs:>27}")
# bersaglio anche sulla finestra comune — altrimenti si confronta un accumulo di 7 anni
# con un bersaglio stimato su 30. ⚠️ Questa colonna e' la MENO affidabile delle due: un
# prelievo perpetuo e' una domanda a 20 anni e 7 anni di storia non la risolvono.
print("\n (3-bis) LO STESSO BERSAGLIO, ma stimato sulla sola finestra comune (poco "
"affidabile: 7 anni)")
print(f" {'motore':8}{'rendita perpetua':>19}{'capitale':>14}{'vs finestra piena':>20}")
berg_c = {}
for m in motori:
al = berg[m]["aliquota"]
perp, cap = bersaglio(comune[m].values.astype(float), al)
berg_c[m] = dict(perp=perp, cap=cap, aliquota=al)
rel = cap / berg[m]["cap"] - 1 if np.isfinite(cap) and np.isfinite(berg[m]["cap"]) else float("nan")
cs = f"${cap:,.0f}" if np.isfinite(cap) else "non sostenibile"
print(f" {m:8}{perp:>18.2%}{cs:>14}{rel:>19.0%}")
# accumulo, ciascuno col proprio regime
print(f"\n (4) ACCUMULO a {a.anni} anni — ciascuno col PROPRIO fisco e i propri costi")
print(f" {'motore':8}{'versato':>13}{'capitale med':>15}{'p25':>13}{'p75':>13}"
f"{'guadagno':>13}{'% del cap.':>12}")
traj = {}
for m in motori:
t = accumula(S[m].values.astype(float), a.lump, a.dep, a.anni,
differita=(m != "BOOK"),
aliquota=TAX_CRYPTO if m == "BOOK" else TAX_ETF,
comm_eur=0.0 if m == "BOOK" else COMM_EUR)
traj[m] = t
L = t[-1]
print(f" {m:8}{L['versato']:>12,.0f}${L['med']:>14,.0f}${L['p25']:>12,.0f}$"
f"{L['p75']:>12,.0f}${L['guadagno']:>12,.0f}${L['guadagno']/L['med']:>11.0%}")
# (4-bis) l'accumulo sulla STESSA finestra: e' il confronto che toglie il regime
print(f"\n (4-bis) LO STESSO ACCUMULO, ma ricampionando SOLO {lo.date()}{hi.date()} "
f"per tutti e tre")
print(f" {'motore':8}{'capitale med':>15}{'p25':>13}{'p75':>13}{'guadagno':>13}"
f"{'vs finestra piena':>20}")
traj_c = {}
for m in motori:
t = accumula(comune[m].values.astype(float), a.lump, a.dep, a.anni,
differita=(m != "BOOK"),
aliquota=TAX_CRYPTO if m == "BOOK" else TAX_ETF,
comm_eur=0.0 if m == "BOOK" else COMM_EUR)
traj_c[m] = t
L, F = t[-1], traj[m][-1]
print(f" {m:8}{L['med']:>14,.0f}${L['p25']:>12,.0f}${L['p75']:>12,.0f}$"
f"{L['guadagno']:>12,.0f}${L['med']/F['med']-1:>19.0%}")
print("\n (5) QUOTA DEL PROPRIO BERSAGLIO RAGGIUNTA (ognuno il suo)")
for m in motori:
L, c = traj[m][-1], berg[m]["cap"]
if np.isfinite(c) and c > 0:
print(f" {m:8} capitale ${L['med']:>10,.0f} · bersaglio ${c:>10,.0f}"
f" · {L['med'] / c:>6.0%}")
else:
print(f" {m:8} capitale ${L['med']:>10,.0f} · bersaglio non sostenibile")
# sensibilita' del proxy World: la quota USA dell'indice e' cambiata molto nel tempo
if "PROXY" in S["_world_fonte"]:
spy, efa = _calendar_daily(EQ.load_eq("SPY")["close"]), _calendar_daily(EQ.load_eq("EFA")["close"])
j = pd.concat({"SPY": spy, "EFA": efa}, axis=1).dropna()
print("\n (6) SENSIBILITA' DEL PROXY — la quota USA di MSCI World e' passata da ~50% "
"(2008) a ~72% (oggi)")
print(f" {'peso USA':>10}{'drift':>9}{'vol':>8}{'Sharpe':>8}{'maxDD':>8}")
for w in (0.5, 0.6, 0.7, 0.8):
x = metriche(w * j["SPY"] + (1 - w) * j["EFA"])
print(f" {w:>9.0%}{x['drift']:>8.1%}{x['vol']:>8.1%}{x['sharpe']:>8.2f}{x['maxdd']:>8.1%}")
if a.json:
Path(a.json).write_text(json.dumps(dict(
piano=dict(lump=a.lump, dep=a.dep, anni=a.anni, start=LS.START, eurusd=CC.EURUSD,
n_paths=N_PATHS, comm_eur=COMM_EUR, tax_crypto=TAX_CRYPTO,
tax_etf=TAX_ETF, patrim=PATRIM, world=S["_world_fonte"]),
metriche={m: metriche(S[m]) for m in motori},
metriche_comune={m: metriche(comune[m]) for m in motori},
finestra_comune=dict(da=str(lo.date()), a=str(hi.date())),
bersaglio={m: berg[m] for m in motori},
bersaglio_comune={m: berg_c[m] for m in motori},
traiettoria=traj, traiettoria_comune=traj_c), indent=1))
print(f"\n scritto {a.json}")
if __name__ == "__main__":
main()
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env python
"""r0807_best_strategy.py — a N anni, qual e' la scelta migliore fra book, ETF e un mix?
DOMANDA DELL'OPERATORE, dopo il confronto book/S&P500/MSCI World (r0807_asset_compare.py).
⚠️ VINCOLO STRUTTURALE, scoperto sbagliando: **un mix esiste solo dove esistono ENTRAMBE le
serie**. La prima stesura confrontava «storia piena» contro «stessa finestra» come due lenti
alternative, ma calcolava i bersagli del mix sull'intersezione in tutti e due i casi — quindi la
lente «storia piena» dichiarava trent'anni e ne usava sette (per w=0 dava rendita 8.63% invece del
4.16% misurato sui 30 anni veri). Il confronto fra finestre NON e' disponibile per un portafoglio
misto, e fingere che lo sia produce numeri incoerenti dentro la stessa riga.
L'IMPIANTO CORRETTO. Una sola finestra — quella comune, l'unica dove il mix e' definito — e la
diversita' di scenario espressa come **spostamento del drift**, che si puo' applicare a entrambi
i lati in modo simmetrico. Quattro scenari:
base drift misurati sulla finestra comune
equity lungo S&P 500 riportato al suo drift MISURATO su 30 anni; book invariato
book dimezzato book a meta' drift; S&P 500 sulla finestra comune
entrambi cauti S&P 500 a 30 anni E book a meta'
⚠️ L'asimmetria fra i due stress E' il risultato, non un difetto: lo scenario prudente sull'equity
e' MISURATO (trent'anni di storia esistono), quello sul book e' GIUDIZIALE (7.4 anni sono tutta la
storia che ha — non c'e' nessun dato con cui stressarlo). Si puo' sbagliare la taglia dello stress
sul book; non si puo' toglierlo.
In ogni scenario la VOLATILITA' e la forma del rischio restano quelle vere: si sposta solo il
rendimento medio, come gia' fa la correzione d'ancora del progetto (`r - (1-f)*mean(r)`).
MODELLO DEL MIX, dichiarato: i versamenti si dividono per peso fisso e i due salvadanai crescono
SEPARATI, senza ribilanciamento (ribilanciare vorrebbe dire vendere e realizzare, e i due lati
hanno regimi fiscali diversi: book 33% ogni anno, ETF 26% alla vendita). Il bersaglio del mix usa
la serie a pesi fissi ribilanciata giornalmente, che e' il portafoglio della fase di PRELIEVO.
uv run python scripts/research/r0807_best_strategy.py [--anni 12]
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
import r0725_capcurve as CC # noqa: E402
import r0726_capwall_refresh as WR # noqa: E402
import r0727_lumpsum_split as LS # noqa: E402
import r0807_asset_compare as AC # noqa: E402
PESI = [0.0, 0.25, 0.50, 0.75, 1.00] # quota sul BOOK; il resto sull'ETF S&P 500
BOOK_HAIRCUT = 0.5 # scenario prudente sul book: meta' drift (GIUDIZIALE)
N_PATHS = 4000
def set_drift(r: pd.Series, annuo: float) -> pd.Series:
"""Sposta il drift di `r` al valore annuo dato, lasciando INTATTA la volatilita'."""
return r - float(r.mean()) + annuo / 365.0
def perp_e_bersaglio(serie: np.ndarray, aliquota: float) -> tuple[float, float]:
lordo = CC.TARGET_EUR_DAY * 365 * CC.EURUSD / (1 - aliquota)
_, perp, cap = WR.perp_and_wall(serie, 1.0, lordo)
return perp, float(cap)
def accumulo_mix(rb: np.ndarray, re_: np.ndarray, w: float, lump: float, dep: float,
anni: int) -> dict:
"""Due salvadanai separati, ciascuno col proprio fisco e i propri costi."""
parti = []
for r, quota, differita, aliq, comm in (
(rb, w, False, AC.TAX_CRYPTO, 0.0),
(re_, 1 - w, True, AC.TAX_ETF, AC.COMM_EUR)):
if quota <= 0:
continue
parti.append(AC.accumula(r, lump * quota, dep * quota, anni,
differita=differita, aliquota=aliq, comm_eur=comm))
n = max(len(p) for p in parti)
serie = [dict(anno=i + 1,
med=sum(p[i]["med"] for p in parti),
versato=sum(p[i]["versato"] for p in parti)) for i in range(n)]
L = {k: sum(p[-1][k] for p in parti) for k in ("med", "p25", "p75", "versato")}
# ⚠️ il conto Deribit di partenza ($600) e' dentro OGNI parte: con due salvadanai lo si
# conterebbe due volte -> si toglie una volta per ogni parte oltre la prima
extra = LS.START * (len(parti) - 1)
for k in ("med", "p25", "p75", "versato"):
L[k] -= extra
for s in serie:
s["med"] -= extra
s["versato"] -= extra
return dict(serie=serie, **{k: float(v) for k, v in L.items()})
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--anni", type=int, default=12)
ap.add_argument("--lump", type=float, default=5000.0)
ap.add_argument("--dep", type=float, default=500.0)
ap.add_argument("--json", type=str, default="")
a = ap.parse_args()
S = AC.carica_serie()
rb_full, re_full = S["BOOK"], S["SP500"]
lo = max(rb_full.index[0], re_full.index[0])
hi = min(rb_full.index[-1], re_full.index[-1])
j = pd.concat({"b": rb_full.loc[lo:hi], "e": re_full.loc[lo:hi]}, axis=1, sort=True).dropna()
rb, re_ = j["b"], j["e"]
d_book = float(rb.mean()) * 365
d_eq_com = float(re_.mean()) * 365
d_eq_30 = float(re_full.mean()) * 365
print("=" * 104)
print(f" r0807 — QUALE STRATEGIA A {a.anni} ANNI: book, ETF, o un mix?")
print("=" * 104)
print(f" piano ${LS.START:,.0f} + €{a.lump:,.0f} + €{a.dep:,.0f}/mese · {N_PATHS:,} path")
print(f" finestra unica {lo.date()}{hi.date()} ({len(j)} giorni): e' l'unica dove un MIX "
f"e' definito")
print(f" drift misurati: book {d_book:.1%} · S&P 500 su questa finestra {d_eq_com:.1%} · "
f"S&P 500 su {len(re_full)/365:.0f} anni {d_eq_30:.1%}")
# correlazione (solo giorni di borsa aperta: gli zeri del weekend la diluiscono)
ap_ = j[j["e"] != 0]
giu = ap_[ap_["e"] < 0]
coda = ap_[ap_["e"] <= ap_["e"].quantile(0.05)]
print(f"\n CORRELAZIONE book↔S&P (giorni di borsa aperta): {float(ap_['b'].corr(ap_['e'])):+.3f}"
f" · nei giorni di ribasso {float(giu['b'].corr(giu['e'])):+.3f}")
print(f" nel 5% di giorni peggiori dell'indice (medio {float(coda['e'].mean()):+.2%}) "
f"il book fa {float(coda['b'].mean()):+.2%}")
scenari = {
"base": (rb, re_),
"equity lungo": (rb, set_drift(re_, d_eq_30)),
"book dimezzato": (set_drift(rb, d_book * BOOK_HAIRCUT), re_),
"entrambi cauti": (set_drift(rb, d_book * BOOK_HAIRCUT), set_drift(re_, d_eq_30)),
}
ris = {}
for nome, (sb, se) in scenari.items():
print(f"\n SCENARIO «{nome}» — book {float(sb.mean())*365:.1%} · "
f"S&P {float(se.mean())*365:.1%} (volatilita' invariate: "
f"{float(sb.std())*np.sqrt(365):.1%} · {float(se.std())*np.sqrt(365):.1%})")
print(f" {'w book':>8}{'rendita perp.':>15}{'bersaglio':>13}{'capitale':>13}"
f"{'p25':>12}{'p75':>12}{'quota':>8}")
riga = {}
for w in PESI:
mix = w * sb + (1 - w) * se
aliq = w * AC.TAX_CRYPTO + (1 - w) * AC.TAX_ETF
perp, cap = perp_e_bersaglio(mix.values.astype(float), aliq)
acc = accumulo_mix(sb.values.astype(float), se.values.astype(float), w,
a.lump, a.dep, a.anni)
q = acc["med"] / cap if np.isfinite(cap) and cap > 0 else float("nan")
riga[w] = dict(perp=perp, cap=cap, aliquota=aliq, quota=float(q),
med=acc["med"], p25=acc["p25"], p75=acc["p75"],
versato=acc["versato"], serie=acc["serie"])
cs = f"${cap:,.0f}" if np.isfinite(cap) else "n/d"
print(f" {w:>7.0%}{perp:>14.2%}{cs:>13}{acc['med']:>12,.0f}$"
f"{acc['p25']:>11,.0f}${acc['p75']:>11,.0f}${q:>8.0%}")
ris[nome] = riga
# ------------------------------------------------------------------ decisione
nomi = list(scenari)
print(f"\n DECISIONE — quota del bersaglio in ogni scenario")
print(f" {'w book':>8}" + "".join(f"{n:>17}" for n in nomi) + f"{'peggiore':>11}")
peggiori, rimpianti = {}, {}
for w in PESI:
qs = [ris[n][w]["quota"] for n in nomi]
peggiori[w] = min(qs)
print(f" {w:>7.0%}" + "".join(f"{q:>17.0%}" for q in qs) + f"{min(qs):>11.0%}")
# rimpianto massimo: quanto si perde, in ogni scenario, rispetto alla scelta che li sarebbe
# stata la migliore. Criterio piu' informativo del solo caso peggiore.
for w in PESI:
rimpianti[w] = max(max(ris[n][v]["quota"] for v in PESI) - ris[n][w]["quota"]
for n in nomi)
print(f"\n {'w book':>8}{'caso peggiore':>16}{'rimpianto massimo':>20}")
for w in PESI:
print(f" {w:>7.0%}{peggiori[w]:>16.0%}{rimpianti[w]:>20.0%}")
b_max = max(peggiori, key=peggiori.get)
b_reg = min(rimpianti, key=rimpianti.get)
print(f"\n massimizza il caso peggiore : w = {b_max:.0%} sul book ({peggiori[b_max]:.0%})")
print(f" minimizza il rimpianto : w = {b_reg:.0%} sul book ({rimpianti[b_reg]:.0%})")
if a.json:
Path(a.json).write_text(json.dumps(dict(
piano=dict(anni=a.anni, lump=a.lump, dep=a.dep, start=LS.START, eurusd=CC.EURUSD,
n_paths=N_PATHS, pesi=PESI, book_haircut=BOOK_HAIRCUT,
tax_crypto=AC.TAX_CRYPTO, tax_etf=AC.TAX_ETF, comm=AC.COMM_EUR),
finestra=dict(da=str(lo.date()), a=str(hi.date()), giorni=len(j)),
drift=dict(book=d_book, eq_comune=d_eq_com, eq_30=d_eq_30),
correlazione=dict(aperta=float(ap_["b"].corr(ap_["e"])),
ribasso=float(giu["b"].corr(giu["e"])),
coda_indice=float(coda["e"].mean()),
coda_book=float(coda["b"].mean())),
scenari={n: {str(w): ris[n][w] for w in PESI} for n in nomi},
peggiori={str(w): peggiori[w] for w in PESI},
rimpianti={str(w): rimpianti[w] for w in PESI},
minimax=b_max, minregret=b_reg), indent=1))
print(f"\n scritto {a.json}")
if __name__ == "__main__":
main()
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env python
"""r0807_growth_yearly.py — la crescita anno per anno, separando VERSAMENTI e GUADAGNO.
DOMANDA DELL'OPERATORE: *"grafico della crescita per anno con versamento e con guadagno"*.
COSA FA: prende il piano come sta oggi (€5.000 di versamento iniziale + €500/mese sul conto
Deribit) e per ogni anno riporta le due componenti del capitale:
capitale(t) = [START + lump + versamenti cumulati fino a t] + [guadagno cumulato]
\_______________ deterministico ______________/ \___ dal book ___/
La prima e' esatta (i bonifici sono decisi, non estratti); la seconda e' la MEDIANA fra i path
del bootstrap. ⚠️ Quindi la riga "guadagno" NON e' il guadagno di un percorso singolo: e'
`mediana(capitale) - versato`, cioe' quanto ha reso il book nel percorso mediano di ogni anno.
La banda p25-p75 dice quanto quella riga puo' spostarsi.
DUE REGIMI, SUGLI STESSI PATH (confronto appaiato):
* `lordo` — il modello pubblicato in CLAUDE.md: l'accumulo compone senza imposte;
* `netto` — con l'imposta annua sulle plusvalenze (33%) + patrimoniale 0.2% sul valore,
cioe' la correzione misurata il 2026-07-27 (`r0727_tasse.py`), che vale -31%
di capitale a 10 anni e -44% a 15.
Riusa senza riscrivere: `r0725_capcurve._boot_paths` (block bootstrap), `r0726_venue_risk.
venue_series` (serie del book live alle ancore canoniche, gia' de-luckata x0.89) e la
contabilita' fiscale di `r0727_tasse.accumula` (stesse assunzioni, stesso carry a 4 anni).
⚠️ Le assunzioni fiscali sono quelle dichiarate in r0727_tasse.py e NON sono un parere fiscale.
uv run python scripts/research/r0807_growth_yearly.py [--json PATH]
"""
from __future__ import annotations
import argparse
import json
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_venue_risk as VR # noqa: E402
import r0727_lumpsum_split as LS # noqa: E402
import r0727_tasse as TX # noqa: E402
LUMP_EUR = 5_000.0 # il versamento iniziale previsto (config/live.json, nota del 26/07)
DEP_EUR = 500.0 # il versamento mensile previsto
YEARS = 15
N_PATHS = 4000
BLOCK = 20
SEED = 807
def traiettoria(r: np.ndarray, lump_eur: float, dep_eur: float, years: int,
aliquota: float, patrimoniale: float,
n_paths: int = N_PATHS, seed: int = SEED) -> list[dict]:
"""Accumulo giorno per giorno, con fotografia a fine di ogni anno.
Stessa meccanica di `r0727_tasse.accumula` (deposito ogni 30 giorni, imposta annua sulla
variazione di valore al netto dei versamenti, minusvalenze in carry 4 anni), ma qui
interessa la SERIE annuale, non il solo valore finale.
"""
rng = np.random.default_rng(seed)
n_days = years * 365
paths = CC._boot_paths(r, n_paths, n_days, BLOCK, rng)
dep = dep_eur * CC.EURUSD
cap = np.full(n_paths, LS.START + lump_eur * CC.EURUSD)
versato = np.full(n_paths, LS.START + lump_eur * CC.EURUSD) # deterministico, ma tenuto
hit = np.zeros(n_paths, bool) # traguardo toccato mai
carry = np.zeros((n_paths, TX.CARRY_ANNI))
imposte = np.zeros(n_paths)
anno_start = cap.copy()
versato_anno = np.zeros(n_paths)
out: list[dict] = []
for t in range(n_days):
cap *= (1.0 + paths[:, t])
hit |= cap >= LS.TARGET
if t % 30 == 0 and t > 0 and dep > 0:
cap += dep
versato += dep
versato_anno += dep
if (t + 1) % 365 == 0:
plus = cap - anno_start - versato_anno
if aliquota > 0:
disponibili = carry.sum(axis=1)
usate = np.minimum(np.maximum(plus, 0.0), disponibili)
imponibile = np.maximum(plus, 0.0) - usate
resid = usate.copy()
for k in range(TX.CARRY_ANNI - 1, -1, -1):
presa = np.minimum(carry[:, k], resid)
carry[:, k] -= presa
resid -= presa
dovuta = imponibile * aliquota
cap -= np.minimum(dovuta, cap)
imposte += 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)
imposte += p
anno_start = cap.copy()
versato_anno[:] = 0.0
anno = (t + 1) // 365
vers = float(versato[0]) # identico su ogni path: bonifici decisi
med = float(np.median(cap))
out.append(dict(
anno=anno,
versato=vers,
capitale_p25=float(np.percentile(cap, 25)),
capitale_med=med,
capitale_p75=float(np.percentile(cap, 75)),
guadagno_med=med - vers,
imposte_med=float(np.median(imposte)),
p_traguardo=float(hit.mean()),
))
return out
def _riga(x: dict) -> str:
return (f" {x['anno']:>4}{x['versato']:>14,.0f}${x['guadagno_med']:>16,.0f}$"
f"{x['capitale_med']:>16,.0f}${x['capitale_p25']:>15,.0f}$"
f"{x['capitale_p75']:>15,.0f}${x['p_traguardo']:>13.1%}")
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--json", type=str, default="")
ap.add_argument("--lump", type=float, default=LUMP_EUR, help="versamento iniziale in EUR")
ap.add_argument("--dep", type=float, default=DEP_EUR, help="versamento mensile in EUR")
ap.add_argument("--anni", type=int, default=YEARS)
args = ap.parse_args()
lump_eur, dep_eur, years = args.lump, args.dep, args.anni
r = VR.venue_series()["Deribit"].values.astype(float)
print("=" * 108)
print(" r0807 — CRESCITA ANNO PER ANNO: quanto e' versamento e quanto e' guadagno")
print("=" * 108)
print(f" piano: START ${LS.START:,.0f} + lump €{lump_eur:,.0f} + €{dep_eur:,.0f}/mese · "
f"orizzonte {years} anni · {N_PATHS:,} path, blocchi {BLOCK}g")
print(f" book live TP01+SKH01 75/25 alle ancore canoniche, de-luckato ×{VR.DELUCK} · "
f"bersaglio ${LS.TARGET:,.0f}")
lordo = traiettoria(r, lump_eur, dep_eur, years, 0.0, 0.0)
netto = traiettoria(r, lump_eur, dep_eur, years, 0.33, TX.PATRIMONIALE)
for nome, serie in (("LORDO — il modello pubblicato (nessuna imposta durante l'accumulo)", lordo),
("NETTO — plusvalenze 33% annue + patrimoniale 0.2% (r0727_tasse)", netto)):
print("\n" + "-" * 108)
print(f" {nome}")
print("-" * 108)
print(f" {'anno':>4}{'versato':>15}{'guadagno med':>17}{'capitale med':>17}"
f"{'p25':>16}{'p75':>16}{'P(traguardo)':>14}")
for x in serie:
print(_riga(x))
print("\n" + "=" * 108)
print(" LETTURA")
print("=" * 108)
for a in [x for x in (5, 10, 15) if x <= years]:
lo = next(x for x in lordo if x["anno"] == a)
ne = next(x for x in netto if x["anno"] == a)
quota_lo = lo["guadagno_med"] / lo["capitale_med"] if lo["capitale_med"] > 0 else 0.0
quota_ne = ne["guadagno_med"] / ne["capitale_med"] if ne["capitale_med"] > 0 else 0.0
print(f" a {a:>2} anni · versato ${lo['versato']:,.0f} → capitale mediano "
f"${lo['capitale_med']:,.0f} lordo (guadagno = {quota_lo:.0%}) · "
f"${ne['capitale_med']:,.0f} netto fisco (guadagno = {quota_ne:.0%}) · "
f"costo del fisco {ne['capitale_med'] / lo['capitale_med'] - 1:+.1%}")
print("\n ⚠️ 'guadagno' = mediana(capitale) versato: e' il rendimento del percorso MEDIANO,")
print(" non di un percorso singolo. La banda p25-p75 dice di quanto puo' spostarsi.")
if args.json:
Path(args.json).write_text(json.dumps(
dict(piano=dict(start=LS.START, lump_eur=lump_eur, dep_eur=dep_eur,
eurusd=CC.EURUSD, anni=years, n_paths=N_PATHS,
deluck=VR.DELUCK, target=LS.TARGET,
aliquota=0.33, patrimoniale=TX.PATRIMONIALE),
lordo=lordo, netto=netto), indent=1))
print(f"\n scritto {args.json}")
if __name__ == "__main__":
main()
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env python
"""build.py — assembla le pagine pubblicate a partire da shell + dati prodotti dagli script.
Le pagine sono deliverable, non sorgenti di verita': ogni numero che mostrano viene da uno script
Python committato, e qui viene INIETTATO, mai trascritto a mano. La regola nasce da un errore di
questa sessione — due anni di dati di un grafico scritti a memoria perche' l'output del terminale
era troncato dal `tail` — e il modo di non ripeterlo e' non avere il passaggio manuale.
# 1. produrre i dati
uv run python scripts/research/r0807_growth_yearly.py --dep 800 --json /tmp/growth800.json
uv run python scripts/research/r0807_asset_compare.py --json /tmp/compare.json
uv run python scripts/research/r0807_best_strategy.py --anni 12 --json /tmp/best.json
# 2. assemblare
uv run python scripts/web/build.py --out /tmp/pagine \\
--dati simulatore=/tmp/series.json confronto=/tmp/compare.json scelta=/tmp/best.json
# 3. verificare che le pagine ESEGUANO (node --check valida solo la sintassi)
node scripts/web/smoke.js /tmp/pagine/*.html
`pagina_crescita.html` ha i dati gia' dentro (due configurazioni di versamento a confronto) e non
richiede iniezione: se manca il segnaposto, il file viene copiato tale e quale.
"""
from __future__ import annotations
import argparse
import shutil
from pathlib import Path
QUI = Path(__file__).resolve().parent
SEGNAPOSTO = "/*__DATA__*/"
MOTORE = "/*__ENGINE__*/"
SERIE = "/*__SERIES__*/"
def costruisci(shell: Path, dati: Path | None, out: Path) -> str:
testo = shell.read_text()
note = []
if MOTORE in testo: # la pagina interattiva porta dentro il motore
eng = (QUI / "engine.js").read_text()
eng = eng.split('if (typeof module !== "undefined")')[0].rstrip()
testo = testo.replace(MOTORE, eng)
note.append("motore")
for tag in (SERIE, SEGNAPOSTO):
if tag in testo:
if dati is None:
raise SystemExit(f"{shell.name}: serve --dati, il file contiene {tag}")
testo = testo.replace(tag, dati.read_text().strip())
note.append(dati.name)
out.write_text(testo)
return ", ".join(note) if note else "nessuna iniezione (gia' completa)"
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--out", required=True, help="cartella di destinazione")
ap.add_argument("--dati", nargs="*", default=[], metavar="nome=percorso.json")
a = ap.parse_args()
mappa = {}
for voce in a.dati:
nome, _, perc = voce.partition("=")
p = Path(perc)
if not p.exists():
raise SystemExit(f"dati mancanti per «{nome}»: {p}")
mappa[nome] = p
dest = Path(a.out)
dest.mkdir(parents=True, exist_ok=True)
for shell in sorted(QUI.glob("pagina_*.html")):
nome = shell.stem.replace("pagina_", "")
out = dest / f"{nome}.html"
try:
esito = costruisci(shell, mappa.get(nome), out)
except SystemExit as e:
print(f" {nome:12} SALTATA — {e}")
continue
print(f" {nome:12} -> {out} ({esito}, {out.stat().st_size/1024:.0f} KB)")
for extra in ("engine.js", "smoke.js", "test_engine.js"):
shutil.copy(QUI / extra, dest / extra)
print(f"\n verifica: node {dest}/smoke.js {dest}/*.html")
if __name__ == "__main__":
main()
+120
View File
@@ -0,0 +1,120 @@
/* engine.js — il motore di accumulo, ri-implementato per il browser.
*
* Deve dare LA STESSA RISPOSTA di scripts/research/r0807_growth_yearly.py: stessa costruzione
* del block bootstrap, stessa cadenza dei versamenti (ogni 30 giorni), stessa contabilita'
* fiscale (imposta annua sulla variazione di valore al netto dei versamenti, minusvalenze in
* carry 4 anni, patrimoniale sul valore). Il seme non e' confrontabile fra i due linguaggi:
* l'accordo si verifica sui RISULTATI, entro il rumore Monte Carlo (test_engine.js).
*/
function makeEngine(CFG) {
const R = CFG.r, NR = R.length, BLOCK = CFG.block, CARRY = CFG.carry;
/* PRNG seminato (mulberry32): serve la riproducibilita', non la qualita' crittografica */
function rng(seed) {
let a = seed >>> 0;
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/* Un accumulo completo. Ritorna, per ogni anno, la distribuzione del capitale fra i path.
* opts: { lumpEur, depEur, years, tax, patrimoniale, paths, seed } */
function run(o) {
const nDays = o.years * 365;
const rand = rng(o.seed || 807);
const dep = o.depEur * CFG.eurusd;
const cap0 = CFG.start_usd + o.lumpEur * CFG.eurusd;
const nPaths = o.paths;
/* capitale a fine di ogni anno, per path */
const snap = [];
for (let y = 0; y < o.years; y++) snap.push(new Float64Array(nPaths));
const hitBy = new Int32Array(o.years); /* quanti path hanno toccato il bersaglio entro l'anno y */
const versatoY = new Float64Array(o.years); /* deterministico, ma CONTATO come in Python */
const carry = new Float64Array(CARRY);
for (let p = 0; p < nPaths; p++) {
let cap = cap0, annoStart = cap0, versatoAnno = 0, hitYear = -1, versato = cap0;
carry.fill(0);
let blockPos = BLOCK, base = 0;
for (let t = 0; t < nDays; t++) {
if (blockPos === BLOCK) { /* nuovo blocco: stessa costruzione di numpy */
base = Math.floor(rand() * (NR - BLOCK));
blockPos = 0;
}
cap *= 1 + R[base + blockPos];
blockPos++;
if (hitYear < 0 && cap >= CFG.target) hitYear = Math.floor(t / 365);
if (t % 30 === 0 && t > 0 && dep > 0) { cap += dep; versato += dep; versatoAnno += dep; }
if ((t + 1) % 365 === 0) {
const plus = cap - annoStart - versatoAnno;
if (o.tax > 0) {
let disponibili = 0;
for (let k = 0; k < CARRY; k++) disponibili += carry[k];
const positivo = plus > 0 ? plus : 0;
const usate = Math.min(positivo, disponibili);
let resid = usate;
for (let k = CARRY - 1; k >= 0; k--) { /* dalla piu' VECCHIA, che scade prima */
const presa = Math.min(carry[k], resid);
carry[k] -= presa; resid -= presa;
}
const dovuta = (positivo - usate) * o.tax;
cap -= Math.min(dovuta, cap);
for (let k = CARRY - 1; k > 0; k--) carry[k] = carry[k - 1]; /* invecchia */
carry[0] = plus < 0 ? -plus : 0;
}
if (o.patrimoniale > 0) cap -= Math.min(cap * o.patrimoniale, cap);
annoStart = cap; versatoAnno = 0;
const yi = (t + 1) / 365 - 1;
snap[yi][p] = cap;
if (p === 0) versatoY[yi] = versato;
}
}
if (hitYear >= 0) for (let y = hitYear; y < o.years; y++) hitBy[y]++;
}
const out = [];
for (let y = 1; y <= o.years; y++) {
const col = snap[y - 1].slice().sort();
const med = quantile(col, 0.5);
out.push({ anno: y, versato: versatoY[y - 1], guadagno: med - versatoY[y - 1],
p25: quantile(col, 0.25), med: med, p75: quantile(col, 0.75),
phit: hitBy[y - 1] / nPaths });
}
return out;
}
/* percentile con interpolazione lineare, come np.percentile (default 'linear') */
function quantile(sorted, f) {
const n = sorted.length;
if (n === 0) return 0;
const pos = f * (n - 1), lo = Math.floor(pos), hi = Math.ceil(pos);
return lo === hi ? sorted[lo] : sorted[lo] + (pos - lo) * (sorted[hi] - sorted[lo]);
}
/* Il versamento mensile che porta P(bersaglio entro `years`) alla confidenza voluta.
* Bisezione, come dep_necessario() in r0727_tasse.py. Ritorna null se non basta nemmeno
* il tetto: cosi' l'interfaccia dice "fuori portata" invece di stampare un numero enorme. */
function solveDeposit(o, pTarget, iters) {
let lo = 0, hi = 20000;
const P = d => run(Object.assign({}, o, { depEur: d })).slice(-1)[0].phit;
if (P(hi) < pTarget) return null;
for (let i = 0; i < (iters || 13); i++) {
const mid = 0.5 * (lo + hi);
if (P(mid) >= pTarget) hi = mid; else lo = mid;
}
return 0.5 * (lo + hi);
}
return { run, solveDeposit, quantile };
}
if (typeof module !== "undefined") module.exports = { makeEngine };
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env python
"""export_series.py — esporta la serie del book live per il motore nel browser.
La pagina interattiva (`pagina_simulatore.html`) simula nel browser, quindi le serve la sequenza
dei ritorni giornalieri VERI del book: gli stessi che usa `r0807_growth_yearly.py`, cioe' TP01 75%
+ SKH01 25% alle ancore canoniche, gia' de-luckati x0.89 sul drift da `r0726_venue_risk`.
Esporta anche le costanti che il motore deve condividere col Python (bersaglio, cambio, aliquote,
lunghezza dei blocchi): se una di queste vivesse in due posti, prima o poi divergerebbe.
uv run python scripts/web/export_series.py --out /tmp/series.json
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
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_venue_risk as VR # noqa: E402
import r0727_lumpsum_split as LS # noqa: E402
import r0727_tasse as TX # noqa: E402
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--out", required=True)
a = ap.parse_args()
s = VR.venue_series()["Deribit"]
r = s.values.astype(float)
dati = dict(
n=len(r), start=str(s.index[0].date()), end=str(s.index[-1].date()),
start_usd=LS.START, target=LS.TARGET, eurusd=CC.EURUSD, deluck=VR.DELUCK,
tax=0.33, patrimoniale=TX.PATRIMONIALE, carry=TX.CARRY_ANNI, block=20,
r=[round(x, 8) for x in r],
)
Path(a.out).write_text(json.dumps(dati))
print(f" {len(r)} giorni [{dati['start']} -> {dati['end']}] "
f"drift {r.mean()*365:.1%} vol {r.std()*365**0.5:.1%}")
print(f" bersaglio ${LS.TARGET:,.0f} · de-luck ×{VR.DELUCK} · "
f"scritto {a.out} ({Path(a.out).stat().st_size/1024:.0f} KB)")
if __name__ == "__main__":
main()
+489
View File
@@ -0,0 +1,489 @@
<title>Da dove viene il guadagno — e se invece fosse un ETF</title>
<style>
:root {
color-scheme: light;
--ground: #f7f7f5; --surface: #fdfdfc; --sunken: #f1f1ee;
--rule: #dededa; --rule-soft: #ebebe7;
--ink: #16171a; --ink-2: #55575c; --ink-3: #83858b;
--s1: #2a78d6; --s2: #eb6834; --s3: #1baf7a; --grid: #e6e6e2;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--ground: #17181a; --surface: #1d1e21; --sunken: #232427;
--rule: #34363a; --rule-soft: #26282b;
--ink: #f1f1ee; --ink-2: #a8aab0; --ink-3: #7c7e85;
--s1: #3987e5; --s2: #d95926; --s3: #199e70; --grid: #2b2d31;
}
}
:root[data-theme="dark"] {
color-scheme: dark;
--ground: #17181a; --surface: #1d1e21; --sunken: #232427;
--rule: #34363a; --rule-soft: #26282b;
--ink: #f1f1ee; --ink-2: #a8aab0; --ink-3: #7c7e85;
--s1: #3987e5; --s2: #d95926; --s3: #199e70; --grid: #2b2d31;
}
*, *::before, *::after { box-sizing: border-box; }
body {
background: var(--ground); color: var(--ink);
font-family: Georgia, "Iowan Old Style", "Times New Roman", serif;
font-size: 17px; line-height: 1.62; margin: 0; padding: 0 24px 96px;
-webkit-font-smoothing: antialiased;
}
.wrap { max-width: 1080px; margin: 0 auto; display: flex; flex-direction: column; gap: 44px; }
.prose { max-width: 68ch; display: flex; flex-direction: column; gap: 16px; }
.mono, .eyebrow, .lede-meta, .legend, .axis, table, .step, .dotrow {
font-family: ui-monospace, SFMono-Regular, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
font-variant-numeric: tabular-nums;
}
header { padding-top: 60px; display: flex; flex-direction: column; gap: 18px; }
.eyebrow { font-size: 11.5px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--ink-3); margin: 0; }
h1 { font-size: clamp(29px, 4.2vw, 44px); line-height: 1.12; margin: 0; font-weight: 400;
letter-spacing: -0.015em; text-wrap: balance; max-width: 22ch; }
.lede { font-size: 19px; color: var(--ink-2); margin: 0; max-width: 62ch; }
.lede b { color: var(--ink); font-weight: 700; }
.lede-meta { font-size: 12.5px; color: var(--ink-3); line-height: 1.9; margin: 0;
padding-top: 14px; border-top: 1px solid var(--rule); }
.lede-meta b { color: var(--ink-2); font-weight: 400; }
h2 { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px;
letter-spacing: 0.15em; text-transform: uppercase; color: var(--ink-2); font-weight: 500;
margin: 0 0 4px; padding-bottom: 10px; border-bottom: 1px solid var(--rule); }
p { margin: 0; }
em { font-style: italic; color: var(--ink-2); }
strong { font-weight: 700; }
/* passi del calcolo */
.steps { display: flex; flex-direction: column; gap: 1px; background: var(--rule);
border: 1px solid var(--rule); border-radius: 3px; overflow: hidden; }
.step { background: var(--surface); padding: 15px 18px; display: grid;
grid-template-columns: 28px 1fr; gap: 14px; align-items: baseline; font-size: 13.5px;
color: var(--ink-2); line-height: 1.65; }
.step .n { color: var(--ink-3); font-size: 11px; letter-spacing: 0.1em; }
.step b { color: var(--ink); font-weight: 400; }
.step code { background: var(--sunken); padding: 1px 5px; border-radius: 2px; font-size: 12.5px; color: var(--ink); }
figure { margin: 0; display: flex; flex-direction: column; gap: 10px; }
.chart-title { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px;
letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink); margin: 0; }
.chart-title span { color: var(--ink-3); text-transform: none; letter-spacing: 0; }
.chart-box { background: var(--surface); border: 1px solid var(--rule); border-radius: 3px;
padding: 18px 16px 10px; overflow-x: auto; }
.chart-box svg { display: block; width: 100%; min-width: 620px; height: auto; }
.legend { display: flex; gap: 22px; flex-wrap: wrap; font-size: 12px; color: var(--ink-2); align-items: center; }
.legend i { width: 20px; height: 3px; border-radius: 2px; display: inline-block; margin-right: 8px; vertical-align: 3px; }
.legend .dash { background: none; border-top: 2px dashed var(--ink-3); height: 0; }
.figcap { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
color: var(--ink-3); line-height: 1.7; max-width: 80ch; }
table.data { border-collapse: collapse; font-size: 12.5px; width: 100%; min-width: 560px; }
table.data th, table.data td { padding: 8px 14px 8px 0; text-align: right; white-space: nowrap; }
table.data th:first-child, table.data td:first-child { text-align: left; }
table.data thead th { color: var(--ink-3); font-weight: 400; border-bottom: 1px solid var(--rule);
font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; }
table.data tbody td { border-bottom: 1px solid var(--rule-soft); color: var(--ink-2); }
table.data tbody td:first-child { color: var(--ink); }
table.data td .sw { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 8px; }
.tablewrap { overflow-x: auto; border: 1px solid var(--rule); border-radius: 3px; background: var(--surface); padding: 14px 16px; }
caption { caption-side: top; text-align: left; font-size: 12px; color: var(--ink-3); padding-bottom: 12px; }
/* banda finale */
.dots { border: 1px solid var(--rule); border-radius: 3px; background: var(--surface); padding: 18px 18px 8px; }
.dotrow { display: grid; grid-template-columns: 90px 1fr; gap: 14px; align-items: center; margin-bottom: 14px; font-size: 12px; color: var(--ink-2); }
details { border-top: 1px solid var(--rule); padding-top: 14px; }
summary { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink-2); cursor: pointer; padding: 4px 0; }
summary:focus-visible { outline: 2px solid var(--s1); outline-offset: 3px; }
#tip { position: fixed; z-index: 40; pointer-events: none; opacity: 0; transition: opacity .1s ease;
background: var(--surface); border: 1px solid var(--rule); border-radius: 3px; padding: 10px 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; line-height: 1.75;
color: var(--ink-2); box-shadow: 0 6px 22px rgba(0,0,0,.14); font-variant-numeric: tabular-nums; max-width: 280px; }
#tip .t { color: var(--ink); letter-spacing: 0.08em; text-transform: uppercase; font-size: 10.5px; display: block; margin-bottom: 5px; }
#tip .r { display: flex; justify-content: space-between; gap: 18px; }
#tip .r b { font-weight: 400; color: var(--ink); }
#tip .sw { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 6px; }
.caveat { border-left: 2px solid var(--s2); padding: 2px 0 2px 18px; color: var(--ink-2);
font-size: 16px; max-width: 64ch; }
.caveat b { color: var(--ink); font-weight: 700; }
footer { border-top: 1px solid var(--rule); padding-top: 18px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px;
color: var(--ink-3); line-height: 1.85; max-width: 80ch; }
footer b { color: var(--ink-2); font-weight: 400; }
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
</style>
<div class="wrap">
<header>
<p class="eyebrow">PythagorasGoal · 2026-08-07</p>
<h1>Da dove viene il guadagno, e se invece fosse un ETF</h1>
<p class="lede">
Due domande. La prima: <b>quel guadagno come si calcola?</b> Non è un tasso ipotizzato — è
quello che producono i ritorni veri del book, ricampionati. La seconda: <b>e se gli stessi
soldi andassero su un S&amp;P 500 o un MSCI World?</b>
</p>
<p class="lede-meta" id="meta"></p>
</header>
<section class="prose">
<h2>1 · Come si calcola il guadagno</h2>
<p>
Da nessuna parte c'è un «rendimento atteso» scritto a mano. C'è una sequenza di ritorni
giornalieri realmente prodotti dal book live, e la si rimescola.
</p>
</section>
<div class="steps" id="steps"></div>
<section class="prose">
<p>
Il rendimento non è un'ipotesi, ma <em>la scelta della sequenza sì</em>: rimescolare gli
ultimi 7 anni assume che il futuro somigli a quel campione. È l'assunzione più forte di tutto
il modello, ed è esattamente ciò che la seconda domanda mette alla prova.
</p>
</section>
<section class="prose">
<h2>2 · I tre motori</h2>
<p>
Stesso piano, stessa macchina, tre sorgenti di ritorni. Le azioni vanno su griglia di
calendario con <em>0,0 nei giorni di borsa chiusa</em> — la convenzione già usata per lo
sleeve azionario: il capitale è fermo, non riciclato. Senza quella correzione una serie di
borsa passata a un motore che annualizza a 365 esce con Sharpe ×1,20.
</p>
</section>
<div class="tablewrap"><table class="data" id="t-serie"></table></div>
<section class="prose">
<p class="caveat">
La riga che conta non è la prima, è la seconda: <b>sulla stessa finestra</b> il book e
l'S&amp;P 500 hanno quasi lo stesso rendimento — <span id="x-drift"></span>. Tutta la
differenza è nel <b>rischio</b>: volatilità <span id="x-vol"></span> e drawdown massimo
<span id="x-dd"></span>. Il book non guadagna di più: perde di meno.
</p>
</section>
<figure>
<p class="chart-title">Capitale mediano per anno <span>— ciascuno ricampionato dalla PROPRIA storia piena</span></p>
<div class="legend" id="leg1"></div>
<div class="chart-box"><svg id="c1" role="img" aria-label="Curve del capitale mediano per anno, storia piena"></svg></div>
<figcaption class="figcap" id="cap1"></figcaption>
</figure>
<figure>
<p class="chart-title">Capitale mediano per anno <span>— tutti e tre ricampionati dalla STESSA finestra</span></p>
<div class="chart-box"><svg id="c2" role="img" aria-label="Curve del capitale mediano per anno, finestra comune"></svg></div>
<figcaption class="figcap" id="cap2"></figcaption>
</figure>
<section class="prose">
<h2>3 · Il bersaglio non è lo stesso per tutti</h2>
<p>
$272.061 è il capitale che serve <em>con la rendita perpetua del book e con l'aliquota del
33%</em>. Un ETF ha un'altra distribuzione e un'altra aliquota, quindi ha un altro bersaglio:
la rendita sostenibile dipende dal drawdown, non dal rendimento medio. Ricalcolato per
ciascuno, con lo stesso metodo.
</p>
</section>
<div class="tablewrap"><table class="data" id="t-berg"></table></div>
<section class="prose">
<h2>4 · La banda, non il numero</h2>
<p>
Le curve sopra sono mediane. Ecco dove sta in realtà il capitale finale: la barra è
l'intervallo p25p75, il segno è la mediana, la linea tratteggiata è il bersaglio di
quel motore.
</p>
</section>
<div class="dots" id="dots"></div>
<section class="prose">
<h2>5 · La lettura onesta</h2>
<p class="caveat" id="lettura"></p>
<p>
Il fisco è modellato diverso perché <em>è</em> diverso: il book realizza in continuo e paga
ogni anno il 33%; un ETF UCITS ad accumulazione non distribuisce nulla e paga il 26% alla
vendita. Il differimento è un vantaggio strutturale dell'ETF, ed è dentro i numeri qui sopra
(le curve degli ETF sono valori di <em>liquidazione</em>, già al netto dell'imposta latente).
Sono assunzioni dichiarate e <em>non sono un parere fiscale</em>.
</p>
</section>
<details>
<summary>Tabella completa — capitale mediano anno per anno, entrambe le lenti</summary>
<div class="tablewrap" style="margin-top:14px"><table class="data" id="t-full"></table></div>
</details>
<footer id="foot"></footer>
</div>
<div id="tip" role="status" aria-live="polite"></div>
<script id="cmp-data" type="application/json">/*__DATA__*/</script>
<script>
(function () {
"use strict";
const D = JSON.parse(document.getElementById("cmp-data").textContent);
const $ = id => document.getElementById(id);
const MOT = ["BOOK", "SP500", "WORLD"];
const NOME = { BOOK: "book live (cripto)", SP500: "S&P 500 (SPY)", WORLD: "MSCI World (proxy)" };
const COL = { BOOK: "var(--s1)", SP500: "var(--s2)", WORLD: "var(--s3)" };
const usd = n => "$" + Math.round(n).toLocaleString("it-IT");
const usdK = n => n === 0 ? "$0" : (Math.abs(n) >= 1e6
? "$" + (n / 1e6).toFixed(n >= 1e7 ? 0 : 1).replace(".", ",") + "M"
: "$" + Math.round(n / 1000) + "k");
const pc = (x, d) => (100 * x).toFixed(d === undefined ? 1 : d).replace(".", ",") + "%";
/* ---------- intestazione ---------- */
const P = D.piano;
$("meta").innerHTML =
"piano: <b>$" + P.start + "</b> + <b>€" + P.lump.toLocaleString("it-IT") + "</b> + <b>€" +
P.dep.toLocaleString("it-IT") + "/mese</b> per <b>" + P.anni + " anni</b> · " +
P.n_paths.toLocaleString("it-IT") + " percorsi, blocchi da 20 giorni · " +
"fisco: cripto <b>" + pc(P.tax_crypto, 0) + " annuo</b>, ETF <b>" + pc(P.tax_etf, 0) +
" differito alla vendita</b>, patrimoniale <b>" + pc(P.patrim, 1) + "</b> · " +
"commissione d'acquisto ETF <b>€" + P.comm_eur + "/mese</b> · MSCI World = " + P.world;
/* ---------- passi ---------- */
const mB = D.metriche.BOOK;
const passi = [
["01", "Si parte dai <b>ritorni giornalieri veri</b> del book live (TP01 75% + SKH01 25%): " +
mB.n.toLocaleString("it-IT") + " giorni dal " + mB.da + " al " + mB.a + ". Non sono simulati: " +
"sono quelli che la strategia ha prodotto, già al netto delle commissioni."],
["02", "Si toglie la <b>fortuna d'ancora</b>: <code>r (10,89)·media(r)</code>. Il book è " +
"stato misurato al 97º percentile delle sue possibili ancore di calendario, e il fattore " +
"0,89 è la correzione misurata, non stimata a occhio."],
["03", "Si <b>rimescolano a blocchi di 20 giorni</b>: si pescano a caso spezzoni di 20 giorni " +
"consecutivi e li si incolla fino a coprire gli anni richiesti. I blocchi servono a non " +
"distruggere il raggruppamento della volatilità — un rimescolamento giorno per giorno " +
"renderebbe il percorso troppo docile e la sopravvivenza troppo alta."],
["04", "Si <b>compone</b>: <code>capitale ×= (1 + r)</code> ogni giorno, più il bonifico ogni " +
"30 giorni, meno l'imposta a fine anno. Ripetuto su " + P.n_paths.toLocaleString("it-IT") +
" percorsi indipendenti."],
["05", "Il <b>guadagno</b> di un anno è <code>mediana(capitale) versato</code>. Il versato è " +
"esatto (i bonifici sono decisi); il resto è quello che ha prodotto il ricampionamento. " +
"Drift implicito del book: <b>" + pc(mB.drift) + " annuo</b> con volatilità <b>" +
pc(mB.vol) + "</b> e drawdown massimo <b>" + pc(Math.abs(mB.maxdd)) + "</b>."]
];
$("steps").innerHTML = passi.map(([n, t]) =>
'<div class="step"><span class="n">' + n + "</span><span>" + t + "</span></div>").join("");
/* ---------- tabella serie ---------- */
const F = D.finestra_comune;
$("t-serie").innerHTML =
"<caption>Le prime tre righe usano la storia piena di ciascuno; le seconde tre la sola " +
"finestra comune <b>" + F.da + " → " + F.a + "</b>. Drift e volatilità sono annualizzati a 365 " +
"giorni su griglia di calendario.</caption>" +
"<thead><tr><th>motore</th><th>anni</th><th>da</th><th>rendimento</th><th>volatilità</th>" +
"<th>Sharpe</th><th>drawdown max</th></tr></thead><tbody>" +
MOT.map(m => { const x = D.metriche[m];
return "<tr><td><span class='sw' style='background:" + COL[m] + "'></span>" + NOME[m] +
"</td><td>" + x.anni.toFixed(1) + "</td><td>" + x.da + "</td><td>" + pc(x.drift) +
"</td><td>" + pc(x.vol) + "</td><td>" + x.sharpe.toFixed(2).replace(".", ",") +
"</td><td>" + pc(x.maxdd) + "</td></tr>"; }).join("") +
MOT.map(m => { const x = D.metriche_comune[m];
return "<tr><td style='color:var(--ink-3)'><span class='sw' style='background:" + COL[m] +
";opacity:.5'></span>" + NOME[m] + " · stessa finestra</td><td>" + x.anni.toFixed(1) +
"</td><td>" + x.da + "</td><td>" + pc(x.drift) + "</td><td>" + pc(x.vol) + "</td><td>" +
x.sharpe.toFixed(2).replace(".", ",") + "</td><td>" + pc(x.maxdd) + "</td></tr>"; }).join("") +
"</tbody>";
const cB = D.metriche_comune.BOOK, cS = D.metriche_comune.SP500;
$("x-drift").textContent = pc(cB.drift) + " contro " + pc(cS.drift);
$("x-vol").textContent = pc(cB.vol) + " contro " + pc(cS.vol);
$("x-dd").textContent = pc(Math.abs(cB.maxdd)) + " contro " + pc(Math.abs(cS.maxdd));
/* ---------- legenda ---------- */
$("leg1").innerHTML = MOT.map(m =>
"<span><i style='background:" + COL[m] + "'></i>" + NOME[m] + "</span>").join("") +
"<span><i class='dash'></i>capitale-rendita di quel motore</span>";
/* ---------- grafico a linee ---------- */
const NS = "http://www.w3.org/2000/svg";
const el = (n, a) => { const e = document.createElementNS(NS, n); for (const k in a) e.setAttribute(k, a[k]); return e; };
const tip = $("tip");
function moveTip(evt) {
const r = tip.getBoundingClientRect();
let x = evt.clientX + 16, y = evt.clientY - r.height - 12;
if (x + r.width > window.innerWidth - 8) x = evt.clientX - r.width - 16;
if (y < 8) y = evt.clientY + 18;
tip.style.left = x + "px"; tip.style.top = y + "px";
}
const hideTip = () => { tip.style.opacity = "0"; };
function drawLines(svgId, traj, berg, capId, nota) {
const svg = $(svgId);
const W = 960, H = 400, M = { t: 26, r: 150, b: 40, l: 78 };
const pw = W - M.l - M.r, ph = H - M.t - M.b;
svg.setAttribute("viewBox", "0 0 " + W + " " + H);
svg.innerHTML = "";
const anni = traj.BOOK.length;
const finiti = MOT.map(m => berg[m].cap).filter(c => isFinite(c) && c > 0);
const rawMax = Math.max(...MOT.flatMap(m => traj[m].map(r => r.med)), ...finiti);
const STEP = [25000, 50000, 100000, 150000, 200000, 250000, 500000].find(s => rawMax / s <= 8) || 1000000;
const YMAX = Math.ceil(rawMax / STEP) * STEP;
const x = a => M.l + (a - 1) / Math.max(1, anni - 1) * pw;
const y = v => M.t + ph - (v / YMAX) * ph;
for (let v = 0; v <= YMAX; v += STEP) {
svg.appendChild(el("line", { x1: M.l, x2: M.l + pw, y1: y(v), y2: y(v),
stroke: v === 0 ? "var(--ink-3)" : "var(--grid)", "stroke-width": 1 }));
const t = el("text", { x: M.l - 12, y: y(v) + 4, "text-anchor": "end", class: "axis",
fill: "var(--ink-3)", "font-size": 11 });
t.textContent = usdK(v); svg.appendChild(t);
}
for (let a = 1; a <= anni; a++) {
if (anni > 12 && a % 2 === 0 && a !== anni) continue;
const t = el("text", { x: x(a), y: H - 16, "text-anchor": "middle", class: "axis",
fill: "var(--ink-3)", "font-size": 11 });
t.textContent = a; svg.appendChild(t);
}
const ax = el("text", { x: M.l + pw + 12, y: H - 16, class: "axis", fill: "var(--ink-3)", "font-size": 10.5 });
ax.textContent = "anno"; svg.appendChild(ax);
/* bersagli: tratteggio orizzontale + etichetta a destra */
MOT.forEach(m => {
const c = berg[m].cap;
if (!isFinite(c) || c <= 0 || c > YMAX) return;
svg.appendChild(el("line", { x1: M.l, x2: M.l + pw, y1: y(c), y2: y(c), stroke: COL[m],
"stroke-width": 1.5, "stroke-dasharray": "4 4", opacity: .55 }));
});
/* linee */
MOT.forEach(m => {
const pts = traj[m].map(r => x(r.anno) + "," + y(r.med)).join(" ");
svg.appendChild(el("polyline", { points: pts, fill: "none", stroke: COL[m],
"stroke-width": 2.5, "stroke-linejoin": "round", "stroke-linecap": "round" }));
const last = traj[m][traj[m].length - 1];
svg.appendChild(el("circle", { cx: x(last.anno), cy: y(last.med), r: 4, fill: COL[m] }));
/* etichetta diretta: identita' mai solo colore */
const lx = M.l + pw + 12;
const a = el("text", { x: lx, y: y(last.med) - 3, class: "axis", fill: COL[m], "font-size": 11 });
a.textContent = NOME[m]; svg.appendChild(a);
const b = el("text", { x: lx, y: y(last.med) + 12, class: "axis", fill: "var(--ink)", "font-size": 11.5 });
b.textContent = usd(last.med); svg.appendChild(b);
});
/* colonne di hover */
for (let a = 1; a <= anni; a++) {
const g = el("g", { tabindex: "0", "aria-label": "anno " + a });
const w = pw / anni;
g.appendChild(el("rect", { x: x(a) - w / 2, y: M.t, width: w, height: ph, fill: "transparent" }));
MOT.forEach(m => g.appendChild(el("circle", { cx: x(a), cy: y(traj[m][a - 1].med), r: 2.5,
fill: COL[m], opacity: .0 })));
const mostra = evt => {
tip.innerHTML = '<span class="t">anno ' + a + "</span>" +
MOT.map(m => { const r = traj[m][a - 1];
return '<div class="r"><span><span class="sw" style="background:' + COL[m] + '"></span>' +
NOME[m].split(" (")[0] + "</span><b>" + usd(r.med) + "</b></div>" +
'<div class="r" style="opacity:.65"><span> banda p25p75</span><b>' +
usdK(r.p25) + " " + usdK(r.p75) + "</b></div>"; }).join("") +
'<div class="r" style="margin-top:5px"><span>versato</span><b>' +
usd(traj.BOOK[a - 1].versato) + "</b></div>";
tip.style.opacity = "1"; moveTip(evt);
};
g.addEventListener("pointerenter", mostra);
g.addEventListener("pointermove", moveTip);
g.addEventListener("pointerleave", hideTip);
g.addEventListener("focus", () => mostra({ clientX: x(a) + 60, clientY: 300 }));
g.addEventListener("blur", hideTip);
svg.appendChild(g);
}
$(capId).innerHTML = nota;
}
drawLines("c1", D.traiettoria, D.bersaglio, "cap1",
"Ogni motore ricampiona la propria storia: il book 7,4 anni, l'S&amp;P 500 trent'anni che " +
"contengono il 2000, il 2008 e il 2022. Le linee tratteggiate sono i tre capitali-rendita, " +
"diversi fra loro. È la lente che <b>favorisce il book</b>: gli si chiede di battere indici " +
"misurati anche nei loro anni peggiori, mai vissuti dalla strategia.");
drawLines("c2", D.traiettoria_comune, D.bersaglio_comune, "cap2",
"Stessa finestra per tutti (" + F.da + " → " + F.a + "), quindi stesso regime di mercato. " +
"Qui l'S&amp;P 500 <b>accumula più del book</b>. I bersagli tratteggiati sono anch'essi " +
"ricalcolati su questa finestra — e vanno presi con le molle: una rendita perpetua è una " +
"domanda a vent'anni, e sette anni non la risolvono.");
/* ---------- tabella bersagli ---------- */
$("t-berg").innerHTML =
"<caption>«Rendita perpetua» = prelievo annuo che mantiene P(capitale a 20 anni ≥ capitale " +
"iniziale) ≥ 90%. Il capitale è quello che serve per <b>€50/giorno netti</b> con l'aliquota di " +
"quel motore.</caption>" +
"<thead><tr><th>motore</th><th>aliquota</th><th>rendita perpetua</th><th>capitale necessario</th>" +
"<th>capitale a " + P.anni + " anni</th><th>quota raggiunta</th></tr></thead><tbody>" +
MOT.map(m => {
const b = D.bersaglio[m], L = D.traiettoria[m][D.traiettoria[m].length - 1];
const q = isFinite(b.cap) && b.cap > 0 ? L.med / b.cap : NaN;
return "<tr><td><span class='sw' style='background:" + COL[m] + "'></span>" + NOME[m] +
"</td><td>" + pc(b.aliquota, 0) + "</td><td>" + pc(b.perp, 2) + "</td><td>" +
(isFinite(b.cap) ? usd(b.cap) : "non sostenibile") + "</td><td>" + usd(L.med) + "</td><td>" +
(isFinite(q) ? pc(q, 0) : "—") + "</td></tr>"; }).join("") + "</tbody>";
/* ---------- banda finale ---------- */
const allMax = Math.max(...MOT.flatMap(m => [D.traiettoria[m].slice(-1)[0].p75,
isFinite(D.bersaglio[m].cap) ? D.bersaglio[m].cap : 0]));
$("dots").innerHTML = MOT.map(m => {
const L = D.traiettoria[m].slice(-1)[0], b = D.bersaglio[m];
const f = v => (100 * v / allMax).toFixed(2) + "%";
const berg = isFinite(b.cap) && b.cap <= allMax
? '<span style="position:absolute;left:' + f(b.cap) + ';top:-6px;bottom:-6px;border-left:2px dashed var(--ink-3)"></span>' : "";
return '<div class="dotrow"><span>' + NOME[m].split(" (")[0] + '</span>' +
'<span style="position:relative;display:block;height:22px">' +
'<span style="position:absolute;left:' + f(L.p25) + ';width:' + f(L.p75 - L.p25) +
';top:7px;height:8px;border-radius:4px;background:' + COL[m] + ';opacity:.32"></span>' +
'<span style="position:absolute;left:calc(' + f(L.med) + ' - 1px);top:3px;width:3px;height:16px;background:' + COL[m] + '"></span>' +
berg + "</span></div>"; }).join("") +
'<p class="figcap" style="margin-top:2px">Barra = intervallo p25p75 del capitale a ' + P.anni +
' anni · segno pieno = mediana · tratteggio = capitale-rendita di quel motore. Scala comune, ' +
'da $0 a ' + usdK(allMax) + '.</p>';
/* ---------- lettura ---------- */
const qB = D.traiettoria.BOOK.slice(-1)[0].med / D.bersaglio.BOOK.cap;
const qS = D.traiettoria.SP500.slice(-1)[0].med / D.bersaglio.SP500.cap;
const qBc = D.traiettoria_comune.BOOK.slice(-1)[0].med / D.bersaglio_comune.BOOK.cap;
const qSc = D.traiettoria_comune.SP500.slice(-1)[0].med / D.bersaglio_comune.SP500.cap;
$("lettura").innerHTML =
"Sulla storia piena di ciascuno il book arriva al <b>" + pc(qB, 0) + "</b> del proprio " +
"bersaglio e l'S&amp;P 500 al <b>" + pc(qS, 0) + "</b>. Sulla stessa finestra diventano <b>" +
pc(qBc, 0) + "</b> e <b>" + pc(qSc, 0) + "</b>: quasi pari. <b>La differenza fra le due lenti " +
"è tutta la domanda</b> — i sette anni del book rappresentano il futuro, o sono una finestra " +
"fortunata? L'indice ha attraversato due crolli che la strategia non ha mai vissuto, e il " +
"book non ha una storia lunga da opporre: ne ha " + D.metriche.BOOK.anni.toFixed(1) + " anni in tutto.";
/* ---------- tabella completa ---------- */
$("t-full").innerHTML =
"<thead><tr><th>anno</th><th>versato</th>" +
MOT.map(m => "<th>" + NOME[m].split(" (")[0] + "</th>").join("") +
MOT.map(m => "<th>" + NOME[m].split(" (")[0] + " · stessa fin.</th>").join("") +
"</tr></thead><tbody>" +
D.traiettoria.BOOK.map((r, i) =>
"<tr><td>" + r.anno + "</td><td>" + usd(r.versato) + "</td>" +
MOT.map(m => "<td>" + usd(D.traiettoria[m][i].med) + "</td>").join("") +
MOT.map(m => "<td style='opacity:.7'>" + usd(D.traiettoria_comune[m][i].med) + "</td>").join("") +
"</tr>").join("") + "</tbody>";
$("foot").innerHTML =
"Fonti: book live dalle serie d'ancora canoniche del progetto; <b>SPY</b> e <b>EFA</b> da " +
"Interactive Brokers, <code>ADJUSTED_LAST</code> (dividendi reinvestiti), certificati. " +
"MSCI World è un <b>proxy</b>, non l'indice: gli ETF reali (URTH, ACWI, VT) non sono " +
"nell'abbonamento dati del conto, quindi è ricostruito come 70% SPY + 30% EFA — e la quota USA " +
"dell'indice vero è passata da ~50% nel 2008 a ~72% oggi, quindi il peso fisso è " +
"un'approssimazione (fra 50% e 80% di USA il rendimento si muove di 0,7 punti, il Sharpe di " +
"0,05). Il prezzo di un ETF è già al netto del suo TER. Non c'è rischio di cambio nel modello: " +
"tutto è in dollari, e un investitore in euro lo corre su entrambi i lati. " +
"Script: <b>scripts/research/r0807_asset_compare.py</b>.";
})();
</script>
+531
View File
@@ -0,0 +1,531 @@
<title>Crescita del capitale: quanto è versamento e quanto è guadagno</title>
<style>
:root {
color-scheme: light;
--ground: #f7f7f5;
--surface: #fdfdfc;
--rule: #dededa;
--rule-soft: #ebebe7;
--ink: #16171a;
--ink-2: #55575c;
--ink-3: #83858b;
--versato: #2a78d6;
--guadagno: #eb6834;
--grid: #e6e6e2;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--ground: #17181a;
--surface: #1d1e21;
--rule: #34363a;
--rule-soft: #26282b;
--ink: #f1f1ee;
--ink-2: #a8aab0;
--ink-3: #7c7e85;
--versato: #3987e5;
--guadagno: #d95926;
--grid: #2b2d31;
}
}
:root[data-theme="dark"] {
color-scheme: dark;
--ground: #17181a;
--surface: #1d1e21;
--rule: #34363a;
--rule-soft: #26282b;
--ink: #f1f1ee;
--ink-2: #a8aab0;
--ink-3: #7c7e85;
--versato: #3987e5;
--guadagno: #d95926;
--grid: #2b2d31;
}
*, *::before, *::after { box-sizing: border-box; }
body {
background: var(--ground);
color: var(--ink);
font-family: Georgia, "Iowan Old Style", "Times New Roman", serif;
font-size: 17px;
line-height: 1.62;
margin: 0;
padding: 0 24px 96px;
-webkit-font-smoothing: antialiased;
}
.wrap { max-width: 1080px; margin: 0 auto; display: flex; flex-direction: column; gap: 44px; }
.prose { max-width: 68ch; display: flex; flex-direction: column; gap: 18px; }
.mono, .eyebrow, .lede-meta, .legend, .axis, table, .tile, .switch {
font-family: ui-monospace, SFMono-Regular, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
font-variant-numeric: tabular-nums;
}
header { padding-top: 64px; display: flex; flex-direction: column; gap: 20px; }
.eyebrow {
font-size: 11.5px; letter-spacing: 0.16em; text-transform: uppercase;
color: var(--ink-3); margin: 0;
}
h1 {
font-size: clamp(30px, 4.4vw, 46px); line-height: 1.12; margin: 0;
font-weight: 400; letter-spacing: -0.015em; text-wrap: balance; max-width: 20ch;
}
.lede { font-size: 19px; color: var(--ink-2); margin: 0; max-width: 62ch; }
.lede b { color: var(--ink); font-weight: 700; }
.lede-meta {
font-size: 12.5px; color: var(--ink-3); line-height: 1.9; margin: 0;
padding-top: 16px; border-top: 1px solid var(--rule);
}
.lede-meta b { color: var(--ink-2); font-weight: 400; }
h2 {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12.5px; letter-spacing: 0.15em; text-transform: uppercase;
color: var(--ink-2); font-weight: 500; margin: 0 0 4px;
padding-bottom: 10px; border-bottom: 1px solid var(--rule);
}
p { margin: 0; }
strong { font-weight: 700; }
em { font-style: italic; color: var(--ink-2); }
figure { margin: 0; display: flex; flex-direction: column; gap: 10px; }
.figcap {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px; color: var(--ink-3); line-height: 1.7; max-width: 78ch;
}
.chart-title {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12.5px; letter-spacing: 0.08em; text-transform: uppercase;
color: var(--ink); margin: 0;
}
.chart-title span { color: var(--ink-3); text-transform: none; letter-spacing: 0; }
.chart-box {
background: var(--surface); border: 1px solid var(--rule); border-radius: 3px;
padding: 18px 16px 10px; overflow-x: auto;
}
.chart-box svg { display: block; width: 100%; min-width: 620px; height: auto; }
.legend { display: flex; gap: 22px; flex-wrap: wrap; font-size: 12px; color: var(--ink-2); align-items: center; }
.legend i { width: 11px; height: 11px; border-radius: 2px; display: inline-block; margin-right: 7px; vertical-align: -1px; }
.legend .band { width: 2px; height: 13px; border-radius: 0; background: var(--ink-3); margin-right: 7px; }
.stack { display: flex; flex-direction: column; gap: 30px; }
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 1px; background: var(--rule); border: 1px solid var(--rule); border-radius: 3px; overflow: hidden; }
.tile { background: var(--surface); padding: 18px 18px 20px; display: flex; flex-direction: column; gap: 6px; }
.tile .k { font-size: 11px; letter-spacing: 0.13em; text-transform: uppercase; color: var(--ink-3); }
.tile .v { font-size: 27px; letter-spacing: -0.02em; color: var(--ink); line-height: 1.1; }
.tile .s { font-size: 12px; color: var(--ink-2); line-height: 1.6; }
.tile .v small { font-size: 15px; color: var(--ink-3); letter-spacing: 0; }
details { border-top: 1px solid var(--rule); padding-top: 14px; }
summary {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase;
color: var(--ink-2); cursor: pointer; padding: 4px 0;
}
summary:focus-visible { outline: 2px solid var(--versato); outline-offset: 3px; }
.tablewrap { overflow-x: auto; margin-top: 16px; }
table { border-collapse: collapse; font-size: 12.5px; width: 100%; min-width: 660px; }
th, td { padding: 6px 12px 6px 0; text-align: right; white-space: nowrap; }
th:first-child, td:first-child { text-align: left; padding-left: 0; }
thead th { color: var(--ink-3); font-weight: 400; border-bottom: 1px solid var(--rule); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; }
tbody td { border-bottom: 1px solid var(--rule-soft); color: var(--ink-2); }
tbody tr td:first-child { color: var(--ink); }
tbody tr.mark td { background: color-mix(in srgb, var(--versato) 7%, transparent); }
caption { caption-side: top; text-align: left; font-size: 12px; color: var(--ink-3); padding-bottom: 10px; }
/* confronto fra piani */
.cmp { border: 1px solid var(--rule); border-radius: 3px; overflow: hidden; }
.cmp table { min-width: 520px; }
.cmp thead th { padding: 12px 14px 10px; background: var(--surface); }
.cmp td { padding: 9px 14px; }
.cmp tbody tr:last-child td { border-bottom: 0; }
.cmp .hi { color: var(--ink); }
#tip {
position: fixed; z-index: 40; pointer-events: none; opacity: 0;
transition: opacity .1s ease;
background: var(--surface); border: 1px solid var(--rule); border-radius: 3px;
padding: 10px 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11.5px; line-height: 1.75; color: var(--ink-2);
box-shadow: 0 6px 22px rgba(0,0,0,.14); font-variant-numeric: tabular-nums;
max-width: 260px;
}
#tip .t { color: var(--ink); letter-spacing: 0.08em; text-transform: uppercase; font-size: 10.5px; display: block; margin-bottom: 5px; }
#tip .r { display: flex; justify-content: space-between; gap: 18px; }
#tip .r b { font-weight: 400; color: var(--ink); }
#tip .sw { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 6px; vertical-align: 0; }
.bargroup { cursor: default; }
.bargroup:focus-visible { outline: none; }
.bargroup:focus-visible .hit { stroke: var(--ink); stroke-width: 1.5; }
.caveat {
border-left: 2px solid var(--guadagno); padding: 2px 0 2px 18px;
color: var(--ink-2); font-size: 16px; max-width: 64ch;
}
.caveat b { color: var(--ink); font-weight: 700; }
footer {
border-top: 1px solid var(--rule); padding-top: 18px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11.5px; color: var(--ink-3); line-height: 1.85; max-width: 78ch;
}
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
</style>
<div class="wrap">
<header>
<p class="eyebrow">PythagorasGoal · piano di accumulo · 2026-08-07</p>
<h1>Quanto è versamento e quanto è guadagno</h1>
<p class="lede">
Il piano con <b>€800 al mese</b> — più i €5.000 iniziali sul conto Deribit — anno per anno,
con le due componenti del capitale separate: i bonifici, che sono decisi, e il rendimento del
book, che è estratto.
</p>
<p class="lede-meta">
book live <b>TP01 + SKH01 75/25</b> alle ancore canoniche, de-luckato <b>×0.89</b> ·
block bootstrap <b>4.000 path</b>, blocchi da 20 giorni ·
bersaglio <b>$272.061</b> (capitale-rendita per €50/giorno netti) ·
<b>scripts/research/r0807_growth_yearly.py --dep 800</b>
</p>
</header>
<section class="prose">
<h2>La forma della curva</h2>
<p>
Per i primi sette anni il capitale <em>è</em> quasi solo la somma dei bonifici: il book
aggiunge poco perché ha poco su cui lavorare. Il sorpasso — l'anno in cui il guadagno
cumulato supera tutto il denaro versato — arriva all'<strong id="x-cross-l"></strong> anno
nel modello lordo e all'<strong id="x-cross-n">11º</strong> col fisco dentro. Da lì la curva
cambia natura: smette di essere un piano di risparmio e diventa un capitale che lavora.
</p>
<p>
Versare di più <em>non</em> anticipa il sorpasso — lo ritarda, perché alza l'asticella che il
guadagno deve superare. A €500/mese cadeva al 7º anno; qui all'8º. Quello che i €300 in più
comprano è il <strong>traguardo</strong>, non il momento in cui la strategia prende il
sopravvento: la mediana tocca il bersaglio al <strong id="x-hit-n">12º</strong> anno col
fisco, contro il 15º a €500.
</p>
</section>
<div class="tiles" id="tiles"></div>
<section class="stack">
<figure>
<p class="chart-title">Modello lordo <span>— nessuna imposta durante l'accumulo</span></p>
<div class="legend" aria-hidden="true">
<span><i style="background:var(--versato)"></i>versato (deterministico)</span>
<span><i style="background:var(--guadagno)"></i>guadagno del percorso mediano</span>
<span><i class="band"></i>banda p25p75 del capitale</span>
</div>
<div class="chart-box"><svg id="c-lordo" role="img" aria-label="Capitale per anno con 800 euro al mese, modello lordo, scomposto in versamenti e guadagno"></svg></div>
</figure>
<figure>
<p class="chart-title">Con il fisco d'accumulo <span>— plusvalenze 33% annue + patrimoniale 0,2%</span></p>
<div class="chart-box"><svg id="c-netto" role="img" aria-label="Capitale per anno con 800 euro al mese e imposte, scomposto in versamenti e guadagno"></svg></div>
<figcaption class="figcap">
Stessa scala verticale nei due grafici: la differenza di altezza <em>è</em> il costo del fisco.
La banda p25p75 dice quanto il percorso può spostarsi; il guadagno disegnato è quello del
percorso mediano, non di un percorso singolo.
</figcaption>
</figure>
</section>
<section class="prose">
<h2>Cosa comprano i €300 in più</h2>
<p>
Il confronto è a parità di tutto il resto — stesso book, stessi path, stesso lump da €5.000 —
e va letto sulla riga della <em>probabilità</em>, non su quella del capitale: la mediana dice
dove finisce metà dei percorsi, la probabilità dice quanti ci arrivano.
</p>
</section>
<div class="cmp"><div class="tablewrap"><table id="cmp"></table></div></div>
<section class="prose">
<h2>Il vincolo dei dieci anni</h2>
<p class="caveat">
Anche €800 al mese <b>non bastano</b> per il traguardo in dieci anni: la probabilità col fisco
dentro è <b id="x-p10">25,5%</b>. Serve circa <b>€8801.050 al mese</b> — e a quel punto si
sono versati oltre $119.000 per arrivare a $272.061, cioè <em>a orizzonte corto non fai
lavorare la strategia, compri il capitale coi bonifici</em>.
</p>
<p>
L'imposta durante l'accumulo <strong>non era mai stata contata</strong> in nessuna traiettoria
del progetto: l'aliquota compariva in un solo punto, la lordizzazione del bersaglio in fase di
<em>prelievo</em>. L'errore è composto — <strong id="x-tax5">15%</strong> di capitale a 5 anni,
<strong id="x-tax10">29%</strong> a 10, <strong id="x-tax15">42%</strong> a 15 — quindi
cresce proprio sull'orizzonte su cui si sta decidendo. Le assunzioni (33% sulle plusvalenze,
minusvalenze riportabili 4 anni, 0,2% annuo sul valore) sono dichiarate e <em>non sono un
parere fiscale</em>.
</p>
</section>
<details>
<summary>Tabella completa — i due regimi, anno per anno, a €800/mese</summary>
<div class="tablewrap">
<table id="tbl">
<caption>Valori in dollari. «capitale» è la mediana fra i 4.000 percorsi; «P(bersaglio)» è la quota di percorsi che ha toccato $272.061 in qualunque momento entro quell'anno.</caption>
</table>
</div>
</details>
<footer>
Il «guadagno» di ogni riga è <b>mediana(capitale) versato</b>: il rendimento del percorso
mediano, non di un percorso singolo — le due componenti non sono indipendenti e non vanno
sommate come se lo fossero. I versamenti entrano ogni 30 giorni. Nessun rischio di venue in
questo modello: a p=1% annuo di fallimento dell'exchange la probabilità di arrivare scende di
~2 punti e quella di <b>perdere tutto</b> sale al 18%, e a p=5% il capitale mediano a 20 anni è
zero per qualunque calendario di versamenti. E un piano si giudica sulle sue deviazioni: qui i
versamenti non si fermano mai, che è l'unico scenario che non succede.
</footer>
</div>
<div id="tip" role="status" aria-live="polite"></div>
<script>
(function () {
"use strict";
/* riga = [anno, versato, guadagno_med, p25, p75, P(bersaglio)] */
const D800 = {
"lordo":[[1,16514,1769,17245,19578,0.0],[2,26978,5761,30155,35973,0.0],[3,37442,12653,45263,55806,0.0],[4,47906,22674,62395,80542,0.0],[5,58370,36254,82632,109462,0.0],[6,68834,54149,105982,145050,0.001],[7,80170,77582,134771,188140,0.027],[8,90634,107277,166043,240255,0.153],[9,101098,145547,201137,302141,0.406],[10,111562,190624,245158,375704,0.663],[11,122026,246454,293656,463843,0.844],[12,132490,314762,352325,569174,0.942],[13,143826,397855,420145,697480,0.981],[14,154290,497056,504972,849886,0.995],[15,164754,611612,598478,1027590,0.998]],
"netto":[[1,16514,1150,16970,18530,0.0],[2,26978,3704,28990,32772,0.0],[3,37442,8016,42367,49006,0.0],[4,47906,14081,56895,67961,0.0],[5,58370,21953,73255,88990,0.0],[6,68834,32239,91315,113319,0.0],[7,80170,45114,112318,141436,0.001],[8,90634,60851,134505,172871,0.014],[9,101098,80839,158029,208664,0.072],[10,111562,102940,185847,248679,0.255],[11,122026,129351,216191,293736,0.508],[12,132490,160661,249046,345007,0.736],[13,143826,196548,286976,403693,0.884],[14,154290,238407,331309,470099,0.961],[15,164754,285353,377454,544074,0.99]]};
/* il piano precedente, per il confronto */
const D500 = {
"lordo":[[1,12590,1480,13204,15143,0.0],[2,19130,4439,21604,26058,0.0],[3,25670,9315,31459,39175,0.0],[4,32210,16336,42734,55555,0.0],[5,38750,25573,55941,74606,0.0],[6,45290,37658,71107,98301,0.0],[7,52375,53218,90027,126660,0.001],[8,58915,73347,110476,161177,0.012],[9,65455,98449,133388,202044,0.059],[10,71995,128781,162249,250780,0.2],[11,78535,166083,194409,309087,0.413],[12,85075,210811,232546,378373,0.625],[13,92160,266535,276493,463805,0.787],[14,98700,331836,332045,563596,0.901],[15,105240,406396,392375,682638,0.958]],
"netto":[[1,12590,964,12975,14272,0.0],[2,19130,2854,20694,23582,0.0],[3,25670,5911,29311,34169,0.0],[4,32210,10094,38695,46478,0.0],[5,38750,15457,49239,60153,0.0],[6,45290,22328,60828,76054,0.0],[7,52375,30864,74451,94333,0.0],[8,58915,41442,88742,114692,0.0],[9,65455,54390,103868,138004,0.001],[10,71995,68996,121916,164188,0.012],[11,78535,86387,141436,193292,0.051],[12,85075,107166,162790,226868,0.154],[13,92160,130846,187634,264641,0.334],[14,98700,158081,216211,308116,0.537],[15,105240,188583,245868,356333,0.732]]};
const TARGET = 272061;
const I = { ANNO:0, VERS:1, GUAD:2, P25:3, P75:4, PHIT:5 };
const D = D800;
const usd = n => "$" + Math.round(n).toLocaleString("it-IT");
const usdK = n => n === 0 ? "$0" : "$" + Math.round(n / 1000) + "k";
const pct = n => (100 * n).toFixed(1).replace(".", ",") + "%";
const ORD = ["", "1º","2º","3º","4º","5º","6º","7º","8º","9º","10º","11º","12º","13º","14º","15º"];
const at = (set, key, y) => set[key].find(r => r[I.ANNO] === y);
const cap = r => r[I.VERS] + r[I.GUAD];
/* primo anno in cui il guadagno cumulato supera tutto il versato */
const crossover = key => (D[key].find(r => r[I.GUAD] > r[I.VERS]) || {})[I.ANNO];
/* primo anno in cui il capitale MEDIANO tocca il bersaglio */
const hitYear = key => (D[key].find(r => cap(r) >= TARGET) || {})[I.ANNO];
/* ---------------- testo guidato dai dati ---------------- */
const put = (id, txt) => { const e = document.getElementById(id); if (e) e.textContent = txt; };
put("x-cross-l", ORD[crossover("lordo")]);
put("x-cross-n", ORD[crossover("netto")]);
put("x-hit-n", ORD[hitYear("netto")]);
put("x-p10", pct(at(D, "netto", 10)[I.PHIT]));
[5, 10, 15].forEach(y => {
const l = cap(at(D, "lordo", y)), n = cap(at(D, "netto", y));
put("x-tax" + y, "" + Math.round(100 * (1 - n / l)) + "%");
});
/* ---------------- tiles ---------------- */
const tiles = [5, 10, 15].map(y => {
const n = at(D, "netto", y), l = at(D, "lordo", y);
return { k: "a " + y + " anni", v: usd(cap(n)),
s: "versati " + usd(n[I.VERS]) + " · il guadagno è il " +
Math.round(100 * n[I.GUAD] / cap(n)) + "% del capitale · lordo sarebbe " + usd(cap(l)) };
}).concat([{ k: "sorpasso", v: crossover("lordo") + "º<small> / " + crossover("netto") + "º anno</small>",
s: "l'anno in cui il guadagno cumulato supera tutto il versato — lordo / col fisco" }]);
document.getElementById("tiles").innerHTML = tiles.map(t =>
'<div class="tile"><span class="k">' + t.k + '</span><span class="v">' + t.v +
'</span><span class="s">' + t.s + "</span></div>").join("");
/* ---------------- confronto 500 vs 800 ---------------- */
const cmpRows = [
["versato a 10 anni", s => usd(at(s, "netto", 10)[I.VERS])],
["capitale a 10 anni", s => usd(cap(at(s, "netto", 10)))],
["P(bersaglio) a 10 anni", s => pct(at(s, "netto", 10)[I.PHIT]), true],
["versato a 15 anni", s => usd(at(s, "netto", 15)[I.VERS])],
["capitale a 15 anni", s => usd(cap(at(s, "netto", 15)))],
["P(bersaglio) a 15 anni", s => pct(at(s, "netto", 15)[I.PHIT]), true],
["anno del traguardo (mediana)", s => {
const r = s.netto.find(x => x[I.VERS] + x[I.GUAD] >= TARGET);
return r ? ORD[r[I.ANNO]] + " anno" : "oltre il 15º"; }, true]
];
document.getElementById("cmp").innerHTML =
"<caption>Tutti i valori sono <b>col fisco d'accumulo</b>, il regime realistico. Stesso lump da €5.000, stessi 4.000 percorsi.</caption>" +
"<thead><tr><th></th><th>€500/mese</th><th>€800/mese</th></tr></thead><tbody>" +
cmpRows.map(([k, f, hi]) =>
"<tr><td>" + k + "</td><td" + (hi ? ' class="hi"' : "") + ">" + f(D500) +
"</td><td" + (hi ? ' class="hi"' : "") + ">" + f(D800) + "</td></tr>").join("") +
"</tbody>";
/* ---------------- tooltip ---------------- */
const tip = document.getElementById("tip");
function showTip(evt, serie, row) {
tip.innerHTML =
'<span class="t">anno ' + row[I.ANNO] + " · " + serie + "</span>" +
'<div class="r"><span><span class="sw" style="background:var(--versato)"></span>versato</span><b>' + usd(row[I.VERS]) + "</b></div>" +
'<div class="r"><span><span class="sw" style="background:var(--guadagno)"></span>guadagno</span><b>' + usd(row[I.GUAD]) + "</b></div>" +
'<div class="r"><span>capitale mediano</span><b>' + usd(cap(row)) + "</b></div>" +
'<div class="r"><span>banda p25p75</span><b>' + usdK(row[I.P25]) + " " + usdK(row[I.P75]) + "</b></div>" +
'<div class="r"><span>P(bersaglio)</span><b>' + pct(row[I.PHIT]) + "</b></div>";
tip.style.opacity = "1";
moveTip(evt);
}
function moveTip(evt) {
const r = tip.getBoundingClientRect();
let x = evt.clientX + 16, y = evt.clientY - r.height - 12;
if (x + r.width > window.innerWidth - 8) x = evt.clientX - r.width - 16;
if (y < 8) y = evt.clientY + 18;
tip.style.left = x + "px";
tip.style.top = y + "px";
}
const hideTip = () => { tip.style.opacity = "0"; };
/* ---------------- scala verticale, dai dati ---------------- */
const rawMax = Math.max(...["lordo", "netto"].flatMap(k => D[k].map(r => r[I.P75])));
const STEP = [25000, 50000, 100000, 150000, 200000, 250000, 500000]
.find(s => rawMax / s <= 8) || 1000000;
const YMAX = Math.ceil(rawMax / STEP) * STEP;
/* ---------------- chart ---------------- */
const NS = "http://www.w3.org/2000/svg";
const el = (n, a) => { const e = document.createElementNS(NS, n);
for (const k in a) e.setAttribute(k, a[k]); return e; };
function roundedTop(x, yTop, w, h, r) {
if (h <= 0) return "";
r = Math.min(r, h, w / 2);
return "M" + x + "," + (yTop + h) + "V" + (yTop + r) +
"a" + r + "," + r + " 0 0 1 " + r + "," + (-r) +
"h" + (w - 2 * r) +
"a" + r + "," + r + " 0 0 1 " + r + "," + r +
"V" + (yTop + h) + "Z";
}
function draw(svgId, key, label) {
const rows = D[key];
const W = 960, H = 360, M = { t: 26, r: 108, b: 38, l: 74 };
const pw = W - M.l - M.r, ph = H - M.t - M.b;
const svg = document.getElementById(svgId);
svg.setAttribute("viewBox", "0 0 " + W + " " + H);
svg.innerHTML = "";
const y = v => M.t + ph - (v / YMAX) * ph;
const step = pw / rows.length;
const bw = Math.min(38, step * 0.62);
for (let v = 0; v <= YMAX; v += STEP) {
svg.appendChild(el("line", { x1: M.l, x2: M.l + pw, y1: y(v), y2: y(v),
stroke: v === 0 ? "var(--ink-3)" : "var(--grid)", "stroke-width": 1 }));
const t = el("text", { x: M.l - 12, y: y(v) + 4, "text-anchor": "end",
class: "axis", fill: "var(--ink-3)", "font-size": 11 });
t.textContent = usdK(v);
svg.appendChild(t);
}
const ty = y(TARGET);
svg.appendChild(el("line", { x1: M.l, x2: M.l + pw, y1: ty, y2: ty,
stroke: "var(--ink-2)", "stroke-width": 1.5, "stroke-dasharray": "5 4" }));
const tl = el("text", { x: M.l + 8, y: ty - 8, class: "axis",
fill: "var(--ink-2)", "font-size": 11 });
tl.textContent = "bersaglio $272.061 — capitale-rendita";
svg.appendChild(tl);
rows.forEach((row, i) => {
const cx = M.l + step * i + step / 2;
const x = cx - bw / 2;
const vTop = y(row[I.VERS]), gTop = y(cap(row)), base = y(0);
const g = el("g", { class: "bargroup", tabindex: "0",
"aria-label": "anno " + row[I.ANNO] + ": versato " + usd(row[I.VERS]) +
", guadagno " + usd(row[I.GUAD]) });
g.appendChild(el("path", { d: roundedTop(x, vTop, bw, base - vTop, 0), fill: "var(--versato)" }));
/* distanziatore da 2px solo dove il segmento e' visibile: nei primi anni il guadagno
vale pochi pixel e il distanziatore lo cancellerebbe */
const rawH = vTop - gTop;
const gh = rawH - (rawH > 6 ? 2 : 0);
if (gh > 0.3) g.appendChild(el("path", { d: roundedTop(x, gTop, bw, gh, 4), fill: "var(--guadagno)" }));
const p25 = y(row[I.P25]), p75 = y(row[I.P75]);
g.appendChild(el("line", { x1: cx, x2: cx, y1: p75, y2: p25,
stroke: "var(--surface)", "stroke-width": 4 }));
g.appendChild(el("line", { x1: cx, x2: cx, y1: p75, y2: p25,
stroke: "var(--ink-2)", "stroke-width": 2, opacity: .85 }));
[p25, p75].forEach(yy => g.appendChild(el("line", { x1: cx - 5, x2: cx + 5, y1: yy, y2: yy,
stroke: "var(--ink-2)", "stroke-width": 2, opacity: .85 })));
const hit = el("rect", { class: "hit", x: M.l + step * i, y: M.t,
width: step, height: ph, fill: "transparent" });
g.appendChild(hit);
g.addEventListener("pointerenter", e => showTip(e, label, row));
g.addEventListener("pointermove", moveTip);
g.addEventListener("pointerleave", hideTip);
g.addEventListener("focus", () => {
const b = hit.getBoundingClientRect();
showTip({ clientX: b.left + b.width / 2, clientY: b.top + 40 }, label, row);
});
g.addEventListener("blur", hideTip);
svg.appendChild(g);
const xt = el("text", { x: cx, y: H - 14, "text-anchor": "middle", class: "axis",
fill: row[I.ANNO] === crossover(key) ? "var(--ink)" : "var(--ink-3)", "font-size": 11 });
xt.textContent = row[I.ANNO];
svg.appendChild(xt);
});
/* etichette dirette delle SERIE a destra dell'ultima barra: l'identita' non e' mai solo
colore, ed e' il relief richiesto dal contrasto dell'arancio in chiaro */
const last = rows[rows.length - 1];
const lastTop = y(cap(last)), lastMid = y(last[I.VERS]), lx = M.l + pw + 14;
[["guadagno", usd(last[I.GUAD]), (lastTop + lastMid) / 2, "var(--guadagno)"],
["versato", usd(last[I.VERS]), (lastMid + y(0)) / 2, "var(--versato)"]
].forEach(([nome, val, yy, col]) => {
const a = el("text", { x: lx, y: yy - 2, class: "axis", fill: col, "font-size": 11 });
a.textContent = nome;
svg.appendChild(a);
const b = el("text", { x: lx, y: yy + 12, class: "axis", fill: "var(--ink)", "font-size": 11.5 });
b.textContent = val;
svg.appendChild(b);
});
/* etichetta diretta: il sorpasso */
const ci = crossover(key) - 1, cRow = rows[ci];
const ccx = M.l + step * ci + step / 2, cy = y(cap(cRow));
svg.appendChild(el("line", { x1: ccx, x2: ccx, y1: cy - 6, y2: cy - 26,
stroke: "var(--guadagno)", "stroke-width": 1.5 }));
const ct = el("text", { x: ccx, y: cy - 32, "text-anchor": "middle", class: "axis",
fill: "var(--guadagno)", "font-size": 11 });
ct.textContent = "sorpasso";
svg.appendChild(ct);
const ax = el("text", { x: M.l + pw + 14, y: H - 14, class: "axis",
fill: "var(--ink-3)", "font-size": 10.5 });
ax.textContent = "anno";
svg.appendChild(ax);
}
draw("c-lordo", "lordo", "lordo");
draw("c-netto", "netto", "col fisco");
/* ---------------- tabella ---------------- */
const tbl = document.getElementById("tbl");
const head = "<thead><tr><th>anno</th><th>versato</th>" +
"<th>guad. lordo</th><th>capitale lordo</th><th>P(bers.)</th>" +
"<th>guad. netto</th><th>capitale netto</th><th>P(bers.)</th><th>costo fisco</th></tr></thead>";
const body = D.lordo.map((l, i) => {
const n = D.netto[i], cl = cap(l), cn = cap(n);
const mark = [5, 10, 15].includes(l[I.ANNO]) ? ' class="mark"' : "";
return "<tr" + mark + "><td>" + l[I.ANNO] + "</td><td>" + usd(l[I.VERS]) + "</td><td>" +
usd(l[I.GUAD]) + "</td><td>" + usd(cl) + "</td><td>" + pct(l[I.PHIT]) + "</td><td>" +
usd(n[I.GUAD]) + "</td><td>" + usd(cn) + "</td><td>" + pct(n[I.PHIT]) + "</td><td>" +
(100 * (cn / cl - 1)).toFixed(1).replace(".", ",") + "%</td></tr>";
}).join("");
tbl.insertAdjacentHTML("beforeend", head + "<tbody>" + body + "</tbody>");
})();
</script>
+393
View File
@@ -0,0 +1,393 @@
<title>Quale strategia — book, ETF o un mix</title>
<style>
:root {
color-scheme: light;
--ground: #f7f7f5; --surface: #fdfdfc; --sunken: #f1f1ee;
--rule: #dededa; --rule-soft: #ebebe7;
--ink: #16171a; --ink-2: #55575c; --ink-3: #83858b;
--s1: #2a78d6; --s2: #eb6834; --s3: #1baf7a; --s4: #eda100; --grid: #e6e6e2;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--ground: #17181a; --surface: #1d1e21; --sunken: #232427;
--rule: #34363a; --rule-soft: #26282b;
--ink: #f1f1ee; --ink-2: #a8aab0; --ink-3: #7c7e85;
--s1: #3987e5; --s2: #d95926; --s3: #199e70; --s4: #c98500; --grid: #2b2d31;
}
}
:root[data-theme="dark"] {
color-scheme: dark;
--ground: #17181a; --surface: #1d1e21; --sunken: #232427;
--rule: #34363a; --rule-soft: #26282b;
--ink: #f1f1ee; --ink-2: #a8aab0; --ink-3: #7c7e85;
--s1: #3987e5; --s2: #d95926; --s3: #199e70; --s4: #c98500; --grid: #2b2d31;
}
*, *::before, *::after { box-sizing: border-box; }
body { background: var(--ground); color: var(--ink);
font-family: Georgia, "Iowan Old Style", "Times New Roman", serif;
font-size: 17px; line-height: 1.62; margin: 0; padding: 0 24px 96px; -webkit-font-smoothing: antialiased; }
.wrap { max-width: 1080px; margin: 0 auto; display: flex; flex-direction: column; gap: 44px; }
.prose { max-width: 68ch; display: flex; flex-direction: column; gap: 16px; }
.mono, .eyebrow, .lede-meta, .legend, .axis, table, .tile {
font-family: ui-monospace, SFMono-Regular, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
font-variant-numeric: tabular-nums; }
header { padding-top: 60px; display: flex; flex-direction: column; gap: 18px; }
.eyebrow { font-size: 11.5px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--ink-3); margin: 0; }
h1 { font-size: clamp(29px, 4.2vw, 44px); line-height: 1.12; margin: 0; font-weight: 400;
letter-spacing: -0.015em; text-wrap: balance; max-width: 21ch; }
.lede { font-size: 19px; color: var(--ink-2); margin: 0; max-width: 62ch; }
.lede b { color: var(--ink); font-weight: 700; }
.lede-meta { font-size: 12.5px; color: var(--ink-3); line-height: 1.9; margin: 0;
padding-top: 14px; border-top: 1px solid var(--rule); }
.lede-meta b { color: var(--ink-2); font-weight: 400; }
h2 { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px;
letter-spacing: 0.15em; text-transform: uppercase; color: var(--ink-2); font-weight: 500;
margin: 0 0 4px; padding-bottom: 10px; border-bottom: 1px solid var(--rule); }
p { margin: 0; }
em { font-style: italic; color: var(--ink-2); }
strong { font-weight: 700; }
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(215px, 1fr)); gap: 1px;
background: var(--rule); border: 1px solid var(--rule); border-radius: 3px; overflow: hidden; }
.tile { background: var(--surface); padding: 17px 18px 19px; display: flex; flex-direction: column; gap: 5px; }
.tile .k { font-size: 11px; letter-spacing: 0.13em; text-transform: uppercase; color: var(--ink-3); }
.tile .v { font-size: 26px; letter-spacing: -0.02em; color: var(--ink); line-height: 1.12; }
.tile .v small { font-size: 14px; color: var(--ink-3); letter-spacing: 0; }
.tile .s { font-size: 12px; color: var(--ink-2); line-height: 1.55; }
.tablewrap { overflow-x: auto; border: 1px solid var(--rule); border-radius: 3px;
background: var(--surface); padding: 14px 16px; }
table.data { border-collapse: collapse; font-size: 12.5px; width: 100%; min-width: 620px; }
table.data th, table.data td { padding: 9px 14px 9px 0; text-align: right; white-space: nowrap; }
table.data th:first-child, table.data td:first-child { text-align: left; }
table.data thead th { color: var(--ink-3); font-weight: 400; border-bottom: 1px solid var(--rule);
font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; }
table.data tbody td { border-bottom: 1px solid var(--rule-soft); color: var(--ink-2); }
table.data tbody td:first-child { color: var(--ink); }
table.data tbody tr.best td { background: color-mix(in srgb, var(--s1) 9%, transparent); color: var(--ink); }
caption { caption-side: top; text-align: left; font-size: 12px; color: var(--ink-3); padding-bottom: 12px; }
.cell { display: inline-block; min-width: 46px; text-align: right; }
figure { margin: 0; display: flex; flex-direction: column; gap: 10px; }
.chart-title { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px;
letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink); margin: 0; }
.chart-title span { color: var(--ink-3); text-transform: none; letter-spacing: 0; }
.chart-box { background: var(--surface); border: 1px solid var(--rule); border-radius: 3px;
padding: 18px 16px 10px; overflow-x: auto; }
.chart-box svg { display: block; width: 100%; min-width: 620px; height: auto; }
.legend { display: flex; gap: 20px; flex-wrap: wrap; font-size: 12px; color: var(--ink-2); align-items: center; }
.legend i { width: 20px; height: 3px; border-radius: 2px; display: inline-block; margin-right: 8px; vertical-align: 3px; }
.figcap { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
color: var(--ink-3); line-height: 1.7; max-width: 80ch; }
.caveat { border-left: 2px solid var(--s2); padding: 2px 0 2px 18px; color: var(--ink-2);
font-size: 16px; max-width: 64ch; }
.caveat b { color: var(--ink); font-weight: 700; }
#tip { position: fixed; z-index: 40; pointer-events: none; opacity: 0; transition: opacity .1s ease;
background: var(--surface); border: 1px solid var(--rule); border-radius: 3px; padding: 10px 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; line-height: 1.75;
color: var(--ink-2); box-shadow: 0 6px 22px rgba(0,0,0,.14); font-variant-numeric: tabular-nums; max-width: 270px; }
#tip .t { color: var(--ink); letter-spacing: 0.08em; text-transform: uppercase; font-size: 10.5px; display: block; margin-bottom: 5px; }
#tip .r { display: flex; justify-content: space-between; gap: 18px; }
#tip .r b { font-weight: 400; color: var(--ink); }
#tip .sw { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 6px; }
footer { border-top: 1px solid var(--rule); padding-top: 18px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px;
color: var(--ink-3); line-height: 1.85; max-width: 80ch; }
footer b { color: var(--ink-2); font-weight: 400; }
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
</style>
<div class="wrap">
<header>
<p class="eyebrow">PythagorasGoal · scelta di portafoglio · 2026-08-07</p>
<h1 id="h1">Quale strategia, a 12 anni</h1>
<p class="lede" id="lede"></p>
<p class="lede-meta" id="meta"></p>
</header>
<section class="prose">
<h2>1 · Perché la domanda non ha una risposta sola</h2>
<p id="p-intro"></p>
<p class="caveat" id="p-asim"></p>
</section>
<div class="tiles" id="tiles"></div>
<figure>
<p class="chart-title">Quota del proprio capitale-rendita raggiunta <span>— per peso sul book, in ciascuno scenario</span></p>
<div class="legend" id="leg"></div>
<div class="chart-box"><svg id="c1" role="img" aria-label="Quota del bersaglio raggiunta per peso sul book, quattro scenari"></svg></div>
<figcaption class="figcap" id="cap1"></figcaption>
</figure>
<section class="prose">
<h2>2 · La tabella della decisione</h2>
<p id="p-tab"></p>
</section>
<div class="tablewrap"><table class="data" id="t-dec"></table></div>
<section class="prose">
<h2>3 · Perché il mix non è un compromesso</h2>
<p id="p-corr"></p>
</section>
<div class="tablewrap"><table class="data" id="t-det"></table></div>
<section class="prose">
<h2>4 · La risposta</h2>
<p class="caveat" id="p-risp"></p>
<p id="p-limiti"></p>
</section>
<footer id="foot"></footer>
</div>
<div id="tip" role="status" aria-live="polite"></div>
<script id="best-data" type="application/json">/*__DATA__*/</script>
<script>
(function () {
"use strict";
const D = JSON.parse(document.getElementById("best-data").textContent);
const $ = id => document.getElementById(id);
const P = D.piano;
const SCEN = Object.keys(D.scenari);
const COL = ["var(--s1)", "var(--s3)", "var(--s2)", "var(--s4)"];
/* ⚠️ le chiavi dei pesi si LEGGONO dal JSON, non si ricostruiscono: Python le scrive come
"0.0"/"1.0", mentre String(P.pesi[0]) in JS darebbe "0" — mismatch silenzioso che rendeva
ogni lookup undefined. Vale per qualunque dizionario Python con chiavi numeriche. */
const W = Object.keys(D.scenari[SCEN[0]]).sort((a, b) => Number(a) - Number(b));
const kOf = w => W.find(k => Number(k) === Number(w));
const usd = n => "$" + Math.round(n).toLocaleString("it-IT");
const usdK = n => "$" + Math.round(n / 1000) + "k";
const pc = (x, d) => (100 * x).toFixed(d === undefined ? 0 : d).replace(".", ",") + "%";
const wlab = w => Number(w) === 0 ? "solo ETF" : (Number(w) === 1 ? "solo book" :
pc(Number(w)) + " book");
const S = (n, w) => D.scenari[n][kOf(w)];
/* ---------- intestazione ---------- */
$("h1").textContent = "Quale strategia, a " + P.anni + " anni";
$("lede").innerHTML =
"Con <b>€" + P.lump.toLocaleString("it-IT") + "</b> iniziali e <b>€" +
P.dep.toLocaleString("it-IT") + "/mese</b> per <b>" + P.anni + " anni</b>: tutto sul book, " +
"tutto su un ETF S&amp;P 500, o un mix? La risposta dipende da un'assunzione che " +
"<b>nessun dato può risolvere</b> — e allora si sceglie ciò che regge comunque.";
$("meta").innerHTML =
"finestra <b>" + D.finestra.da + " → " + D.finestra.a + "</b> (" + D.finestra.giorni +
" giorni): è l'unica dove un mix è definito · drift misurati: book <b>" + pc(D.drift.book, 1) +
"</b>, S&amp;P su questa finestra <b>" + pc(D.drift.eq_comune, 1) + "</b>, S&amp;P su 30 anni <b>" +
pc(D.drift.eq_30, 1) + "</b> · " + P.n_paths.toLocaleString("it-IT") + " percorsi · " +
"fisco cripto <b>" + pc(P.tax_crypto) + " annuo</b>, ETF <b>" + pc(P.tax_etf) + " differito</b>";
$("p-intro").innerHTML =
"Il book ha <b>" + (D.finestra.giorni / 365).toFixed(1) + " anni</b> di storia in tutto, " +
"l'indice ne ha trenta. Sugli stessi sette anni i due rendono quasi uguale (" +
pc(D.drift.book, 1) + " contro " + pc(D.drift.eq_comune, 1) + "); ma quei trent'anni " +
"contengono il 2000 e il 2008, e lì l'indice rende " + pc(D.drift.eq_30, 1) + ". " +
"Quindi la domanda vera non è «quale ha reso di più», è <em>quanto di ciò che ho misurato " +
"sopravvive ai prossimi dodici anni</em>. Non essendoci un dato che lo decida, si guarda la " +
"stessa scelta sotto quattro scenari e si tiene quella che non crolla in nessuno.";
$("p-asim").innerHTML =
"⚠️ <b>L'asimmetria fra i due scenari prudenti è essa stessa il risultato.</b> Quello " +
"sull'indice è <b>misurato</b> — trent'anni di storia esistono e dicono " + pc(D.drift.eq_30, 1) +
". Quello sul book è <b>giudiziale</b>: metà del drift, scelto a mano, perché " +
(D.finestra.giorni / 365).toFixed(1) + " anni sono tutta la storia che ha e non c'è nulla con " +
"cui stressarlo. Si può discutere la taglia di quel taglio; non lo si può togliere.";
/* ---------- tiles ---------- */
const peg = D.peggiori, rim = D.rimpianti;
const wMax = D.minimax, wReg = D.minregret;
const base = SCEN[0];
/* il criterio del caso peggiore puo' non distinguere due pesi: se lo scarto e' dentro il
rumore Monte Carlo (~1%) va detto, invece di far sembrare che uno vinca */
const ordPeg = W.slice().sort((a, b) => peg[b] - peg[a]);
const pareggio = Math.abs(peg[ordPeg[0]] - peg[ordPeg[1]]) < 0.01;
$("tiles").innerHTML = [
{ k: "massimizza il caso peggiore", v: pareggio ? wlab(ordPeg[0]) + " ≈ " + wlab(ordPeg[1]) : wlab(wMax),
s: pareggio
? "pareggio dentro il rumore (" + pc(peg[ordPeg[0]], 1) + " contro " + pc(peg[ordPeg[1]], 1) +
"): questo criterio non li distingue"
: "nello scenario a lei più ostile arriva al " + pc(peg[kOf(wMax)]) + " del proprio bersaglio" },
{ k: "minimizza il rimpianto", v: wlab(wReg),
s: "non perde mai più di " + pc(rim[kOf(wReg)]) + " rispetto alla scelta migliore di ogni scenario" },
{ k: "correlazione book ↔ indice", v: D.correlazione.aperta.toFixed(2).replace(".", ","),
s: "nei giorni di ribasso " + D.correlazione.ribasso.toFixed(2).replace(".", ",") +
"; nel 5% peggiore dell'indice (" + pc(D.correlazione.coda_indice, 2) + ") il book fa " +
pc(D.correlazione.coda_book, 2) },
{ k: "scenario base · solo book", v: pc(S(base, 1).quota),
s: "capitale " + usd(S(base, 1).med) + " contro un bersaglio di " + usd(S(base, 1).cap) }
].map(t => '<div class="tile"><span class="k">' + t.k + '</span><span class="v">' + t.v +
'</span><span class="s">' + t.s + "</span></div>").join("");
/* ---------- grafico: quota vs peso ---------- */
$("leg").innerHTML = SCEN.map((n, i) =>
"<span><i style='background:" + COL[i] + "'></i>" + n + "</span>").join("");
(function () {
const svg = $("c1");
const Wd = 960, H = 400, M = { t: 26, r: 150, b: 46, l: 66 };
const pw = Wd - M.l - M.r, ph = H - M.t - M.b;
svg.setAttribute("viewBox", "0 0 " + Wd + " " + H);
svg.innerHTML = "";
const NS = "http://www.w3.org/2000/svg";
const el = (n, a) => { const e = document.createElementNS(NS, n); for (const k in a) e.setAttribute(k, a[k]); return e; };
const vals = SCEN.flatMap(n => W.map(w => S(n, w).quota));
const YMAX = Math.ceil(Math.max(1.0, ...vals) / 0.2) * 0.2;
const x = w => M.l + Number(w) * pw;
const y = v => M.t + ph - (v / YMAX) * ph;
for (let v = 0; v <= YMAX + 1e-9; v += 0.2) {
svg.appendChild(el("line", { x1: M.l, x2: M.l + pw, y1: y(v), y2: y(v),
stroke: v === 0 ? "var(--ink-3)" : "var(--grid)", "stroke-width": 1 }));
const t = el("text", { x: M.l - 12, y: y(v) + 4, "text-anchor": "end", class: "axis",
fill: "var(--ink-3)", "font-size": 11 });
t.textContent = pc(v); svg.appendChild(t);
}
/* la riga del 100%: il bersaglio raggiunto */
svg.appendChild(el("line", { x1: M.l, x2: M.l + pw, y1: y(1), y2: y(1),
stroke: "var(--ink-2)", "stroke-width": 1.5, "stroke-dasharray": "5 4" }));
const bl = el("text", { x: M.l + 8, y: y(1) - 8, class: "axis", fill: "var(--ink-2)", "font-size": 11 });
bl.textContent = "bersaglio raggiunto"; svg.appendChild(bl);
W.forEach(w => {
const t = el("text", { x: x(w), y: H - 20, "text-anchor": "middle", class: "axis",
fill: "var(--ink-3)", "font-size": 11 });
t.textContent = pc(Number(w)); svg.appendChild(t);
});
const ax = el("text", { x: M.l + pw / 2, y: H - 4, "text-anchor": "middle", class: "axis",
fill: "var(--ink-3)", "font-size": 10.5 });
ax.textContent = "quota sul book (il resto su ETF S&P 500)"; svg.appendChild(ax);
SCEN.forEach((n, i) => {
const pts = W.map(w => x(w) + "," + y(S(n, w).quota)).join(" ");
svg.appendChild(el("polyline", { points: pts, fill: "none", stroke: COL[i],
"stroke-width": 2.5, "stroke-linejoin": "round" }));
W.forEach(w => svg.appendChild(el("circle", { cx: x(w), cy: y(S(n, w).quota), r: 3.5, fill: COL[i] })));
const last = S(n, W[W.length - 1]);
const a = el("text", { x: M.l + pw + 12, y: y(last.quota) - 3, class: "axis", fill: COL[i], "font-size": 11 });
a.textContent = n; svg.appendChild(a);
const b = el("text", { x: M.l + pw + 12, y: y(last.quota) + 12, class: "axis", fill: "var(--ink)", "font-size": 11.5 });
b.textContent = pc(last.quota); svg.appendChild(b);
});
/* hover per colonna */
const tip = $("tip");
const move = evt => { const r = tip.getBoundingClientRect();
let X = evt.clientX + 16, Y = evt.clientY - r.height - 12;
if (X + r.width > window.innerWidth - 8) X = evt.clientX - r.width - 16;
if (Y < 8) Y = evt.clientY + 18;
tip.style.left = X + "px"; tip.style.top = Y + "px"; };
W.forEach(w => {
const g = el("g", { tabindex: "0", "aria-label": wlab(w) });
g.appendChild(el("rect", { x: x(w) - pw / (W.length * 2), y: M.t,
width: pw / (W.length - 1) * 0.9, height: ph, fill: "transparent" }));
const show = evt => {
tip.innerHTML = '<span class="t">' + wlab(w) + "</span>" +
SCEN.map((n, i) => '<div class="r"><span><span class="sw" style="background:' + COL[i] +
'"></span>' + n + "</span><b>" + pc(S(n, w).quota) + "</b></div>").join("") +
'<div class="r" style="margin-top:5px"><span>capitale (base)</span><b>' +
usd(S(base, w).med) + "</b></div>" +
'<div class="r"><span>bersaglio (base)</span><b>' + usd(S(base, w).cap) + "</b></div>";
tip.style.opacity = "1"; move(evt); };
g.addEventListener("pointerenter", show);
g.addEventListener("pointermove", move);
g.addEventListener("pointerleave", () => tip.style.opacity = "0");
g.addEventListener("focus", () => show({ clientX: x(w) + 40, clientY: 320 }));
g.addEventListener("blur", () => tip.style.opacity = "0");
svg.appendChild(g);
});
$("cap1").innerHTML =
"Ogni punto è: capitale mediano a " + P.anni + " anni diviso il capitale-rendita di " +
"<em>quella</em> composizione — perché il bersaglio cambia col portafoglio, non è un numero " +
"fisso. Il 100% è la linea da superare. Le quattro curve sono lo stesso calcolo sotto quattro " +
"assunzioni di rendimento; le volatilità e la forma del rischio non sono mai state toccate.";
})();
/* ---------- tabella decisione ---------- */
$("t-dec").innerHTML =
"<caption>Quota del proprio capitale-rendita raggiunta a " + P.anni + " anni. «Caso peggiore» " +
"= il minimo della riga. «Rimpianto massimo» = quanto si perde, nello scenario in cui va " +
"peggio, rispetto alla scelta che <em>lì</em> sarebbe stata la migliore.</caption>" +
"<thead><tr><th>scelta</th>" + SCEN.map(n => "<th>" + n + "</th>").join("") +
"<th>caso peggiore</th><th>rimpianto max</th></tr></thead><tbody>" +
W.map(w => {
const cls = (kOf(wMax) === w || kOf(wReg) === w) ? ' class="best"' : "";
return "<tr" + cls + "><td>" + wlab(w) + "</td>" +
SCEN.map(n => "<td><span class='cell'>" + pc(S(n, w).quota) + "</span></td>").join("") +
"<td><span class='cell'>" + pc(peg[w]) + "</span></td>" +
"<td><span class='cell'>" + pc(rim[w]) + "</span></td></tr>"; }).join("") + "</tbody>";
$("p-tab").innerHTML =
"Le colonne non sono probabilità: sono quattro mondi possibili, e non si sa quale sia il " +
"nostro. Una scelta buona è una che sta in alto in tutti — non una che vince nel mondo che " +
"preferiamo.";
/* ---------- perché il mix ---------- */
$("p-corr").innerHTML =
"La correlazione fra book e indice è <b>" + D.correlazione.aperta.toFixed(2).replace(".", ",") +
"</b> nei giorni di borsa aperta e <b>" + D.correlazione.ribasso.toFixed(2).replace(".", ",") +
"</b> nei giorni di ribasso. Nel 5% di giornate peggiori dell'indice — quelle in cui perde in " +
"media <b>" + pc(D.correlazione.coda_indice, 2) + "</b> — il book fa <b>" +
pc(D.correlazione.coda_book, 2) + "</b>. Per questo un mix non è «un po' di ciascuno per non " +
"sbagliare»: due motori scorrelati messi insieme hanno una volatilità più bassa della media " +
"delle loro, e la rendita sostenibile dipende dal drawdown, non dal rendimento medio. È il " +
"motivo per cui nella tabella qui sotto il <em>bersaglio</em> di un mix può essere più basso " +
"di quello di entrambi i puri.";
$("t-det").innerHTML =
"<caption>Dettaglio nello scenario «" + base + "». La rendita perpetua è il prelievo annuo " +
"che mantiene P(capitale a 20 anni ≥ capitale iniziale) ≥ 90%.</caption>" +
"<thead><tr><th>scelta</th><th>rendita perpetua</th><th>capitale-rendita</th>" +
"<th>capitale a " + P.anni + " anni</th><th>banda p25p75</th><th>quota</th></tr></thead><tbody>" +
W.map(w => { const r = S(base, w);
return "<tr><td>" + wlab(w) + "</td><td>" + pc(r.perp, 2) + "</td><td>" + usd(r.cap) +
"</td><td>" + usd(r.med) + "</td><td>" + usdK(r.p25) + " " + usdK(r.p75) +
"</td><td>" + pc(r.quota) + "</td></tr>"; }).join("") + "</tbody>";
/* ---------- risposta ---------- */
$("p-risp").innerHTML = ""; /* riempita sotto, dipende dai numeri */
const qBookPeg = peg[kOf(1)];
const qEtfPeg = peg[kOf(0)];
$("p-risp").innerHTML =
"<b>" + wlab(wReg) + "</b>. Non perché vinca — vince solo nello scenario base — ma perché è la " +
"scelta il cui <b>rimpianto massimo</b> è più basso (" + pc(rim[kOf(wReg)]) + " contro il " +
pc(rim[kOf(1)]) + " del book puro e il " + pc(rim[kOf(0)]) +
" dell'ETF puro): qualunque dei quattro mondi si avveri, resta vicina alla scelta che sarebbe " +
"stata giusta lì. Tutto sul book arriva al " + pc(qBookPeg) + " del bersaglio nel mondo che " +
"gli è ostile, tutto sull'ETF al " + pc(qEtfPeg) + " nel suo. " +
(pareggio ? "⚠️ Il criterio del solo caso peggiore <em>non</em> distingue " + wlab(ordPeg[0]) +
" da " + wlab(ordPeg[1]) + " (" + pc(peg[ordPeg[0]], 1) + " contro " + pc(peg[ordPeg[1]], 1) +
", dentro il rumore): a separarli è il rimpianto, non il caso peggiore." : "");
$("p-limiti").innerHTML =
"⚠️ Cosa questa analisi <em>non</em> dice. Non c'è il rischio di venue (a p=1% annuo la " +
"probabilità di perdere tutto il lato cripto è ~18% su vent'anni, e sul lato ETF quel rischio " +
"praticamente non esiste — il che spinge ancora verso il mix, ma non è nel conto). Il mix " +
"assume due salvadanai separati senza ribilanciamento, perché ribilanciare vuol dire vendere " +
"e realizzare. Il taglio del 50% al drift del book è una scelta, non una misura. E gli scenari " +
"sono quattro mondi, non quattro probabilità: la tabella aiuta a non farsi male, non a " +
"indovinare.";
$("foot").innerHTML =
"Script: <b>scripts/research/r0807_best_strategy.py</b> (decisione) e " +
"<b>r0807_asset_compare.py</b> (serie e fisco). Il book è TP01 75% + SKH01 25% alle ancore " +
"canoniche, de-luckato ×0.89; l'ETF è SPY con <code>ADJUSTED_LAST</code> da Interactive " +
"Brokers, su griglia di calendario con 0,0 a borsa chiusa. Fisco: cripto " + pc(P.tax_crypto) +
" annuo sulle plusvalenze più " + pc(0.002, 1) + " sul valore; ETF UCITS ad accumulazione " +
pc(P.tax_etf) + " differito alla vendita più " + pc(0.002, 1) + " — assunzioni dichiarate, " +
"<b>non un parere fiscale</b>. Commissione d'acquisto ETF €" + P.comm + "/mese.";
})();
</script>
+602
View File
@@ -0,0 +1,602 @@
<title>Simulatore del piano di accumulo — PythagorasGoal</title>
<style>
:root {
color-scheme: light;
--ground: #f7f7f5;
--surface: #fdfdfc;
--sunken: #f1f1ee;
--rule: #dededa;
--rule-soft: #ebebe7;
--ink: #16171a;
--ink-2: #55575c;
--ink-3: #83858b;
--versato: #2a78d6;
--guadagno: #eb6834;
--grid: #e6e6e2;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--ground: #17181a; --surface: #1d1e21; --sunken: #232427;
--rule: #34363a; --rule-soft: #26282b;
--ink: #f1f1ee; --ink-2: #a8aab0; --ink-3: #7c7e85;
--versato: #3987e5; --guadagno: #d95926; --grid: #2b2d31;
}
}
:root[data-theme="dark"] {
color-scheme: dark;
--ground: #17181a; --surface: #1d1e21; --sunken: #232427;
--rule: #34363a; --rule-soft: #26282b;
--ink: #f1f1ee; --ink-2: #a8aab0; --ink-3: #7c7e85;
--versato: #3987e5; --guadagno: #d95926; --grid: #2b2d31;
}
*, *::before, *::after { box-sizing: border-box; }
body {
background: var(--ground); color: var(--ink);
font-family: Georgia, "Iowan Old Style", "Times New Roman", serif;
font-size: 17px; line-height: 1.62; margin: 0; padding: 0 24px 96px;
-webkit-font-smoothing: antialiased;
}
.wrap { max-width: 1080px; margin: 0 auto; display: flex; flex-direction: column; gap: 40px; }
.prose { max-width: 68ch; display: flex; flex-direction: column; gap: 16px; }
.mono, .eyebrow, .lede-meta, .legend, .axis, table, .tile, .ctl, .solve, .badge, button, input {
font-family: ui-monospace, SFMono-Regular, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
font-variant-numeric: tabular-nums;
}
header { padding-top: 60px; display: flex; flex-direction: column; gap: 18px; }
.eyebrow { font-size: 11.5px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--ink-3); margin: 0; }
h1 { font-size: clamp(29px, 4.2vw, 44px); line-height: 1.12; margin: 0; font-weight: 400;
letter-spacing: -0.015em; text-wrap: balance; max-width: 21ch; }
.lede { font-size: 19px; color: var(--ink-2); margin: 0; max-width: 62ch; }
.lede b { color: var(--ink); font-weight: 700; }
.lede-meta { font-size: 12.5px; color: var(--ink-3); line-height: 1.9; margin: 0;
padding-top: 14px; border-top: 1px solid var(--rule); }
.lede-meta b { color: var(--ink-2); font-weight: 400; }
h2 { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px;
letter-spacing: 0.15em; text-transform: uppercase; color: var(--ink-2); font-weight: 500;
margin: 0 0 4px; padding-bottom: 10px; border-bottom: 1px solid var(--rule); }
p { margin: 0; }
em { font-style: italic; color: var(--ink-2); }
/* ---------- comandi ---------- */
.panel { background: var(--surface); border: 1px solid var(--rule); border-radius: 3px; }
.ctl { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 1px;
background: var(--rule); border: 1px solid var(--rule); border-radius: 3px; overflow: hidden; }
.ctl > div { background: var(--surface); padding: 16px 18px 18px; display: flex; flex-direction: column; gap: 9px; }
.ctl label { font-size: 11px; letter-spacing: 0.13em; text-transform: uppercase; color: var(--ink-3); }
.row { display: flex; align-items: baseline; gap: 8px; }
.row .unit { color: var(--ink-3); font-size: 14px; }
input[type="number"] {
background: var(--sunken); border: 1px solid var(--rule); border-radius: 2px; color: var(--ink);
font-size: 21px; padding: 5px 8px; width: 100%; max-width: 140px; letter-spacing: -0.01em;
}
input[type="number"]:focus-visible { outline: 2px solid var(--versato); outline-offset: 1px; }
input[type="range"] { width: 100%; accent-color: var(--versato); margin: 0; }
.seg { display: flex; gap: 0; border: 1px solid var(--rule); border-radius: 2px; overflow: hidden; width: fit-content; }
.seg button { background: var(--surface); border: 0; border-right: 1px solid var(--rule);
color: var(--ink-2); font-size: 12px; padding: 8px 13px; cursor: pointer; }
.seg button:last-child { border-right: 0; }
.seg button[aria-pressed="true"] { background: var(--versato); color: #fff; }
.seg button:focus-visible { outline: 2px solid var(--ink); outline-offset: -2px; }
/* ---------- tiles ---------- */
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1px;
background: var(--rule); border: 1px solid var(--rule); border-radius: 3px; overflow: hidden; }
.tile { background: var(--surface); padding: 17px 18px 19px; display: flex; flex-direction: column; gap: 5px; }
.tile .k { font-size: 11px; letter-spacing: 0.13em; text-transform: uppercase; color: var(--ink-3); }
.tile .v { font-size: 26px; letter-spacing: -0.02em; color: var(--ink); line-height: 1.12; }
.tile .v small { font-size: 14px; color: var(--ink-3); letter-spacing: 0; }
.tile .s { font-size: 12px; color: var(--ink-2); line-height: 1.55; }
/* ---------- risolutore ---------- */
.solve { border: 1px solid var(--rule); border-radius: 3px; overflow: hidden; }
.solve table { width: 100%; border-collapse: collapse; font-size: 13px; }
.solve th { background: var(--surface); color: var(--ink-3); font-weight: 400; font-size: 11px;
letter-spacing: 0.08em; text-transform: uppercase; text-align: right; padding: 11px 14px; }
.solve th:first-child { text-align: left; }
.solve td { padding: 11px 14px; text-align: right; border-top: 1px solid var(--rule-soft); color: var(--ink-2); }
.solve td:first-child { text-align: left; color: var(--ink); }
.solve td.big { color: var(--ink); font-size: 17px; }
.solve tr.now td { background: color-mix(in srgb, var(--versato) 8%, transparent); }
.apply { background: transparent; border: 1px solid var(--rule); border-radius: 2px; color: var(--ink-2);
font-size: 11px; padding: 5px 11px; cursor: pointer; letter-spacing: 0.06em; text-transform: uppercase; }
.apply:hover { border-color: var(--versato); color: var(--versato); }
.apply:focus-visible { outline: 2px solid var(--versato); outline-offset: 2px; }
.apply[disabled] { opacity: .35; cursor: default; }
.working { color: var(--ink-3); font-style: italic; }
/* ---------- grafico ---------- */
figure { margin: 0; display: flex; flex-direction: column; gap: 10px; }
.chart-title { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px;
letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink); margin: 0; }
.chart-title span { color: var(--ink-3); text-transform: none; letter-spacing: 0; }
.chart-box { background: var(--surface); border: 1px solid var(--rule); border-radius: 3px;
padding: 18px 16px 10px; overflow-x: auto; }
.chart-box svg { display: block; width: 100%; min-width: 620px; height: auto; }
.legend { display: flex; gap: 22px; flex-wrap: wrap; font-size: 12px; color: var(--ink-2); align-items: center; }
.legend i { width: 11px; height: 11px; border-radius: 2px; display: inline-block; margin-right: 7px; vertical-align: -1px; }
.legend .band { width: 2px; height: 13px; background: var(--ink-3); margin-right: 7px; }
.figcap { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
color: var(--ink-3); line-height: 1.7; max-width: 80ch; }
/* ---------- tabella ---------- */
details { border-top: 1px solid var(--rule); padding-top: 14px; }
summary { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink-2); cursor: pointer; padding: 4px 0; }
summary:focus-visible { outline: 2px solid var(--versato); outline-offset: 3px; }
.tablewrap { overflow-x: auto; margin-top: 14px; }
table.data { border-collapse: collapse; font-size: 12.5px; width: 100%; min-width: 640px; }
table.data th, table.data td { padding: 6px 12px 6px 0; text-align: right; white-space: nowrap; }
table.data th:first-child, table.data td:first-child { text-align: left; }
table.data thead th { color: var(--ink-3); font-weight: 400; border-bottom: 1px solid var(--rule);
font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; }
table.data tbody td { border-bottom: 1px solid var(--rule-soft); color: var(--ink-2); }
table.data tbody td:first-child { color: var(--ink); }
#tip { position: fixed; z-index: 40; pointer-events: none; opacity: 0; transition: opacity .1s ease;
background: var(--surface); border: 1px solid var(--rule); border-radius: 3px; padding: 10px 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; line-height: 1.75;
color: var(--ink-2); box-shadow: 0 6px 22px rgba(0,0,0,.14); font-variant-numeric: tabular-nums; max-width: 260px; }
#tip .t { color: var(--ink); letter-spacing: 0.08em; text-transform: uppercase; font-size: 10.5px; display: block; margin-bottom: 5px; }
#tip .r { display: flex; justify-content: space-between; gap: 18px; }
#tip .r b { font-weight: 400; color: var(--ink); }
#tip .sw { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 6px; }
.bargroup:focus-visible .hit { stroke: var(--ink); stroke-width: 1.5; }
.caveat { border-left: 2px solid var(--guadagno); padding: 2px 0 2px 18px; color: var(--ink-2);
font-size: 16px; max-width: 64ch; }
.caveat b { color: var(--ink); font-weight: 700; }
footer { border-top: 1px solid var(--rule); padding-top: 18px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px;
color: var(--ink-3); line-height: 1.85; max-width: 80ch; }
footer b { color: var(--ink-2); font-weight: 400; }
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
</style>
<div class="wrap">
<header>
<p class="eyebrow">PythagorasGoal · simulatore del piano · 2026-08-07</p>
<h1>Imposta il piano, guarda la curva</h1>
<p class="lede">
Capitale iniziale, versamento mensile e durata. La simulazione gira <b>qui dentro</b>, sui
ritorni reali del book live: 2.692 giorni dal 2019 al 2026, ricampionati a blocchi di 20
giorni per non distruggere il raggruppamento della volatilità.
</p>
<p class="lede-meta">
book live <b>TP01 + SKH01 75/25</b>, de-luckato <b>×0.89</b> · bersaglio <b>$272.061</b>
(capitale-rendita per €50/giorno netti) · motore verificato contro
<b>r0807_growth_yearly.py</b>: differenza fra le medie <b>0,01%</b> su 8 semi per parte
</p>
</header>
<div class="ctl">
<div>
<label for="n-lump">Capitale iniziale</label>
<div class="row"><span class="unit"></span><input type="number" id="n-lump" min="0" max="200000" step="500" value="5000"></div>
<input type="range" id="r-lump" min="0" max="50000" step="500" value="5000">
</div>
<div>
<label for="n-dep">Versamento mensile</label>
<div class="row"><span class="unit"></span><input type="number" id="n-dep" min="0" max="10000" step="25" value="500"></div>
<input type="range" id="r-dep" min="0" max="2500" step="25" value="500">
</div>
<div>
<label for="n-anni">Durata prevista</label>
<div class="row"><input type="number" id="n-anni" min="1" max="30" step="1" value="15"><span class="unit">anni</span></div>
<input type="range" id="r-anni" min="1" max="30" step="1" value="15">
</div>
<div>
<label>Regime</label>
<div class="seg" role="group" aria-label="Regime fiscale">
<button type="button" id="b-netto" aria-pressed="true">col fisco</button>
<button type="button" id="b-lordo" aria-pressed="false">lordo</button>
</div>
<p class="s" style="font-size:12px;color:var(--ink-3);line-height:1.5;margin:0">
«col fisco» = plusvalenze 33% annue + patrimoniale 0,2%. È il regime realistico.
</p>
</div>
</div>
<div class="tiles" id="tiles"></div>
<section class="prose">
<h2>La curva migliore a tempo fissato</h2>
<p>
Fissata la durata, il versamento mensile è <em>l'unica leva che resta</em>: il rendimento non
si comanda e il capitale iniziale è quello che è. Qui sotto il versamento cercato per
bisezione — lo stesso metodo di <em>dep_necessario</em> in <em>r0727_tasse.py</em> — perché la
probabilità di toccare il bersaglio entro la durata impostata arrivi a 1 su 2, 3 su 4, 9 su 10.
</p>
</section>
<div class="solve"><table id="solve"></table></div>
<figure>
<p class="chart-title" id="ct">Capitale per anno</p>
<div class="legend" aria-hidden="true">
<span><i style="background:var(--versato)"></i>versato (deterministico)</span>
<span><i style="background:var(--guadagno)"></i>guadagno del percorso mediano</span>
<span><i class="band"></i>banda p25p75 del capitale</span>
</div>
<div class="chart-box"><svg id="chart" role="img" aria-label="Capitale per anno, scomposto in versamenti e guadagno"></svg></div>
<figcaption class="figcap" id="figcap"></figcaption>
</figure>
<details>
<summary>Tabella completa — anno per anno</summary>
<div class="tablewrap"><table class="data" id="tbl"></table></div>
</details>
<section class="prose">
<h2>Cosa questo strumento non sa</h2>
<p class="caveat">
Il «guadagno» disegnato è <b>mediana(capitale) versato</b>: il rendimento del percorso
mediano, non di un percorso singolo — le due componenti non sono indipendenti e non vanno
sommate come se lo fossero. La banda p25p75 dice quanto il percorso può spostarsi.
</p>
<p>
Non c'è il <strong>rischio di venue</strong>: a p=1% annuo di fallimento dell'exchange la
probabilità di arrivare scende di ~2 punti e quella di <em>perdere tutto</em> sale al 18%; a
p=5% il capitale mediano a 20 anni è zero per qualunque calendario di versamenti. E i
versamenti qui non si fermano mai, che è l'unico scenario che non succede: interromperli al
5º anno porta la probabilità di centrare il bersaglio dal 90% al 53%.
</p>
<p>
Le assunzioni fiscali (33% sulle plusvalenze, minusvalenze riportabili 4 anni, 0,2% annuo sul
valore) sono dichiarate e <em>non sono un parere fiscale</em>; il modello tassa la variazione
annua di valore, quindi è un limite superiore rispetto alla pura realizzazione — stretto,
però, perché questo book realizza quasi tutto entro l'anno. E i ritorni passati del book non
sono una previsione: sono <em>l'unico campione che abbiamo</em>, ricampionato.
</p>
</section>
<footer id="foot"></footer>
</div>
<div id="tip" role="status" aria-live="polite"></div>
<script id="series-data" type="application/json">/*__SERIES__*/</script>
<script>
/*__ENGINE__*/
(function () {
"use strict";
const CFG = JSON.parse(document.getElementById("series-data").textContent);
const E = makeEngine(CFG);
const TARGET = CFG.target;
const PATHS_MAIN = 3000; /* ~150 ms a 20 anni: la curva si ridisegna mentre si trascina */
/* Il risolutore fa 13 corse per livello. Misurato: la banda del risultato resta ±1% da 1.200 a
3.000 percorsi -> non domina il Monte Carlo ma la granularita' della bisezione (20.000/2^12
= ~€5). Percio' si tiene basso e si dichiara l'incertezza, invece di pagare tempo per una
precisione che non arriva. */
const PATHS_SOLVE = 1500;
const SEED = 20260807;
const $ = id => document.getElementById(id);
const usd = n => "$" + Math.round(n).toLocaleString("it-IT");
const usdK = n => n === 0 ? "$0" : (Math.abs(n) >= 1e6
? "$" + (n / 1e6).toFixed(n >= 1e7 ? 0 : 1).replace(".", ",") + "M"
: "$" + Math.round(n / 1000) + "k");
const eur = n => "€" + Math.round(n).toLocaleString("it-IT");
const pct = n => (100 * n).toFixed(1).replace(".", ",") + "%";
const ORD = n => n + "º";
let state = { lump: 5000, dep: 500, anni: 15, netto: true };
let cur = null, curAlt = null;
/* ---------------- comandi ---------------- */
function bind(name, key, max) {
const n = $("n-" + name), r = $("r-" + name);
const set = (v, from) => {
v = Math.max(Number(n.min), Math.min(Number(n.max), Math.round(v)));
state[key] = v;
if (from !== "n") n.value = v;
if (from !== "r") r.value = Math.min(Number(r.max), v);
schedule();
};
n.addEventListener("input", () => { if (n.value !== "") set(Number(n.value), "n"); });
r.addEventListener("input", () => set(Number(r.value), "r"));
}
bind("lump", "lump");
bind("dep", "dep");
bind("anni", "anni");
$("b-netto").addEventListener("click", () => setRegime(true));
$("b-lordo").addEventListener("click", () => setRegime(false));
function setRegime(netto) {
state.netto = netto;
$("b-netto").setAttribute("aria-pressed", String(netto));
$("b-lordo").setAttribute("aria-pressed", String(!netto));
schedule();
}
let timer = null;
function schedule() {
clearTimeout(timer);
timer = setTimeout(recompute, 110);
}
function opts(netto, paths) {
return { lumpEur: state.lump, depEur: state.dep, years: state.anni,
tax: netto ? CFG.tax : 0, patrimoniale: netto ? CFG.patrimoniale : 0,
paths: paths, seed: SEED };
}
/* ---------------- ciclo principale ---------------- */
function recompute() {
cur = E.run(opts(state.netto, PATHS_MAIN));
curAlt = E.run(opts(!state.netto, PATHS_MAIN));
renderTiles(); renderChart(); renderTable(); renderFoot();
startSolver();
}
const cap = r => r.med;
const lastRow = () => cur[cur.length - 1];
function crossover(rows) { const r = rows.find(x => x.guadagno > x.versato); return r ? r.anno : null; }
function hitYear(rows) { const r = rows.find(x => x.med >= TARGET); return r ? r.anno : null; }
function renderTiles() {
const L = lastRow(), alt = curAlt[curAlt.length - 1];
const cx = crossover(cur), hy = hitYear(cur);
const t = [
{ k: "capitale a " + state.anni + " anni", v: usd(L.med),
s: "banda p25p75 " + usdK(L.p25) + " " + usdK(L.p75) },
{ k: "versato in totale", v: usd(L.versato),
s: "il guadagno è il " + Math.round(100 * L.guadagno / L.med) + "% del capitale finale" },
{ k: "P(bersaglio) entro " + state.anni + " anni", v: pct(L.phit),
s: hy ? "la mediana lo tocca al " + ORD(hy) + " anno" : "la mediana non lo tocca in questa durata" },
{ k: "sorpasso", v: cx ? ORD(cx) + "<small> anno</small>" : "—",
s: cx ? "l'anno in cui il guadagno supera tutto il versato"
: "il guadagno non supera il versato entro la durata" },
{ k: state.netto ? "costo del fisco" : "costo del fisco (se acceso)",
v: (state.netto ? "" + Math.round(100 * (1 - L.med / alt.med)) : "" + Math.round(100 * (1 - alt.med / L.med))) + "%",
s: state.netto ? "senza imposte sarebbe " + usd(alt.med) : "col fisco sarebbe " + usd(alt.med) }
];
$("tiles").innerHTML = t.map(x =>
'<div class="tile"><span class="k">' + x.k + '</span><span class="v">' + x.v +
'</span><span class="s">' + x.s + "</span></div>").join("");
}
/* ---------------- risolutore: una bisezione per frame, cosi' la pagina resta viva ---------------- */
const LIVELLI = [
{ p: 0.50, etichetta: "1 percorso su 2", nota: "la mediana arriva" },
{ p: 0.75, etichetta: "3 su 4", nota: "margine ragionevole" },
{ p: 0.90, etichetta: "9 su 10", nota: "quasi certo, e quasi tutto comprato coi bonifici" }
];
let solverToken = 0;
function startSolver() {
const token = ++solverToken;
const risultati = LIVELLI.map(() => ({ stato: "calcolo", val: null }));
renderSolve(risultati);
let i = 0;
function next() {
if (token !== solverToken) return; /* un nuovo input ha invalidato la corsa */
if (i >= LIVELLI.length) return;
const o = opts(state.netto, PATHS_SOLVE);
const P = d => E.run(Object.assign({}, o, { depEur: d })).slice(-1)[0].phit;
let lo = 0, hi = 20000, step = 0;
if (P(hi) < LIVELLI[i].p) { risultati[i] = { stato: "fuori", val: null }; i++; renderSolve(risultati); requestAnimationFrame(next); return; }
function bisect() {
if (token !== solverToken) return;
const mid = 0.5 * (lo + hi);
if (P(mid) >= LIVELLI[i].p) hi = mid; else lo = mid;
step++;
risultati[i] = { stato: step >= 12 ? "ok" : "calcolo", val: 0.5 * (lo + hi) };
renderSolve(risultati);
if (step < 12) requestAnimationFrame(bisect);
else { i++; requestAnimationFrame(next); }
}
requestAnimationFrame(bisect);
}
requestAnimationFrame(next);
}
function renderSolve(res) {
const L = lastRow();
const rows = LIVELLI.map((liv, i) => {
const r = res[i];
let cella;
if (r.stato === "fuori") cella = '<span class="working">fuori portata</span>';
else if (r.val === null) cella = '<span class="working">calcolo…</span>';
else cella = eur(Math.round(r.val / 5) * 5) + (r.stato === "calcolo" ? ' <span class="working">…</span>' : "");
const applicabile = r.stato === "ok" && r.val !== null;
return "<tr><td>" + liv.etichetta + "</td><td class='big'>" + cella + "</td><td>" +
liv.nota + "</td><td>" + (applicabile
? '<button class="apply" data-v="' + Math.round(r.val / 5) * 5 + '">usa</button>'
: '<button class="apply" disabled>usa</button>') + "</td></tr>";
}).join("");
$("solve").innerHTML =
"<thead><tr><th>perché il bersaglio sia colpito in " + state.anni + " anni</th>" +
"<th>versamento mensile</th><th></th><th></th></tr></thead>" +
"<tbody><tr class='now'><td>il piano impostato ora</td><td class='big'>" + eur(state.dep) +
"</td><td>dà P = " + pct(L.phit) + " · capitale mediano " + usd(L.med) +
"</td><td></td></tr>" + rows + "</tbody>";
$("solve").querySelectorAll("button.apply[data-v]").forEach(b =>
b.addEventListener("click", () => {
const v = Number(b.dataset.v);
$("n-dep").value = v; $("r-dep").value = Math.min(Number($("r-dep").max), v);
state.dep = v; schedule();
}));
}
/* ---------------- grafico ---------------- */
const NS = "http://www.w3.org/2000/svg";
const el = (n, a) => { const e = document.createElementNS(NS, n); for (const k in a) e.setAttribute(k, a[k]); return e; };
const tip = $("tip");
function showTip(evt, row) {
tip.innerHTML = '<span class="t">anno ' + row.anno + " · " + (state.netto ? "col fisco" : "lordo") + "</span>" +
'<div class="r"><span><span class="sw" style="background:var(--versato)"></span>versato</span><b>' + usd(row.versato) + "</b></div>" +
'<div class="r"><span><span class="sw" style="background:var(--guadagno)"></span>guadagno</span><b>' + usd(row.guadagno) + "</b></div>" +
'<div class="r"><span>capitale mediano</span><b>' + usd(row.med) + "</b></div>" +
'<div class="r"><span>banda p25p75</span><b>' + usdK(row.p25) + " " + usdK(row.p75) + "</b></div>" +
'<div class="r"><span>P(bersaglio)</span><b>' + pct(row.phit) + "</b></div>";
tip.style.opacity = "1"; moveTip(evt);
}
function moveTip(evt) {
const r = tip.getBoundingClientRect();
let x = evt.clientX + 16, y = evt.clientY - r.height - 12;
if (x + r.width > window.innerWidth - 8) x = evt.clientX - r.width - 16;
if (y < 8) y = evt.clientY + 18;
tip.style.left = x + "px"; tip.style.top = y + "px";
}
const hideTip = () => { tip.style.opacity = "0"; };
function roundedTop(x, yTop, w, h, r) {
if (h <= 0) return "";
r = Math.min(r, h, w / 2);
return "M" + x + "," + (yTop + h) + "V" + (yTop + r) +
"a" + r + "," + r + " 0 0 1 " + r + "," + (-r) + "h" + (w - 2 * r) +
"a" + r + "," + r + " 0 0 1 " + r + "," + r + "V" + (yTop + h) + "Z";
}
function renderChart() {
const rows = cur;
const svg = $("chart");
const W = 960, H = 380, M = { t: 26, r: 108, b: 38, l: 78 };
const pw = W - M.l - M.r, ph = H - M.t - M.b;
svg.setAttribute("viewBox", "0 0 " + W + " " + H);
svg.innerHTML = "";
/* scala verticale dai dati: deve contenere il bersaglio anche quando il piano non lo raggiunge */
const rawMax = Math.max(TARGET * 1.05, ...rows.map(r => r.p75));
const STEP = [5000, 10000, 25000, 50000, 100000, 150000, 200000, 250000, 500000, 1000000, 2500000]
.find(s => rawMax / s <= 8) || 5000000;
const YMAX = Math.ceil(rawMax / STEP) * STEP;
const y = v => M.t + ph - (v / YMAX) * ph;
const step = pw / rows.length;
const bw = Math.min(38, step * 0.62);
for (let v = 0; v <= YMAX; v += STEP) {
svg.appendChild(el("line", { x1: M.l, x2: M.l + pw, y1: y(v), y2: y(v),
stroke: v === 0 ? "var(--ink-3)" : "var(--grid)", "stroke-width": 1 }));
const t = el("text", { x: M.l - 12, y: y(v) + 4, "text-anchor": "end", class: "axis",
fill: "var(--ink-3)", "font-size": 11 });
t.textContent = usdK(v);
svg.appendChild(t);
}
const ty = y(TARGET);
svg.appendChild(el("line", { x1: M.l, x2: M.l + pw, y1: ty, y2: ty, stroke: "var(--ink-2)",
"stroke-width": 1.5, "stroke-dasharray": "5 4" }));
const tl = el("text", { x: M.l + 8, y: ty - 8, class: "axis", fill: "var(--ink-2)", "font-size": 11 });
tl.textContent = "bersaglio $272.061 — capitale-rendita";
svg.appendChild(tl);
const cx0 = crossover(rows);
rows.forEach((row, i) => {
const cx = M.l + step * i + step / 2, x = cx - bw / 2;
const vTop = y(row.versato), gTop = y(row.med), base = y(0);
const g = el("g", { class: "bargroup", tabindex: "0",
"aria-label": "anno " + row.anno + ": versato " + usd(row.versato) + ", guadagno " + usd(row.guadagno) });
g.appendChild(el("path", { d: roundedTop(x, vTop, bw, base - vTop, 0), fill: "var(--versato)" }));
const rawH = vTop - gTop, gh = rawH - (rawH > 6 ? 2 : 0);
if (gh > 0.3) g.appendChild(el("path", { d: roundedTop(x, gTop, bw, gh, 4), fill: "var(--guadagno)" }));
/* un guadagno NEGATIVO (percorso mediano sotto il versato) si disegna sotto la linea del versato */
if (rawH < 0) g.appendChild(el("path", { d: roundedTop(x, vTop, bw, -rawH, 0), fill: "var(--guadagno)", opacity: .45 }));
const p25 = y(row.p25), p75 = y(row.p75);
g.appendChild(el("line", { x1: cx, x2: cx, y1: p75, y2: p25, stroke: "var(--surface)", "stroke-width": 4 }));
g.appendChild(el("line", { x1: cx, x2: cx, y1: p75, y2: p25, stroke: "var(--ink-2)", "stroke-width": 2, opacity: .85 }));
[p25, p75].forEach(yy => g.appendChild(el("line", { x1: cx - 5, x2: cx + 5, y1: yy, y2: yy,
stroke: "var(--ink-2)", "stroke-width": 2, opacity: .85 })));
const hit = el("rect", { class: "hit", x: M.l + step * i, y: M.t, width: step, height: ph, fill: "transparent" });
g.appendChild(hit);
g.addEventListener("pointerenter", e => showTip(e, row));
g.addEventListener("pointermove", moveTip);
g.addEventListener("pointerleave", hideTip);
g.addEventListener("focus", () => { const b = hit.getBoundingClientRect();
showTip({ clientX: b.left + b.width / 2, clientY: b.top + 40 }, row); });
g.addEventListener("blur", hideTip);
svg.appendChild(g);
/* con molte barre si etichetta un anno su due, per non impilare i numeri */
const passo = rows.length > 20 ? 5 : (rows.length > 12 ? 2 : 1);
if (row.anno % passo === 0 || row.anno === rows.length || row.anno === cx0) {
const xt = el("text", { x: cx, y: H - 14, "text-anchor": "middle", class: "axis",
fill: row.anno === cx0 ? "var(--ink)" : "var(--ink-3)", "font-size": 11 });
xt.textContent = row.anno;
svg.appendChild(xt);
}
});
/* etichette dirette delle serie: l'identita' non e' mai solo colore */
const last = rows[rows.length - 1];
const lastTop = y(last.med), lastMid = y(last.versato), lx = M.l + pw + 14;
[["guadagno", usd(last.guadagno), (Math.min(lastTop, lastMid) + lastMid) / 2, "var(--guadagno)"],
["versato", usd(last.versato), (lastMid + y(0)) / 2, "var(--versato)"]
].forEach(([nome, val, yy, col]) => {
const a = el("text", { x: lx, y: Math.max(20, yy - 2), class: "axis", fill: col, "font-size": 11 });
a.textContent = nome; svg.appendChild(a);
const b = el("text", { x: lx, y: Math.max(34, yy + 12), class: "axis", fill: "var(--ink)", "font-size": 11.5 });
b.textContent = val; svg.appendChild(b);
});
if (cx0) {
const ci = cx0 - 1, cRow = rows[ci];
const ccx = M.l + step * ci + step / 2, cy = y(cRow.med);
svg.appendChild(el("line", { x1: ccx, x2: ccx, y1: cy - 6, y2: cy - 26, stroke: "var(--guadagno)", "stroke-width": 1.5 }));
const ct = el("text", { x: ccx, y: cy - 32, "text-anchor": "middle", class: "axis",
fill: "var(--guadagno)", "font-size": 11 });
ct.textContent = "sorpasso"; svg.appendChild(ct);
}
const ax = el("text", { x: M.l + pw + 14, y: H - 14, class: "axis", fill: "var(--ink-3)", "font-size": 10.5 });
ax.textContent = "anno"; svg.appendChild(ax);
$("ct").innerHTML = "Capitale per anno <span>— " + eur(state.lump) + " iniziali + " +
eur(state.dep) + "/mese · " + state.anni + " anni · " +
(state.netto ? "col fisco d'accumulo" : "senza imposte") + "</span>";
$("figcap").textContent =
"Scala verticale dai dati, sempre estesa almeno fino al bersaglio. " +
PATHS_MAIN.toLocaleString("it-IT") + " percorsi ricampionati a blocchi di 20 giorni; " +
"il guadagno disegnato è quello del percorso mediano, non di un percorso singolo.";
}
function renderTable() {
const body = cur.map((r, i) => {
const a = curAlt[i];
return "<tr><td>" + r.anno + "</td><td>" + usd(r.versato) + "</td><td>" + usd(r.guadagno) +
"</td><td>" + usd(r.med) + "</td><td>" + usdK(r.p25) + "</td><td>" + usdK(r.p75) +
"</td><td>" + pct(r.phit) + "</td><td>" + usd(a.med) + "</td></tr>";
}).join("");
$("tbl").innerHTML = "<thead><tr><th>anno</th><th>versato</th><th>guadagno</th>" +
"<th>capitale mediano</th><th>p25</th><th>p75</th><th>P(bersaglio)</th><th>" +
(state.netto ? "senza imposte" : "col fisco") + "</th></tr></thead><tbody>" + body + "</tbody>";
}
function renderFoot() {
const L = lastRow();
$("foot").innerHTML =
"Serie: <b>" + CFG.n.toLocaleString("it-IT") + "</b> giorni dal <b>" + CFG.start +
"</b> al <b>" + CFG.end + "</b> · cambio <b>EUR/USD " + CFG.eurusd +
"</b> · conto di partenza <b>$" + CFG.start_usd + "</b> (quello vero, oggi) · " +
"blocchi da <b>" + CFG.block + "</b> giorni · <b>" + PATHS_MAIN.toLocaleString("it-IT") +
"</b> percorsi per la curva, <b>" + PATHS_SOLVE.toLocaleString("it-IT") +
"</b> per il risolutore — il versamento suggerito porta <b>~±1%</b> di incertezza " +
"(bisezione + Monte Carlo), quindi si legge come «circa», non come una cifra esatta. " +
"Il risolutore e' stato confrontato con <b>dep_necessario</b> di r0727_tasse.py: scarto " +
"0,4% / 0,6% / 1,1% su tre configurazioni di riferimento. " +
"Con questo piano si versano <b>" + usd(L.versato - CFG.start_usd - state.lump * CFG.eurusd) +
"</b> di bonifici oltre al capitale iniziale.";
}
recompute();
})();
</script>
+84
View File
@@ -0,0 +1,84 @@
/* smoke.js — esegue DAVVERO lo script di una pagina, con un DOM finto.
*
* Perche' serve: `node --check` valida la SINTASSI e non tocca l'esecuzione. Ho pubblicato una
* pagina che passava il controllo di sintassi e moriva alla prima riga utile, perche' le chiavi
* dei pesi arrivavano da Python come "0.0"/"1.0" e la pagina le ricostruiva con String(0) -> "0".
* Un test che non esegue non vede niente di tutto cio'.
*
* node smoke.js pagina.html [...]
*/
const fs = require("fs");
function makeEl(tag) {
const el = {
tagName: tag, _html: "", textContent: "", value: "", dataset: {},
style: new Proxy({}, { get: () => "", set: () => true }),
children: [], attributes: {}, min: "0", max: "1000000",
setAttribute(k, v) { this.attributes[k] = v; },
getAttribute(k) { return this.attributes[k]; },
appendChild(c) { this.children.push(c); return c; },
addEventListener() {},
removeEventListener() {},
getBoundingClientRect() { return { left: 0, top: 0, width: 100, height: 40, right: 100, bottom: 40 }; },
insertAdjacentHTML(pos, html) { this._html += html; },
querySelectorAll() { return { forEach() {} }; },
querySelector() { return makeEl("div"); },
contains() { return false; },
focus() {}
};
Object.defineProperty(el, "innerHTML", {
get() { return this._html; },
set(v) { if (v === undefined || String(v).includes("undefined")) el._undef = true; this._html = String(v); }
});
return el;
}
const known = new Map();
global.document = {
_byId: known,
getElementById(id) {
if (!known.has(id)) known.set(id, makeEl("div"));
return known.get(id);
},
createElementNS(ns, tag) { return makeEl(tag); },
createElement(tag) { return makeEl(tag); },
querySelectorAll() { return { forEach() {} }; },
addEventListener() {}
};
global.window = { innerWidth: 1400, innerHeight: 900, addEventListener() {} };
global.requestAnimationFrame = fn => { /* una sola passata: basta per vedere se esplode */ };
global.setTimeout = (fn, ms) => { try { fn(); } catch (e) { throw e; } return 0; };
global.clearTimeout = () => {};
let bad = 0;
for (const file of process.argv.slice(2)) {
known.clear();
const html = fs.readFileSync(file, "utf8");
const blocchi = [...html.matchAll(/<script(?:\s[^>]*)?>([\s\S]*?)<\/script>/g)];
/* i blocchi type="application/json" sono DATI: il DOM finto deve restituirli */
for (const m of blocchi) {
const tagAttr = m[0].slice(0, m[0].indexOf(">"));
const idm = tagAttr.match(/id="([^"]+)"/);
if (tagAttr.includes("application/json") && idm) {
const el = document.getElementById(idm[1]);
el.textContent = m[1];
}
}
const code = blocchi.filter(m => !m[0].includes("application/json")).map(m => m[1]).join("\n");
try {
new Function(code)();
/* un id mai riempito, o riempito con "undefined", e' un difetto silenzioso */
const vuoti = [...known.entries()].filter(([, e]) => e._undef);
if (vuoti.length) {
console.log("⚠ " + file + " — gira, ma scrive 'undefined' in: " +
vuoti.map(([k]) => k).join(", "));
bad++;
} else {
console.log("✓ " + file + " — esegue senza errori (" + known.size + " elementi toccati)");
}
} catch (e) {
console.log("✗ " + file + " — " + e.constructor.name + ": " + e.message);
bad++;
}
}
process.exit(bad ? 1 : 0);
+84
View File
@@ -0,0 +1,84 @@
/* test_engine.js — il motore JS deve riprodurre r0807_growth_yearly.py.
*
* Non si confrontano i semi (non sono confrontabili fra linguaggi): si confrontano i RISULTATI
* entro il rumore Monte Carlo, su due configurazioni gia' calcolate in Python, e si controlla
* che le identita' deterministiche (il versato) coincidano ESATTAMENTE — quelle non hanno rumore.
*
* ⚠️ TRAPPOLA, in cui sono caduto scrivendo questo test: il riferimento Python e' UNA estrazione
* (seme 807), non un valore vero. Confrontandoci 8 estrazioni JS ho visto "8/8 sopra il
* riferimento, media +0.75%" e l'ho letto come distorsione sistematica — ma 8 segni concordi
* rispetto a UN punto rumoroso non sono 8 osservazioni indipendenti: dicono solo che quel punto
* sta in basso. Misurato per bene (8 semi PER LINGUAGGIO, distribuzione contro distribuzione):
* differenza delle medie -0.01%, t = -0.04, e i due campionatori cadono entrambi entro 1.7 SE
* dall'atteso ANALITICO della media di blocco. Le tolleranze qui sotto sono percio' tarate sul
* rumore di UN confronto punto-a-punto (~1 SE = 0.7%), non su una distorsione che non esiste.
*/
const fs = require("fs");
const { makeEngine } = require("./engine.js");
const CFG = JSON.parse(fs.readFileSync("series.json", "utf8"));
const E = makeEngine(CFG);
/* valori di riferimento prodotti da Python (4.000 path, seme 807) */
const REF = {
"500": { dep: 500, py: JSON.parse(fs.readFileSync("growth.json", "utf8")) },
"800": { dep: 800, py: JSON.parse(fs.readFileSync("growth800.json", "utf8")) }
};
const PATHS = 4000;
let fail = 0;
const pct = x => (100 * x).toFixed(1) + "%";
for (const nome of Object.keys(REF)) {
const { dep, py } = REF[nome];
for (const regime of ["lordo", "netto"]) {
const js = E.run({ lumpEur: 5000, depEur: dep, years: 15,
tax: regime === "netto" ? CFG.tax : 0,
patrimoniale: regime === "netto" ? CFG.patrimoniale : 0,
paths: PATHS, seed: 20260807 });
console.log(`\n--- €${dep}/mese · ${regime} ---`);
console.log("anno versato PY versato JS med PY med JS scarto P PY P JS");
for (const y of [1, 5, 10, 15]) {
const a = py[regime][y - 1], b = js[y - 1];
const capPy = a.versato + a.guadagno_med;
const d = capPy > 0 ? b.med / capPy - 1 : 0;
/* il versato e' deterministico: deve coincidere al centesimo */
const vOk = Math.abs(a.versato - b.versato) < 0.01;
/* le mediane hanno rumore Monte Carlo: tolleranza 3% */
const mOk = Math.abs(d) < 0.03;
/* le probabilita' hanno SE <= 0.8pp a 4.000 path: tolleranza 3pp */
const pOk = Math.abs(a.p_traguardo - b.phit) < 0.03;
if (!vOk || !mOk || !pOk) fail++;
console.log(
String(y).padStart(4) +
("$" + Math.round(a.versato).toLocaleString("en-US")).padStart(13) +
("$" + Math.round(b.versato).toLocaleString("en-US")).padStart(14) +
("$" + Math.round(capPy).toLocaleString("en-US")).padStart(12) +
("$" + Math.round(b.med).toLocaleString("en-US")).padStart(12) +
(d >= 0 ? "+" : "") + (100 * d).toFixed(1).padStart(9) + "%" +
pct(a.p_traguardo).padStart(8) + pct(b.phit).padStart(8) +
(vOk && mOk && pOk ? "" : " <-- FUORI TOLLERANZA"));
}
}
}
/* controllo positivo: il test deve saper fallire. Un motore con l'imposta spenta quando
dovrebbe essere accesa deve risultare FUORI tolleranza rispetto al riferimento netto. */
const rotto = E.run({ lumpEur: 5000, depEur: 500, years: 15, tax: 0, patrimoniale: 0,
paths: 1500, seed: 1 });
const refNetto15 = REF["500"].py.netto[14];
const capRef = refNetto15.versato + refNetto15.guadagno_med;
const scartoRotto = rotto[14].med / capRef - 1;
console.log("\ncontrollo positivo (motore volutamente sbagliato: fisco spento): scarto " +
(100 * scartoRotto).toFixed(1) + "% -> " +
(Math.abs(scartoRotto) > 0.03 ? "rilevato, il test sa fallire" : "NON RILEVATO: il test e' cieco"));
if (Math.abs(scartoRotto) <= 0.03) fail++;
/* velocita': l'interfaccia deve restare utilizzabile */
const t0 = Date.now();
E.run({ lumpEur: 5000, depEur: 500, years: 20, tax: CFG.tax,
patrimoniale: CFG.patrimoniale, paths: 3000, seed: 5 });
console.log("\n20 anni x 3.000 path: " + (Date.now() - t0) + " ms");
console.log(fail === 0 ? "\nOK — il motore JS riproduce il Python" : "\n" + fail + " CONTROLLI FALLITI");
process.exit(fail === 0 ? 0 : 1);