Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ba5ea2f5c3 | |||
| ed2532b991 | |||
| 56141b0ffe | |||
| 68c6894d85 |
@@ -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 − (1−0.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.
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 };
|
||||
@@ -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()
|
||||
@@ -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&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&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 p25–p75, 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 − (1−0,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 p25–p75</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&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&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 p25–p75 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&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>
|
||||
@@ -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">8º</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 p25–p75 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 p25–p75 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>€880–1.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 p25–p75</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>
|
||||
@@ -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&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&P su questa finestra <b>" + pc(D.drift.eq_comune, 1) + "</b>, S&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 p25–p75</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>
|
||||
@@ -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 p25–p75 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 p25–p75 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 p25–p75 " + 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 p25–p75</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>
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user