feat(web): simulatore di accumulo nel browser, verificato contro il Python

scripts/web/ — motore in JavaScript (engine.js) sui ritorni VERI del book
esportati da export_series.py, pagine assemblate da build.py. Da "sistema
dinamico dove imposti valore iniziale, mensile e durata, e calcola la best curva
fissando il tempo": la simulazione gira nel browser, quindi il motore va
riscritto e VA PROVATO che dia la stessa risposta.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-08-07 18:44:13 +00:00
parent 56141b0ffe
commit ed2532b991
9 changed files with 2444 additions and 0 deletions
+489
View File
@@ -0,0 +1,489 @@
<title>Da dove viene il guadagno — e se invece fosse un ETF</title>
<style>
:root {
color-scheme: light;
--ground: #f7f7f5; --surface: #fdfdfc; --sunken: #f1f1ee;
--rule: #dededa; --rule-soft: #ebebe7;
--ink: #16171a; --ink-2: #55575c; --ink-3: #83858b;
--s1: #2a78d6; --s2: #eb6834; --s3: #1baf7a; --grid: #e6e6e2;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--ground: #17181a; --surface: #1d1e21; --sunken: #232427;
--rule: #34363a; --rule-soft: #26282b;
--ink: #f1f1ee; --ink-2: #a8aab0; --ink-3: #7c7e85;
--s1: #3987e5; --s2: #d95926; --s3: #199e70; --grid: #2b2d31;
}
}
:root[data-theme="dark"] {
color-scheme: dark;
--ground: #17181a; --surface: #1d1e21; --sunken: #232427;
--rule: #34363a; --rule-soft: #26282b;
--ink: #f1f1ee; --ink-2: #a8aab0; --ink-3: #7c7e85;
--s1: #3987e5; --s2: #d95926; --s3: #199e70; --grid: #2b2d31;
}
*, *::before, *::after { box-sizing: border-box; }
body {
background: var(--ground); color: var(--ink);
font-family: Georgia, "Iowan Old Style", "Times New Roman", serif;
font-size: 17px; line-height: 1.62; margin: 0; padding: 0 24px 96px;
-webkit-font-smoothing: antialiased;
}
.wrap { max-width: 1080px; margin: 0 auto; display: flex; flex-direction: column; gap: 44px; }
.prose { max-width: 68ch; display: flex; flex-direction: column; gap: 16px; }
.mono, .eyebrow, .lede-meta, .legend, .axis, table, .step, .dotrow {
font-family: ui-monospace, SFMono-Regular, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
font-variant-numeric: tabular-nums;
}
header { padding-top: 60px; display: flex; flex-direction: column; gap: 18px; }
.eyebrow { font-size: 11.5px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--ink-3); margin: 0; }
h1 { font-size: clamp(29px, 4.2vw, 44px); line-height: 1.12; margin: 0; font-weight: 400;
letter-spacing: -0.015em; text-wrap: balance; max-width: 22ch; }
.lede { font-size: 19px; color: var(--ink-2); margin: 0; max-width: 62ch; }
.lede b { color: var(--ink); font-weight: 700; }
.lede-meta { font-size: 12.5px; color: var(--ink-3); line-height: 1.9; margin: 0;
padding-top: 14px; border-top: 1px solid var(--rule); }
.lede-meta b { color: var(--ink-2); font-weight: 400; }
h2 { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px;
letter-spacing: 0.15em; text-transform: uppercase; color: var(--ink-2); font-weight: 500;
margin: 0 0 4px; padding-bottom: 10px; border-bottom: 1px solid var(--rule); }
p { margin: 0; }
em { font-style: italic; color: var(--ink-2); }
strong { font-weight: 700; }
/* passi del calcolo */
.steps { display: flex; flex-direction: column; gap: 1px; background: var(--rule);
border: 1px solid var(--rule); border-radius: 3px; overflow: hidden; }
.step { background: var(--surface); padding: 15px 18px; display: grid;
grid-template-columns: 28px 1fr; gap: 14px; align-items: baseline; font-size: 13.5px;
color: var(--ink-2); line-height: 1.65; }
.step .n { color: var(--ink-3); font-size: 11px; letter-spacing: 0.1em; }
.step b { color: var(--ink); font-weight: 400; }
.step code { background: var(--sunken); padding: 1px 5px; border-radius: 2px; font-size: 12.5px; color: var(--ink); }
figure { margin: 0; display: flex; flex-direction: column; gap: 10px; }
.chart-title { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px;
letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink); margin: 0; }
.chart-title span { color: var(--ink-3); text-transform: none; letter-spacing: 0; }
.chart-box { background: var(--surface); border: 1px solid var(--rule); border-radius: 3px;
padding: 18px 16px 10px; overflow-x: auto; }
.chart-box svg { display: block; width: 100%; min-width: 620px; height: auto; }
.legend { display: flex; gap: 22px; flex-wrap: wrap; font-size: 12px; color: var(--ink-2); align-items: center; }
.legend i { width: 20px; height: 3px; border-radius: 2px; display: inline-block; margin-right: 8px; vertical-align: 3px; }
.legend .dash { background: none; border-top: 2px dashed var(--ink-3); height: 0; }
.figcap { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
color: var(--ink-3); line-height: 1.7; max-width: 80ch; }
table.data { border-collapse: collapse; font-size: 12.5px; width: 100%; min-width: 560px; }
table.data th, table.data td { padding: 8px 14px 8px 0; text-align: right; white-space: nowrap; }
table.data th:first-child, table.data td:first-child { text-align: left; }
table.data thead th { color: var(--ink-3); font-weight: 400; border-bottom: 1px solid var(--rule);
font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; }
table.data tbody td { border-bottom: 1px solid var(--rule-soft); color: var(--ink-2); }
table.data tbody td:first-child { color: var(--ink); }
table.data td .sw { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 8px; }
.tablewrap { overflow-x: auto; border: 1px solid var(--rule); border-radius: 3px; background: var(--surface); padding: 14px 16px; }
caption { caption-side: top; text-align: left; font-size: 12px; color: var(--ink-3); padding-bottom: 12px; }
/* banda finale */
.dots { border: 1px solid var(--rule); border-radius: 3px; background: var(--surface); padding: 18px 18px 8px; }
.dotrow { display: grid; grid-template-columns: 90px 1fr; gap: 14px; align-items: center; margin-bottom: 14px; font-size: 12px; color: var(--ink-2); }
details { border-top: 1px solid var(--rule); padding-top: 14px; }
summary { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink-2); cursor: pointer; padding: 4px 0; }
summary:focus-visible { outline: 2px solid var(--s1); outline-offset: 3px; }
#tip { position: fixed; z-index: 40; pointer-events: none; opacity: 0; transition: opacity .1s ease;
background: var(--surface); border: 1px solid var(--rule); border-radius: 3px; padding: 10px 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; line-height: 1.75;
color: var(--ink-2); box-shadow: 0 6px 22px rgba(0,0,0,.14); font-variant-numeric: tabular-nums; max-width: 280px; }
#tip .t { color: var(--ink); letter-spacing: 0.08em; text-transform: uppercase; font-size: 10.5px; display: block; margin-bottom: 5px; }
#tip .r { display: flex; justify-content: space-between; gap: 18px; }
#tip .r b { font-weight: 400; color: var(--ink); }
#tip .sw { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 6px; }
.caveat { border-left: 2px solid var(--s2); padding: 2px 0 2px 18px; color: var(--ink-2);
font-size: 16px; max-width: 64ch; }
.caveat b { color: var(--ink); font-weight: 700; }
footer { border-top: 1px solid var(--rule); padding-top: 18px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px;
color: var(--ink-3); line-height: 1.85; max-width: 80ch; }
footer b { color: var(--ink-2); font-weight: 400; }
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
</style>
<div class="wrap">
<header>
<p class="eyebrow">PythagorasGoal · 2026-08-07</p>
<h1>Da dove viene il guadagno, e se invece fosse un ETF</h1>
<p class="lede">
Due domande. La prima: <b>quel guadagno come si calcola?</b> Non è un tasso ipotizzato — è
quello che producono i ritorni veri del book, ricampionati. La seconda: <b>e se gli stessi
soldi andassero su un S&amp;P 500 o un MSCI World?</b>
</p>
<p class="lede-meta" id="meta"></p>
</header>
<section class="prose">
<h2>1 · Come si calcola il guadagno</h2>
<p>
Da nessuna parte c'è un «rendimento atteso» scritto a mano. C'è una sequenza di ritorni
giornalieri realmente prodotti dal book live, e la si rimescola.
</p>
</section>
<div class="steps" id="steps"></div>
<section class="prose">
<p>
Il rendimento non è un'ipotesi, ma <em>la scelta della sequenza sì</em>: rimescolare gli
ultimi 7 anni assume che il futuro somigli a quel campione. È l'assunzione più forte di tutto
il modello, ed è esattamente ciò che la seconda domanda mette alla prova.
</p>
</section>
<section class="prose">
<h2>2 · I tre motori</h2>
<p>
Stesso piano, stessa macchina, tre sorgenti di ritorni. Le azioni vanno su griglia di
calendario con <em>0,0 nei giorni di borsa chiusa</em> — la convenzione già usata per lo
sleeve azionario: il capitale è fermo, non riciclato. Senza quella correzione una serie di
borsa passata a un motore che annualizza a 365 esce con Sharpe ×1,20.
</p>
</section>
<div class="tablewrap"><table class="data" id="t-serie"></table></div>
<section class="prose">
<p class="caveat">
La riga che conta non è la prima, è la seconda: <b>sulla stessa finestra</b> il book e
l'S&amp;P 500 hanno quasi lo stesso rendimento — <span id="x-drift"></span>. Tutta la
differenza è nel <b>rischio</b>: volatilità <span id="x-vol"></span> e drawdown massimo
<span id="x-dd"></span>. Il book non guadagna di più: perde di meno.
</p>
</section>
<figure>
<p class="chart-title">Capitale mediano per anno <span>— ciascuno ricampionato dalla PROPRIA storia piena</span></p>
<div class="legend" id="leg1"></div>
<div class="chart-box"><svg id="c1" role="img" aria-label="Curve del capitale mediano per anno, storia piena"></svg></div>
<figcaption class="figcap" id="cap1"></figcaption>
</figure>
<figure>
<p class="chart-title">Capitale mediano per anno <span>— tutti e tre ricampionati dalla STESSA finestra</span></p>
<div class="chart-box"><svg id="c2" role="img" aria-label="Curve del capitale mediano per anno, finestra comune"></svg></div>
<figcaption class="figcap" id="cap2"></figcaption>
</figure>
<section class="prose">
<h2>3 · Il bersaglio non è lo stesso per tutti</h2>
<p>
$272.061 è il capitale che serve <em>con la rendita perpetua del book e con l'aliquota del
33%</em>. Un ETF ha un'altra distribuzione e un'altra aliquota, quindi ha un altro bersaglio:
la rendita sostenibile dipende dal drawdown, non dal rendimento medio. Ricalcolato per
ciascuno, con lo stesso metodo.
</p>
</section>
<div class="tablewrap"><table class="data" id="t-berg"></table></div>
<section class="prose">
<h2>4 · La banda, non il numero</h2>
<p>
Le curve sopra sono mediane. Ecco dove sta in realtà il capitale finale: la barra è
l'intervallo p25p75, il segno è la mediana, la linea tratteggiata è il bersaglio di
quel motore.
</p>
</section>
<div class="dots" id="dots"></div>
<section class="prose">
<h2>5 · La lettura onesta</h2>
<p class="caveat" id="lettura"></p>
<p>
Il fisco è modellato diverso perché <em>è</em> diverso: il book realizza in continuo e paga
ogni anno il 33%; un ETF UCITS ad accumulazione non distribuisce nulla e paga il 26% alla
vendita. Il differimento è un vantaggio strutturale dell'ETF, ed è dentro i numeri qui sopra
(le curve degli ETF sono valori di <em>liquidazione</em>, già al netto dell'imposta latente).
Sono assunzioni dichiarate e <em>non sono un parere fiscale</em>.
</p>
</section>
<details>
<summary>Tabella completa — capitale mediano anno per anno, entrambe le lenti</summary>
<div class="tablewrap" style="margin-top:14px"><table class="data" id="t-full"></table></div>
</details>
<footer id="foot"></footer>
</div>
<div id="tip" role="status" aria-live="polite"></div>
<script id="cmp-data" type="application/json">/*__DATA__*/</script>
<script>
(function () {
"use strict";
const D = JSON.parse(document.getElementById("cmp-data").textContent);
const $ = id => document.getElementById(id);
const MOT = ["BOOK", "SP500", "WORLD"];
const NOME = { BOOK: "book live (cripto)", SP500: "S&P 500 (SPY)", WORLD: "MSCI World (proxy)" };
const COL = { BOOK: "var(--s1)", SP500: "var(--s2)", WORLD: "var(--s3)" };
const usd = n => "$" + Math.round(n).toLocaleString("it-IT");
const usdK = n => n === 0 ? "$0" : (Math.abs(n) >= 1e6
? "$" + (n / 1e6).toFixed(n >= 1e7 ? 0 : 1).replace(".", ",") + "M"
: "$" + Math.round(n / 1000) + "k");
const pc = (x, d) => (100 * x).toFixed(d === undefined ? 1 : d).replace(".", ",") + "%";
/* ---------- intestazione ---------- */
const P = D.piano;
$("meta").innerHTML =
"piano: <b>$" + P.start + "</b> + <b>€" + P.lump.toLocaleString("it-IT") + "</b> + <b>€" +
P.dep.toLocaleString("it-IT") + "/mese</b> per <b>" + P.anni + " anni</b> · " +
P.n_paths.toLocaleString("it-IT") + " percorsi, blocchi da 20 giorni · " +
"fisco: cripto <b>" + pc(P.tax_crypto, 0) + " annuo</b>, ETF <b>" + pc(P.tax_etf, 0) +
" differito alla vendita</b>, patrimoniale <b>" + pc(P.patrim, 1) + "</b> · " +
"commissione d'acquisto ETF <b>€" + P.comm_eur + "/mese</b> · MSCI World = " + P.world;
/* ---------- passi ---------- */
const mB = D.metriche.BOOK;
const passi = [
["01", "Si parte dai <b>ritorni giornalieri veri</b> del book live (TP01 75% + SKH01 25%): " +
mB.n.toLocaleString("it-IT") + " giorni dal " + mB.da + " al " + mB.a + ". Non sono simulati: " +
"sono quelli che la strategia ha prodotto, già al netto delle commissioni."],
["02", "Si toglie la <b>fortuna d'ancora</b>: <code>r (10,89)·media(r)</code>. Il book è " +
"stato misurato al 97º percentile delle sue possibili ancore di calendario, e il fattore " +
"0,89 è la correzione misurata, non stimata a occhio."],
["03", "Si <b>rimescolano a blocchi di 20 giorni</b>: si pescano a caso spezzoni di 20 giorni " +
"consecutivi e li si incolla fino a coprire gli anni richiesti. I blocchi servono a non " +
"distruggere il raggruppamento della volatilità — un rimescolamento giorno per giorno " +
"renderebbe il percorso troppo docile e la sopravvivenza troppo alta."],
["04", "Si <b>compone</b>: <code>capitale ×= (1 + r)</code> ogni giorno, più il bonifico ogni " +
"30 giorni, meno l'imposta a fine anno. Ripetuto su " + P.n_paths.toLocaleString("it-IT") +
" percorsi indipendenti."],
["05", "Il <b>guadagno</b> di un anno è <code>mediana(capitale) versato</code>. Il versato è " +
"esatto (i bonifici sono decisi); il resto è quello che ha prodotto il ricampionamento. " +
"Drift implicito del book: <b>" + pc(mB.drift) + " annuo</b> con volatilità <b>" +
pc(mB.vol) + "</b> e drawdown massimo <b>" + pc(Math.abs(mB.maxdd)) + "</b>."]
];
$("steps").innerHTML = passi.map(([n, t]) =>
'<div class="step"><span class="n">' + n + "</span><span>" + t + "</span></div>").join("");
/* ---------- tabella serie ---------- */
const F = D.finestra_comune;
$("t-serie").innerHTML =
"<caption>Le prime tre righe usano la storia piena di ciascuno; le seconde tre la sola " +
"finestra comune <b>" + F.da + " → " + F.a + "</b>. Drift e volatilità sono annualizzati a 365 " +
"giorni su griglia di calendario.</caption>" +
"<thead><tr><th>motore</th><th>anni</th><th>da</th><th>rendimento</th><th>volatilità</th>" +
"<th>Sharpe</th><th>drawdown max</th></tr></thead><tbody>" +
MOT.map(m => { const x = D.metriche[m];
return "<tr><td><span class='sw' style='background:" + COL[m] + "'></span>" + NOME[m] +
"</td><td>" + x.anni.toFixed(1) + "</td><td>" + x.da + "</td><td>" + pc(x.drift) +
"</td><td>" + pc(x.vol) + "</td><td>" + x.sharpe.toFixed(2).replace(".", ",") +
"</td><td>" + pc(x.maxdd) + "</td></tr>"; }).join("") +
MOT.map(m => { const x = D.metriche_comune[m];
return "<tr><td style='color:var(--ink-3)'><span class='sw' style='background:" + COL[m] +
";opacity:.5'></span>" + NOME[m] + " · stessa finestra</td><td>" + x.anni.toFixed(1) +
"</td><td>" + x.da + "</td><td>" + pc(x.drift) + "</td><td>" + pc(x.vol) + "</td><td>" +
x.sharpe.toFixed(2).replace(".", ",") + "</td><td>" + pc(x.maxdd) + "</td></tr>"; }).join("") +
"</tbody>";
const cB = D.metriche_comune.BOOK, cS = D.metriche_comune.SP500;
$("x-drift").textContent = pc(cB.drift) + " contro " + pc(cS.drift);
$("x-vol").textContent = pc(cB.vol) + " contro " + pc(cS.vol);
$("x-dd").textContent = pc(Math.abs(cB.maxdd)) + " contro " + pc(Math.abs(cS.maxdd));
/* ---------- legenda ---------- */
$("leg1").innerHTML = MOT.map(m =>
"<span><i style='background:" + COL[m] + "'></i>" + NOME[m] + "</span>").join("") +
"<span><i class='dash'></i>capitale-rendita di quel motore</span>";
/* ---------- grafico a linee ---------- */
const NS = "http://www.w3.org/2000/svg";
const el = (n, a) => { const e = document.createElementNS(NS, n); for (const k in a) e.setAttribute(k, a[k]); return e; };
const tip = $("tip");
function moveTip(evt) {
const r = tip.getBoundingClientRect();
let x = evt.clientX + 16, y = evt.clientY - r.height - 12;
if (x + r.width > window.innerWidth - 8) x = evt.clientX - r.width - 16;
if (y < 8) y = evt.clientY + 18;
tip.style.left = x + "px"; tip.style.top = y + "px";
}
const hideTip = () => { tip.style.opacity = "0"; };
function drawLines(svgId, traj, berg, capId, nota) {
const svg = $(svgId);
const W = 960, H = 400, M = { t: 26, r: 150, b: 40, l: 78 };
const pw = W - M.l - M.r, ph = H - M.t - M.b;
svg.setAttribute("viewBox", "0 0 " + W + " " + H);
svg.innerHTML = "";
const anni = traj.BOOK.length;
const finiti = MOT.map(m => berg[m].cap).filter(c => isFinite(c) && c > 0);
const rawMax = Math.max(...MOT.flatMap(m => traj[m].map(r => r.med)), ...finiti);
const STEP = [25000, 50000, 100000, 150000, 200000, 250000, 500000].find(s => rawMax / s <= 8) || 1000000;
const YMAX = Math.ceil(rawMax / STEP) * STEP;
const x = a => M.l + (a - 1) / Math.max(1, anni - 1) * pw;
const y = v => M.t + ph - (v / YMAX) * ph;
for (let v = 0; v <= YMAX; v += STEP) {
svg.appendChild(el("line", { x1: M.l, x2: M.l + pw, y1: y(v), y2: y(v),
stroke: v === 0 ? "var(--ink-3)" : "var(--grid)", "stroke-width": 1 }));
const t = el("text", { x: M.l - 12, y: y(v) + 4, "text-anchor": "end", class: "axis",
fill: "var(--ink-3)", "font-size": 11 });
t.textContent = usdK(v); svg.appendChild(t);
}
for (let a = 1; a <= anni; a++) {
if (anni > 12 && a % 2 === 0 && a !== anni) continue;
const t = el("text", { x: x(a), y: H - 16, "text-anchor": "middle", class: "axis",
fill: "var(--ink-3)", "font-size": 11 });
t.textContent = a; svg.appendChild(t);
}
const ax = el("text", { x: M.l + pw + 12, y: H - 16, class: "axis", fill: "var(--ink-3)", "font-size": 10.5 });
ax.textContent = "anno"; svg.appendChild(ax);
/* bersagli: tratteggio orizzontale + etichetta a destra */
MOT.forEach(m => {
const c = berg[m].cap;
if (!isFinite(c) || c <= 0 || c > YMAX) return;
svg.appendChild(el("line", { x1: M.l, x2: M.l + pw, y1: y(c), y2: y(c), stroke: COL[m],
"stroke-width": 1.5, "stroke-dasharray": "4 4", opacity: .55 }));
});
/* linee */
MOT.forEach(m => {
const pts = traj[m].map(r => x(r.anno) + "," + y(r.med)).join(" ");
svg.appendChild(el("polyline", { points: pts, fill: "none", stroke: COL[m],
"stroke-width": 2.5, "stroke-linejoin": "round", "stroke-linecap": "round" }));
const last = traj[m][traj[m].length - 1];
svg.appendChild(el("circle", { cx: x(last.anno), cy: y(last.med), r: 4, fill: COL[m] }));
/* etichetta diretta: identita' mai solo colore */
const lx = M.l + pw + 12;
const a = el("text", { x: lx, y: y(last.med) - 3, class: "axis", fill: COL[m], "font-size": 11 });
a.textContent = NOME[m]; svg.appendChild(a);
const b = el("text", { x: lx, y: y(last.med) + 12, class: "axis", fill: "var(--ink)", "font-size": 11.5 });
b.textContent = usd(last.med); svg.appendChild(b);
});
/* colonne di hover */
for (let a = 1; a <= anni; a++) {
const g = el("g", { tabindex: "0", "aria-label": "anno " + a });
const w = pw / anni;
g.appendChild(el("rect", { x: x(a) - w / 2, y: M.t, width: w, height: ph, fill: "transparent" }));
MOT.forEach(m => g.appendChild(el("circle", { cx: x(a), cy: y(traj[m][a - 1].med), r: 2.5,
fill: COL[m], opacity: .0 })));
const mostra = evt => {
tip.innerHTML = '<span class="t">anno ' + a + "</span>" +
MOT.map(m => { const r = traj[m][a - 1];
return '<div class="r"><span><span class="sw" style="background:' + COL[m] + '"></span>' +
NOME[m].split(" (")[0] + "</span><b>" + usd(r.med) + "</b></div>" +
'<div class="r" style="opacity:.65"><span> banda p25p75</span><b>' +
usdK(r.p25) + " " + usdK(r.p75) + "</b></div>"; }).join("") +
'<div class="r" style="margin-top:5px"><span>versato</span><b>' +
usd(traj.BOOK[a - 1].versato) + "</b></div>";
tip.style.opacity = "1"; moveTip(evt);
};
g.addEventListener("pointerenter", mostra);
g.addEventListener("pointermove", moveTip);
g.addEventListener("pointerleave", hideTip);
g.addEventListener("focus", () => mostra({ clientX: x(a) + 60, clientY: 300 }));
g.addEventListener("blur", hideTip);
svg.appendChild(g);
}
$(capId).innerHTML = nota;
}
drawLines("c1", D.traiettoria, D.bersaglio, "cap1",
"Ogni motore ricampiona la propria storia: il book 7,4 anni, l'S&amp;P 500 trent'anni che " +
"contengono il 2000, il 2008 e il 2022. Le linee tratteggiate sono i tre capitali-rendita, " +
"diversi fra loro. È la lente che <b>favorisce il book</b>: gli si chiede di battere indici " +
"misurati anche nei loro anni peggiori, mai vissuti dalla strategia.");
drawLines("c2", D.traiettoria_comune, D.bersaglio_comune, "cap2",
"Stessa finestra per tutti (" + F.da + " → " + F.a + "), quindi stesso regime di mercato. " +
"Qui l'S&amp;P 500 <b>accumula più del book</b>. I bersagli tratteggiati sono anch'essi " +
"ricalcolati su questa finestra — e vanno presi con le molle: una rendita perpetua è una " +
"domanda a vent'anni, e sette anni non la risolvono.");
/* ---------- tabella bersagli ---------- */
$("t-berg").innerHTML =
"<caption>«Rendita perpetua» = prelievo annuo che mantiene P(capitale a 20 anni ≥ capitale " +
"iniziale) ≥ 90%. Il capitale è quello che serve per <b>€50/giorno netti</b> con l'aliquota di " +
"quel motore.</caption>" +
"<thead><tr><th>motore</th><th>aliquota</th><th>rendita perpetua</th><th>capitale necessario</th>" +
"<th>capitale a " + P.anni + " anni</th><th>quota raggiunta</th></tr></thead><tbody>" +
MOT.map(m => {
const b = D.bersaglio[m], L = D.traiettoria[m][D.traiettoria[m].length - 1];
const q = isFinite(b.cap) && b.cap > 0 ? L.med / b.cap : NaN;
return "<tr><td><span class='sw' style='background:" + COL[m] + "'></span>" + NOME[m] +
"</td><td>" + pc(b.aliquota, 0) + "</td><td>" + pc(b.perp, 2) + "</td><td>" +
(isFinite(b.cap) ? usd(b.cap) : "non sostenibile") + "</td><td>" + usd(L.med) + "</td><td>" +
(isFinite(q) ? pc(q, 0) : "—") + "</td></tr>"; }).join("") + "</tbody>";
/* ---------- banda finale ---------- */
const allMax = Math.max(...MOT.flatMap(m => [D.traiettoria[m].slice(-1)[0].p75,
isFinite(D.bersaglio[m].cap) ? D.bersaglio[m].cap : 0]));
$("dots").innerHTML = MOT.map(m => {
const L = D.traiettoria[m].slice(-1)[0], b = D.bersaglio[m];
const f = v => (100 * v / allMax).toFixed(2) + "%";
const berg = isFinite(b.cap) && b.cap <= allMax
? '<span style="position:absolute;left:' + f(b.cap) + ';top:-6px;bottom:-6px;border-left:2px dashed var(--ink-3)"></span>' : "";
return '<div class="dotrow"><span>' + NOME[m].split(" (")[0] + '</span>' +
'<span style="position:relative;display:block;height:22px">' +
'<span style="position:absolute;left:' + f(L.p25) + ';width:' + f(L.p75 - L.p25) +
';top:7px;height:8px;border-radius:4px;background:' + COL[m] + ';opacity:.32"></span>' +
'<span style="position:absolute;left:calc(' + f(L.med) + ' - 1px);top:3px;width:3px;height:16px;background:' + COL[m] + '"></span>' +
berg + "</span></div>"; }).join("") +
'<p class="figcap" style="margin-top:2px">Barra = intervallo p25p75 del capitale a ' + P.anni +
' anni · segno pieno = mediana · tratteggio = capitale-rendita di quel motore. Scala comune, ' +
'da $0 a ' + usdK(allMax) + '.</p>';
/* ---------- lettura ---------- */
const qB = D.traiettoria.BOOK.slice(-1)[0].med / D.bersaglio.BOOK.cap;
const qS = D.traiettoria.SP500.slice(-1)[0].med / D.bersaglio.SP500.cap;
const qBc = D.traiettoria_comune.BOOK.slice(-1)[0].med / D.bersaglio_comune.BOOK.cap;
const qSc = D.traiettoria_comune.SP500.slice(-1)[0].med / D.bersaglio_comune.SP500.cap;
$("lettura").innerHTML =
"Sulla storia piena di ciascuno il book arriva al <b>" + pc(qB, 0) + "</b> del proprio " +
"bersaglio e l'S&amp;P 500 al <b>" + pc(qS, 0) + "</b>. Sulla stessa finestra diventano <b>" +
pc(qBc, 0) + "</b> e <b>" + pc(qSc, 0) + "</b>: quasi pari. <b>La differenza fra le due lenti " +
"è tutta la domanda</b> — i sette anni del book rappresentano il futuro, o sono una finestra " +
"fortunata? L'indice ha attraversato due crolli che la strategia non ha mai vissuto, e il " +
"book non ha una storia lunga da opporre: ne ha " + D.metriche.BOOK.anni.toFixed(1) + " anni in tutto.";
/* ---------- tabella completa ---------- */
$("t-full").innerHTML =
"<thead><tr><th>anno</th><th>versato</th>" +
MOT.map(m => "<th>" + NOME[m].split(" (")[0] + "</th>").join("") +
MOT.map(m => "<th>" + NOME[m].split(" (")[0] + " · stessa fin.</th>").join("") +
"</tr></thead><tbody>" +
D.traiettoria.BOOK.map((r, i) =>
"<tr><td>" + r.anno + "</td><td>" + usd(r.versato) + "</td>" +
MOT.map(m => "<td>" + usd(D.traiettoria[m][i].med) + "</td>").join("") +
MOT.map(m => "<td style='opacity:.7'>" + usd(D.traiettoria_comune[m][i].med) + "</td>").join("") +
"</tr>").join("") + "</tbody>";
$("foot").innerHTML =
"Fonti: book live dalle serie d'ancora canoniche del progetto; <b>SPY</b> e <b>EFA</b> da " +
"Interactive Brokers, <code>ADJUSTED_LAST</code> (dividendi reinvestiti), certificati. " +
"MSCI World è un <b>proxy</b>, non l'indice: gli ETF reali (URTH, ACWI, VT) non sono " +
"nell'abbonamento dati del conto, quindi è ricostruito come 70% SPY + 30% EFA — e la quota USA " +
"dell'indice vero è passata da ~50% nel 2008 a ~72% oggi, quindi il peso fisso è " +
"un'approssimazione (fra 50% e 80% di USA il rendimento si muove di 0,7 punti, il Sharpe di " +
"0,05). Il prezzo di un ETF è già al netto del suo TER. Non c'è rischio di cambio nel modello: " +
"tutto è in dollari, e un investitore in euro lo corre su entrambi i lati. " +
"Script: <b>scripts/research/r0807_asset_compare.py</b>.";
})();
</script>