Files
PythagorasGoal/scripts/web/pagina_simulatore.html
T
Adriano Dal Pastro ed2532b991 feat(web): simulatore di accumulo nel browser, verificato contro il Python
scripts/web/ — motore in JavaScript (engine.js) sui ritorni VERI del book
esportati da export_series.py, pagine assemblate da build.py. Da "sistema
dinamico dove imposti valore iniziale, mensile e durata, e calcola la best curva
fissando il tempo": la simulazione gira nel browser, quindi il motore va
riscritto e VA PROVATO che dia la stessa risposta.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 18:44:13 +00:00

603 lines
31 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<title>Simulatore del piano di accumulo — PythagorasGoal</title>
<style>
:root {
color-scheme: light;
--ground: #f7f7f5;
--surface: #fdfdfc;
--sunken: #f1f1ee;
--rule: #dededa;
--rule-soft: #ebebe7;
--ink: #16171a;
--ink-2: #55575c;
--ink-3: #83858b;
--versato: #2a78d6;
--guadagno: #eb6834;
--grid: #e6e6e2;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--ground: #17181a; --surface: #1d1e21; --sunken: #232427;
--rule: #34363a; --rule-soft: #26282b;
--ink: #f1f1ee; --ink-2: #a8aab0; --ink-3: #7c7e85;
--versato: #3987e5; --guadagno: #d95926; --grid: #2b2d31;
}
}
:root[data-theme="dark"] {
color-scheme: dark;
--ground: #17181a; --surface: #1d1e21; --sunken: #232427;
--rule: #34363a; --rule-soft: #26282b;
--ink: #f1f1ee; --ink-2: #a8aab0; --ink-3: #7c7e85;
--versato: #3987e5; --guadagno: #d95926; --grid: #2b2d31;
}
*, *::before, *::after { box-sizing: border-box; }
body {
background: var(--ground); color: var(--ink);
font-family: Georgia, "Iowan Old Style", "Times New Roman", serif;
font-size: 17px; line-height: 1.62; margin: 0; padding: 0 24px 96px;
-webkit-font-smoothing: antialiased;
}
.wrap { max-width: 1080px; margin: 0 auto; display: flex; flex-direction: column; gap: 40px; }
.prose { max-width: 68ch; display: flex; flex-direction: column; gap: 16px; }
.mono, .eyebrow, .lede-meta, .legend, .axis, table, .tile, .ctl, .solve, .badge, button, input {
font-family: ui-monospace, SFMono-Regular, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
font-variant-numeric: tabular-nums;
}
header { padding-top: 60px; display: flex; flex-direction: column; gap: 18px; }
.eyebrow { font-size: 11.5px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--ink-3); margin: 0; }
h1 { font-size: clamp(29px, 4.2vw, 44px); line-height: 1.12; margin: 0; font-weight: 400;
letter-spacing: -0.015em; text-wrap: balance; max-width: 21ch; }
.lede { font-size: 19px; color: var(--ink-2); margin: 0; max-width: 62ch; }
.lede b { color: var(--ink); font-weight: 700; }
.lede-meta { font-size: 12.5px; color: var(--ink-3); line-height: 1.9; margin: 0;
padding-top: 14px; border-top: 1px solid var(--rule); }
.lede-meta b { color: var(--ink-2); font-weight: 400; }
h2 { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px;
letter-spacing: 0.15em; text-transform: uppercase; color: var(--ink-2); font-weight: 500;
margin: 0 0 4px; padding-bottom: 10px; border-bottom: 1px solid var(--rule); }
p { margin: 0; }
em { font-style: italic; color: var(--ink-2); }
/* ---------- comandi ---------- */
.panel { background: var(--surface); border: 1px solid var(--rule); border-radius: 3px; }
.ctl { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 1px;
background: var(--rule); border: 1px solid var(--rule); border-radius: 3px; overflow: hidden; }
.ctl > div { background: var(--surface); padding: 16px 18px 18px; display: flex; flex-direction: column; gap: 9px; }
.ctl label { font-size: 11px; letter-spacing: 0.13em; text-transform: uppercase; color: var(--ink-3); }
.row { display: flex; align-items: baseline; gap: 8px; }
.row .unit { color: var(--ink-3); font-size: 14px; }
input[type="number"] {
background: var(--sunken); border: 1px solid var(--rule); border-radius: 2px; color: var(--ink);
font-size: 21px; padding: 5px 8px; width: 100%; max-width: 140px; letter-spacing: -0.01em;
}
input[type="number"]:focus-visible { outline: 2px solid var(--versato); outline-offset: 1px; }
input[type="range"] { width: 100%; accent-color: var(--versato); margin: 0; }
.seg { display: flex; gap: 0; border: 1px solid var(--rule); border-radius: 2px; overflow: hidden; width: fit-content; }
.seg button { background: var(--surface); border: 0; border-right: 1px solid var(--rule);
color: var(--ink-2); font-size: 12px; padding: 8px 13px; cursor: pointer; }
.seg button:last-child { border-right: 0; }
.seg button[aria-pressed="true"] { background: var(--versato); color: #fff; }
.seg button:focus-visible { outline: 2px solid var(--ink); outline-offset: -2px; }
/* ---------- tiles ---------- */
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1px;
background: var(--rule); border: 1px solid var(--rule); border-radius: 3px; overflow: hidden; }
.tile { background: var(--surface); padding: 17px 18px 19px; display: flex; flex-direction: column; gap: 5px; }
.tile .k { font-size: 11px; letter-spacing: 0.13em; text-transform: uppercase; color: var(--ink-3); }
.tile .v { font-size: 26px; letter-spacing: -0.02em; color: var(--ink); line-height: 1.12; }
.tile .v small { font-size: 14px; color: var(--ink-3); letter-spacing: 0; }
.tile .s { font-size: 12px; color: var(--ink-2); line-height: 1.55; }
/* ---------- risolutore ---------- */
.solve { border: 1px solid var(--rule); border-radius: 3px; overflow: hidden; }
.solve table { width: 100%; border-collapse: collapse; font-size: 13px; }
.solve th { background: var(--surface); color: var(--ink-3); font-weight: 400; font-size: 11px;
letter-spacing: 0.08em; text-transform: uppercase; text-align: right; padding: 11px 14px; }
.solve th:first-child { text-align: left; }
.solve td { padding: 11px 14px; text-align: right; border-top: 1px solid var(--rule-soft); color: var(--ink-2); }
.solve td:first-child { text-align: left; color: var(--ink); }
.solve td.big { color: var(--ink); font-size: 17px; }
.solve tr.now td { background: color-mix(in srgb, var(--versato) 8%, transparent); }
.apply { background: transparent; border: 1px solid var(--rule); border-radius: 2px; color: var(--ink-2);
font-size: 11px; padding: 5px 11px; cursor: pointer; letter-spacing: 0.06em; text-transform: uppercase; }
.apply:hover { border-color: var(--versato); color: var(--versato); }
.apply:focus-visible { outline: 2px solid var(--versato); outline-offset: 2px; }
.apply[disabled] { opacity: .35; cursor: default; }
.working { color: var(--ink-3); font-style: italic; }
/* ---------- grafico ---------- */
figure { margin: 0; display: flex; flex-direction: column; gap: 10px; }
.chart-title { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px;
letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink); margin: 0; }
.chart-title span { color: var(--ink-3); text-transform: none; letter-spacing: 0; }
.chart-box { background: var(--surface); border: 1px solid var(--rule); border-radius: 3px;
padding: 18px 16px 10px; overflow-x: auto; }
.chart-box svg { display: block; width: 100%; min-width: 620px; height: auto; }
.legend { display: flex; gap: 22px; flex-wrap: wrap; font-size: 12px; color: var(--ink-2); align-items: center; }
.legend i { width: 11px; height: 11px; border-radius: 2px; display: inline-block; margin-right: 7px; vertical-align: -1px; }
.legend .band { width: 2px; height: 13px; background: var(--ink-3); margin-right: 7px; }
.figcap { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
color: var(--ink-3); line-height: 1.7; max-width: 80ch; }
/* ---------- tabella ---------- */
details { border-top: 1px solid var(--rule); padding-top: 14px; }
summary { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink-2); cursor: pointer; padding: 4px 0; }
summary:focus-visible { outline: 2px solid var(--versato); outline-offset: 3px; }
.tablewrap { overflow-x: auto; margin-top: 14px; }
table.data { border-collapse: collapse; font-size: 12.5px; width: 100%; min-width: 640px; }
table.data th, table.data td { padding: 6px 12px 6px 0; text-align: right; white-space: nowrap; }
table.data th:first-child, table.data td:first-child { text-align: left; }
table.data thead th { color: var(--ink-3); font-weight: 400; border-bottom: 1px solid var(--rule);
font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; }
table.data tbody td { border-bottom: 1px solid var(--rule-soft); color: var(--ink-2); }
table.data tbody td:first-child { color: var(--ink); }
#tip { position: fixed; z-index: 40; pointer-events: none; opacity: 0; transition: opacity .1s ease;
background: var(--surface); border: 1px solid var(--rule); border-radius: 3px; padding: 10px 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; line-height: 1.75;
color: var(--ink-2); box-shadow: 0 6px 22px rgba(0,0,0,.14); font-variant-numeric: tabular-nums; max-width: 260px; }
#tip .t { color: var(--ink); letter-spacing: 0.08em; text-transform: uppercase; font-size: 10.5px; display: block; margin-bottom: 5px; }
#tip .r { display: flex; justify-content: space-between; gap: 18px; }
#tip .r b { font-weight: 400; color: var(--ink); }
#tip .sw { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 6px; }
.bargroup:focus-visible .hit { stroke: var(--ink); stroke-width: 1.5; }
.caveat { border-left: 2px solid var(--guadagno); padding: 2px 0 2px 18px; color: var(--ink-2);
font-size: 16px; max-width: 64ch; }
.caveat b { color: var(--ink); font-weight: 700; }
footer { border-top: 1px solid var(--rule); padding-top: 18px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px;
color: var(--ink-3); line-height: 1.85; max-width: 80ch; }
footer b { color: var(--ink-2); font-weight: 400; }
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
</style>
<div class="wrap">
<header>
<p class="eyebrow">PythagorasGoal · simulatore del piano · 2026-08-07</p>
<h1>Imposta il piano, guarda la curva</h1>
<p class="lede">
Capitale iniziale, versamento mensile e durata. La simulazione gira <b>qui dentro</b>, sui
ritorni reali del book live: 2.692 giorni dal 2019 al 2026, ricampionati a blocchi di 20
giorni per non distruggere il raggruppamento della volatilità.
</p>
<p class="lede-meta">
book live <b>TP01 + SKH01 75/25</b>, de-luckato <b>×0.89</b> · bersaglio <b>$272.061</b>
(capitale-rendita per €50/giorno netti) · motore verificato contro
<b>r0807_growth_yearly.py</b>: differenza fra le medie <b>0,01%</b> su 8 semi per parte
</p>
</header>
<div class="ctl">
<div>
<label for="n-lump">Capitale iniziale</label>
<div class="row"><span class="unit"></span><input type="number" id="n-lump" min="0" max="200000" step="500" value="5000"></div>
<input type="range" id="r-lump" min="0" max="50000" step="500" value="5000">
</div>
<div>
<label for="n-dep">Versamento mensile</label>
<div class="row"><span class="unit"></span><input type="number" id="n-dep" min="0" max="10000" step="25" value="500"></div>
<input type="range" id="r-dep" min="0" max="2500" step="25" value="500">
</div>
<div>
<label for="n-anni">Durata prevista</label>
<div class="row"><input type="number" id="n-anni" min="1" max="30" step="1" value="15"><span class="unit">anni</span></div>
<input type="range" id="r-anni" min="1" max="30" step="1" value="15">
</div>
<div>
<label>Regime</label>
<div class="seg" role="group" aria-label="Regime fiscale">
<button type="button" id="b-netto" aria-pressed="true">col fisco</button>
<button type="button" id="b-lordo" aria-pressed="false">lordo</button>
</div>
<p class="s" style="font-size:12px;color:var(--ink-3);line-height:1.5;margin:0">
«col fisco» = plusvalenze 33% annue + patrimoniale 0,2%. È il regime realistico.
</p>
</div>
</div>
<div class="tiles" id="tiles"></div>
<section class="prose">
<h2>La curva migliore a tempo fissato</h2>
<p>
Fissata la durata, il versamento mensile è <em>l'unica leva che resta</em>: il rendimento non
si comanda e il capitale iniziale è quello che è. Qui sotto il versamento cercato per
bisezione — lo stesso metodo di <em>dep_necessario</em> in <em>r0727_tasse.py</em> — perché la
probabilità di toccare il bersaglio entro la durata impostata arrivi a 1 su 2, 3 su 4, 9 su 10.
</p>
</section>
<div class="solve"><table id="solve"></table></div>
<figure>
<p class="chart-title" id="ct">Capitale per anno</p>
<div class="legend" aria-hidden="true">
<span><i style="background:var(--versato)"></i>versato (deterministico)</span>
<span><i style="background:var(--guadagno)"></i>guadagno del percorso mediano</span>
<span><i class="band"></i>banda p25p75 del capitale</span>
</div>
<div class="chart-box"><svg id="chart" role="img" aria-label="Capitale per anno, scomposto in versamenti e guadagno"></svg></div>
<figcaption class="figcap" id="figcap"></figcaption>
</figure>
<details>
<summary>Tabella completa — anno per anno</summary>
<div class="tablewrap"><table class="data" id="tbl"></table></div>
</details>
<section class="prose">
<h2>Cosa questo strumento non sa</h2>
<p class="caveat">
Il «guadagno» disegnato è <b>mediana(capitale) versato</b>: il rendimento del percorso
mediano, non di un percorso singolo — le due componenti non sono indipendenti e non vanno
sommate come se lo fossero. La banda p25p75 dice quanto il percorso può spostarsi.
</p>
<p>
Non c'è il <strong>rischio di venue</strong>: a p=1% annuo di fallimento dell'exchange la
probabilità di arrivare scende di ~2 punti e quella di <em>perdere tutto</em> sale al 18%; a
p=5% il capitale mediano a 20 anni è zero per qualunque calendario di versamenti. E i
versamenti qui non si fermano mai, che è l'unico scenario che non succede: interromperli al
5º anno porta la probabilità di centrare il bersaglio dal 90% al 53%.
</p>
<p>
Le assunzioni fiscali (33% sulle plusvalenze, minusvalenze riportabili 4 anni, 0,2% annuo sul
valore) sono dichiarate e <em>non sono un parere fiscale</em>; il modello tassa la variazione
annua di valore, quindi è un limite superiore rispetto alla pura realizzazione — stretto,
però, perché questo book realizza quasi tutto entro l'anno. E i ritorni passati del book non
sono una previsione: sono <em>l'unico campione che abbiamo</em>, ricampionato.
</p>
</section>
<footer id="foot"></footer>
</div>
<div id="tip" role="status" aria-live="polite"></div>
<script id="series-data" type="application/json">/*__SERIES__*/</script>
<script>
/*__ENGINE__*/
(function () {
"use strict";
const CFG = JSON.parse(document.getElementById("series-data").textContent);
const E = makeEngine(CFG);
const TARGET = CFG.target;
const PATHS_MAIN = 3000; /* ~150 ms a 20 anni: la curva si ridisegna mentre si trascina */
/* Il risolutore fa 13 corse per livello. Misurato: la banda del risultato resta ±1% da 1.200 a
3.000 percorsi -> non domina il Monte Carlo ma la granularita' della bisezione (20.000/2^12
= ~€5). Percio' si tiene basso e si dichiara l'incertezza, invece di pagare tempo per una
precisione che non arriva. */
const PATHS_SOLVE = 1500;
const SEED = 20260807;
const $ = id => document.getElementById(id);
const usd = n => "$" + Math.round(n).toLocaleString("it-IT");
const usdK = n => n === 0 ? "$0" : (Math.abs(n) >= 1e6
? "$" + (n / 1e6).toFixed(n >= 1e7 ? 0 : 1).replace(".", ",") + "M"
: "$" + Math.round(n / 1000) + "k");
const eur = n => "€" + Math.round(n).toLocaleString("it-IT");
const pct = n => (100 * n).toFixed(1).replace(".", ",") + "%";
const ORD = n => n + "º";
let state = { lump: 5000, dep: 500, anni: 15, netto: true };
let cur = null, curAlt = null;
/* ---------------- comandi ---------------- */
function bind(name, key, max) {
const n = $("n-" + name), r = $("r-" + name);
const set = (v, from) => {
v = Math.max(Number(n.min), Math.min(Number(n.max), Math.round(v)));
state[key] = v;
if (from !== "n") n.value = v;
if (from !== "r") r.value = Math.min(Number(r.max), v);
schedule();
};
n.addEventListener("input", () => { if (n.value !== "") set(Number(n.value), "n"); });
r.addEventListener("input", () => set(Number(r.value), "r"));
}
bind("lump", "lump");
bind("dep", "dep");
bind("anni", "anni");
$("b-netto").addEventListener("click", () => setRegime(true));
$("b-lordo").addEventListener("click", () => setRegime(false));
function setRegime(netto) {
state.netto = netto;
$("b-netto").setAttribute("aria-pressed", String(netto));
$("b-lordo").setAttribute("aria-pressed", String(!netto));
schedule();
}
let timer = null;
function schedule() {
clearTimeout(timer);
timer = setTimeout(recompute, 110);
}
function opts(netto, paths) {
return { lumpEur: state.lump, depEur: state.dep, years: state.anni,
tax: netto ? CFG.tax : 0, patrimoniale: netto ? CFG.patrimoniale : 0,
paths: paths, seed: SEED };
}
/* ---------------- ciclo principale ---------------- */
function recompute() {
cur = E.run(opts(state.netto, PATHS_MAIN));
curAlt = E.run(opts(!state.netto, PATHS_MAIN));
renderTiles(); renderChart(); renderTable(); renderFoot();
startSolver();
}
const cap = r => r.med;
const lastRow = () => cur[cur.length - 1];
function crossover(rows) { const r = rows.find(x => x.guadagno > x.versato); return r ? r.anno : null; }
function hitYear(rows) { const r = rows.find(x => x.med >= TARGET); return r ? r.anno : null; }
function renderTiles() {
const L = lastRow(), alt = curAlt[curAlt.length - 1];
const cx = crossover(cur), hy = hitYear(cur);
const t = [
{ k: "capitale a " + state.anni + " anni", v: usd(L.med),
s: "banda p25p75 " + usdK(L.p25) + " " + usdK(L.p75) },
{ k: "versato in totale", v: usd(L.versato),
s: "il guadagno è il " + Math.round(100 * L.guadagno / L.med) + "% del capitale finale" },
{ k: "P(bersaglio) entro " + state.anni + " anni", v: pct(L.phit),
s: hy ? "la mediana lo tocca al " + ORD(hy) + " anno" : "la mediana non lo tocca in questa durata" },
{ k: "sorpasso", v: cx ? ORD(cx) + "<small> anno</small>" : "—",
s: cx ? "l'anno in cui il guadagno supera tutto il versato"
: "il guadagno non supera il versato entro la durata" },
{ k: state.netto ? "costo del fisco" : "costo del fisco (se acceso)",
v: (state.netto ? "" + Math.round(100 * (1 - L.med / alt.med)) : "" + Math.round(100 * (1 - alt.med / L.med))) + "%",
s: state.netto ? "senza imposte sarebbe " + usd(alt.med) : "col fisco sarebbe " + usd(alt.med) }
];
$("tiles").innerHTML = t.map(x =>
'<div class="tile"><span class="k">' + x.k + '</span><span class="v">' + x.v +
'</span><span class="s">' + x.s + "</span></div>").join("");
}
/* ---------------- risolutore: una bisezione per frame, cosi' la pagina resta viva ---------------- */
const LIVELLI = [
{ p: 0.50, etichetta: "1 percorso su 2", nota: "la mediana arriva" },
{ p: 0.75, etichetta: "3 su 4", nota: "margine ragionevole" },
{ p: 0.90, etichetta: "9 su 10", nota: "quasi certo, e quasi tutto comprato coi bonifici" }
];
let solverToken = 0;
function startSolver() {
const token = ++solverToken;
const risultati = LIVELLI.map(() => ({ stato: "calcolo", val: null }));
renderSolve(risultati);
let i = 0;
function next() {
if (token !== solverToken) return; /* un nuovo input ha invalidato la corsa */
if (i >= LIVELLI.length) return;
const o = opts(state.netto, PATHS_SOLVE);
const P = d => E.run(Object.assign({}, o, { depEur: d })).slice(-1)[0].phit;
let lo = 0, hi = 20000, step = 0;
if (P(hi) < LIVELLI[i].p) { risultati[i] = { stato: "fuori", val: null }; i++; renderSolve(risultati); requestAnimationFrame(next); return; }
function bisect() {
if (token !== solverToken) return;
const mid = 0.5 * (lo + hi);
if (P(mid) >= LIVELLI[i].p) hi = mid; else lo = mid;
step++;
risultati[i] = { stato: step >= 12 ? "ok" : "calcolo", val: 0.5 * (lo + hi) };
renderSolve(risultati);
if (step < 12) requestAnimationFrame(bisect);
else { i++; requestAnimationFrame(next); }
}
requestAnimationFrame(bisect);
}
requestAnimationFrame(next);
}
function renderSolve(res) {
const L = lastRow();
const rows = LIVELLI.map((liv, i) => {
const r = res[i];
let cella;
if (r.stato === "fuori") cella = '<span class="working">fuori portata</span>';
else if (r.val === null) cella = '<span class="working">calcolo…</span>';
else cella = eur(Math.round(r.val / 5) * 5) + (r.stato === "calcolo" ? ' <span class="working">…</span>' : "");
const applicabile = r.stato === "ok" && r.val !== null;
return "<tr><td>" + liv.etichetta + "</td><td class='big'>" + cella + "</td><td>" +
liv.nota + "</td><td>" + (applicabile
? '<button class="apply" data-v="' + Math.round(r.val / 5) * 5 + '">usa</button>'
: '<button class="apply" disabled>usa</button>') + "</td></tr>";
}).join("");
$("solve").innerHTML =
"<thead><tr><th>perché il bersaglio sia colpito in " + state.anni + " anni</th>" +
"<th>versamento mensile</th><th></th><th></th></tr></thead>" +
"<tbody><tr class='now'><td>il piano impostato ora</td><td class='big'>" + eur(state.dep) +
"</td><td>dà P = " + pct(L.phit) + " · capitale mediano " + usd(L.med) +
"</td><td></td></tr>" + rows + "</tbody>";
$("solve").querySelectorAll("button.apply[data-v]").forEach(b =>
b.addEventListener("click", () => {
const v = Number(b.dataset.v);
$("n-dep").value = v; $("r-dep").value = Math.min(Number($("r-dep").max), v);
state.dep = v; schedule();
}));
}
/* ---------------- grafico ---------------- */
const NS = "http://www.w3.org/2000/svg";
const el = (n, a) => { const e = document.createElementNS(NS, n); for (const k in a) e.setAttribute(k, a[k]); return e; };
const tip = $("tip");
function showTip(evt, row) {
tip.innerHTML = '<span class="t">anno ' + row.anno + " · " + (state.netto ? "col fisco" : "lordo") + "</span>" +
'<div class="r"><span><span class="sw" style="background:var(--versato)"></span>versato</span><b>' + usd(row.versato) + "</b></div>" +
'<div class="r"><span><span class="sw" style="background:var(--guadagno)"></span>guadagno</span><b>' + usd(row.guadagno) + "</b></div>" +
'<div class="r"><span>capitale mediano</span><b>' + usd(row.med) + "</b></div>" +
'<div class="r"><span>banda p25p75</span><b>' + usdK(row.p25) + " " + usdK(row.p75) + "</b></div>" +
'<div class="r"><span>P(bersaglio)</span><b>' + pct(row.phit) + "</b></div>";
tip.style.opacity = "1"; moveTip(evt);
}
function moveTip(evt) {
const r = tip.getBoundingClientRect();
let x = evt.clientX + 16, y = evt.clientY - r.height - 12;
if (x + r.width > window.innerWidth - 8) x = evt.clientX - r.width - 16;
if (y < 8) y = evt.clientY + 18;
tip.style.left = x + "px"; tip.style.top = y + "px";
}
const hideTip = () => { tip.style.opacity = "0"; };
function roundedTop(x, yTop, w, h, r) {
if (h <= 0) return "";
r = Math.min(r, h, w / 2);
return "M" + x + "," + (yTop + h) + "V" + (yTop + r) +
"a" + r + "," + r + " 0 0 1 " + r + "," + (-r) + "h" + (w - 2 * r) +
"a" + r + "," + r + " 0 0 1 " + r + "," + r + "V" + (yTop + h) + "Z";
}
function renderChart() {
const rows = cur;
const svg = $("chart");
const W = 960, H = 380, M = { t: 26, r: 108, b: 38, l: 78 };
const pw = W - M.l - M.r, ph = H - M.t - M.b;
svg.setAttribute("viewBox", "0 0 " + W + " " + H);
svg.innerHTML = "";
/* scala verticale dai dati: deve contenere il bersaglio anche quando il piano non lo raggiunge */
const rawMax = Math.max(TARGET * 1.05, ...rows.map(r => r.p75));
const STEP = [5000, 10000, 25000, 50000, 100000, 150000, 200000, 250000, 500000, 1000000, 2500000]
.find(s => rawMax / s <= 8) || 5000000;
const YMAX = Math.ceil(rawMax / STEP) * STEP;
const y = v => M.t + ph - (v / YMAX) * ph;
const step = pw / rows.length;
const bw = Math.min(38, step * 0.62);
for (let v = 0; v <= YMAX; v += STEP) {
svg.appendChild(el("line", { x1: M.l, x2: M.l + pw, y1: y(v), y2: y(v),
stroke: v === 0 ? "var(--ink-3)" : "var(--grid)", "stroke-width": 1 }));
const t = el("text", { x: M.l - 12, y: y(v) + 4, "text-anchor": "end", class: "axis",
fill: "var(--ink-3)", "font-size": 11 });
t.textContent = usdK(v);
svg.appendChild(t);
}
const ty = y(TARGET);
svg.appendChild(el("line", { x1: M.l, x2: M.l + pw, y1: ty, y2: ty, stroke: "var(--ink-2)",
"stroke-width": 1.5, "stroke-dasharray": "5 4" }));
const tl = el("text", { x: M.l + 8, y: ty - 8, class: "axis", fill: "var(--ink-2)", "font-size": 11 });
tl.textContent = "bersaglio $272.061 — capitale-rendita";
svg.appendChild(tl);
const cx0 = crossover(rows);
rows.forEach((row, i) => {
const cx = M.l + step * i + step / 2, x = cx - bw / 2;
const vTop = y(row.versato), gTop = y(row.med), base = y(0);
const g = el("g", { class: "bargroup", tabindex: "0",
"aria-label": "anno " + row.anno + ": versato " + usd(row.versato) + ", guadagno " + usd(row.guadagno) });
g.appendChild(el("path", { d: roundedTop(x, vTop, bw, base - vTop, 0), fill: "var(--versato)" }));
const rawH = vTop - gTop, gh = rawH - (rawH > 6 ? 2 : 0);
if (gh > 0.3) g.appendChild(el("path", { d: roundedTop(x, gTop, bw, gh, 4), fill: "var(--guadagno)" }));
/* un guadagno NEGATIVO (percorso mediano sotto il versato) si disegna sotto la linea del versato */
if (rawH < 0) g.appendChild(el("path", { d: roundedTop(x, vTop, bw, -rawH, 0), fill: "var(--guadagno)", opacity: .45 }));
const p25 = y(row.p25), p75 = y(row.p75);
g.appendChild(el("line", { x1: cx, x2: cx, y1: p75, y2: p25, stroke: "var(--surface)", "stroke-width": 4 }));
g.appendChild(el("line", { x1: cx, x2: cx, y1: p75, y2: p25, stroke: "var(--ink-2)", "stroke-width": 2, opacity: .85 }));
[p25, p75].forEach(yy => g.appendChild(el("line", { x1: cx - 5, x2: cx + 5, y1: yy, y2: yy,
stroke: "var(--ink-2)", "stroke-width": 2, opacity: .85 })));
const hit = el("rect", { class: "hit", x: M.l + step * i, y: M.t, width: step, height: ph, fill: "transparent" });
g.appendChild(hit);
g.addEventListener("pointerenter", e => showTip(e, row));
g.addEventListener("pointermove", moveTip);
g.addEventListener("pointerleave", hideTip);
g.addEventListener("focus", () => { const b = hit.getBoundingClientRect();
showTip({ clientX: b.left + b.width / 2, clientY: b.top + 40 }, row); });
g.addEventListener("blur", hideTip);
svg.appendChild(g);
/* con molte barre si etichetta un anno su due, per non impilare i numeri */
const passo = rows.length > 20 ? 5 : (rows.length > 12 ? 2 : 1);
if (row.anno % passo === 0 || row.anno === rows.length || row.anno === cx0) {
const xt = el("text", { x: cx, y: H - 14, "text-anchor": "middle", class: "axis",
fill: row.anno === cx0 ? "var(--ink)" : "var(--ink-3)", "font-size": 11 });
xt.textContent = row.anno;
svg.appendChild(xt);
}
});
/* etichette dirette delle serie: l'identita' non e' mai solo colore */
const last = rows[rows.length - 1];
const lastTop = y(last.med), lastMid = y(last.versato), lx = M.l + pw + 14;
[["guadagno", usd(last.guadagno), (Math.min(lastTop, lastMid) + lastMid) / 2, "var(--guadagno)"],
["versato", usd(last.versato), (lastMid + y(0)) / 2, "var(--versato)"]
].forEach(([nome, val, yy, col]) => {
const a = el("text", { x: lx, y: Math.max(20, yy - 2), class: "axis", fill: col, "font-size": 11 });
a.textContent = nome; svg.appendChild(a);
const b = el("text", { x: lx, y: Math.max(34, yy + 12), class: "axis", fill: "var(--ink)", "font-size": 11.5 });
b.textContent = val; svg.appendChild(b);
});
if (cx0) {
const ci = cx0 - 1, cRow = rows[ci];
const ccx = M.l + step * ci + step / 2, cy = y(cRow.med);
svg.appendChild(el("line", { x1: ccx, x2: ccx, y1: cy - 6, y2: cy - 26, stroke: "var(--guadagno)", "stroke-width": 1.5 }));
const ct = el("text", { x: ccx, y: cy - 32, "text-anchor": "middle", class: "axis",
fill: "var(--guadagno)", "font-size": 11 });
ct.textContent = "sorpasso"; svg.appendChild(ct);
}
const ax = el("text", { x: M.l + pw + 14, y: H - 14, class: "axis", fill: "var(--ink-3)", "font-size": 10.5 });
ax.textContent = "anno"; svg.appendChild(ax);
$("ct").innerHTML = "Capitale per anno <span>— " + eur(state.lump) + " iniziali + " +
eur(state.dep) + "/mese · " + state.anni + " anni · " +
(state.netto ? "col fisco d'accumulo" : "senza imposte") + "</span>";
$("figcap").textContent =
"Scala verticale dai dati, sempre estesa almeno fino al bersaglio. " +
PATHS_MAIN.toLocaleString("it-IT") + " percorsi ricampionati a blocchi di 20 giorni; " +
"il guadagno disegnato è quello del percorso mediano, non di un percorso singolo.";
}
function renderTable() {
const body = cur.map((r, i) => {
const a = curAlt[i];
return "<tr><td>" + r.anno + "</td><td>" + usd(r.versato) + "</td><td>" + usd(r.guadagno) +
"</td><td>" + usd(r.med) + "</td><td>" + usdK(r.p25) + "</td><td>" + usdK(r.p75) +
"</td><td>" + pct(r.phit) + "</td><td>" + usd(a.med) + "</td></tr>";
}).join("");
$("tbl").innerHTML = "<thead><tr><th>anno</th><th>versato</th><th>guadagno</th>" +
"<th>capitale mediano</th><th>p25</th><th>p75</th><th>P(bersaglio)</th><th>" +
(state.netto ? "senza imposte" : "col fisco") + "</th></tr></thead><tbody>" + body + "</tbody>";
}
function renderFoot() {
const L = lastRow();
$("foot").innerHTML =
"Serie: <b>" + CFG.n.toLocaleString("it-IT") + "</b> giorni dal <b>" + CFG.start +
"</b> al <b>" + CFG.end + "</b> · cambio <b>EUR/USD " + CFG.eurusd +
"</b> · conto di partenza <b>$" + CFG.start_usd + "</b> (quello vero, oggi) · " +
"blocchi da <b>" + CFG.block + "</b> giorni · <b>" + PATHS_MAIN.toLocaleString("it-IT") +
"</b> percorsi per la curva, <b>" + PATHS_SOLVE.toLocaleString("it-IT") +
"</b> per il risolutore — il versamento suggerito porta <b>~±1%</b> di incertezza " +
"(bisezione + Monte Carlo), quindi si legge come «circa», non come una cifra esatta. " +
"Il risolutore e' stato confrontato con <b>dep_necessario</b> di r0727_tasse.py: scarto " +
"0,4% / 0,6% / 1,1% su tre configurazioni di riferimento. " +
"Con questo piano si versano <b>" + usd(L.versato - CFG.start_usd - state.lump * CFG.eurusd) +
"</b> di bonifici oltre al capitale iniziale.";
}
recompute();
})();
</script>