ed2532b991
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>
603 lines
31 KiB
HTML
603 lines
31 KiB
HTML
<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>
|