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:
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python
|
||||
"""build.py — assembla le pagine pubblicate a partire da shell + dati prodotti dagli script.
|
||||
|
||||
Le pagine sono deliverable, non sorgenti di verita': ogni numero che mostrano viene da uno script
|
||||
Python committato, e qui viene INIETTATO, mai trascritto a mano. La regola nasce da un errore di
|
||||
questa sessione — due anni di dati di un grafico scritti a memoria perche' l'output del terminale
|
||||
era troncato dal `tail` — e il modo di non ripeterlo e' non avere il passaggio manuale.
|
||||
|
||||
# 1. produrre i dati
|
||||
uv run python scripts/research/r0807_growth_yearly.py --dep 800 --json /tmp/growth800.json
|
||||
uv run python scripts/research/r0807_asset_compare.py --json /tmp/compare.json
|
||||
uv run python scripts/research/r0807_best_strategy.py --anni 12 --json /tmp/best.json
|
||||
|
||||
# 2. assemblare
|
||||
uv run python scripts/web/build.py --out /tmp/pagine \\
|
||||
--dati simulatore=/tmp/series.json confronto=/tmp/compare.json scelta=/tmp/best.json
|
||||
|
||||
# 3. verificare che le pagine ESEGUANO (node --check valida solo la sintassi)
|
||||
node scripts/web/smoke.js /tmp/pagine/*.html
|
||||
|
||||
`pagina_crescita.html` ha i dati gia' dentro (due configurazioni di versamento a confronto) e non
|
||||
richiede iniezione: se manca il segnaposto, il file viene copiato tale e quale.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
QUI = Path(__file__).resolve().parent
|
||||
SEGNAPOSTO = "/*__DATA__*/"
|
||||
MOTORE = "/*__ENGINE__*/"
|
||||
SERIE = "/*__SERIES__*/"
|
||||
|
||||
|
||||
def costruisci(shell: Path, dati: Path | None, out: Path) -> str:
|
||||
testo = shell.read_text()
|
||||
note = []
|
||||
|
||||
if MOTORE in testo: # la pagina interattiva porta dentro il motore
|
||||
eng = (QUI / "engine.js").read_text()
|
||||
eng = eng.split('if (typeof module !== "undefined")')[0].rstrip()
|
||||
testo = testo.replace(MOTORE, eng)
|
||||
note.append("motore")
|
||||
|
||||
for tag in (SERIE, SEGNAPOSTO):
|
||||
if tag in testo:
|
||||
if dati is None:
|
||||
raise SystemExit(f"{shell.name}: serve --dati, il file contiene {tag}")
|
||||
testo = testo.replace(tag, dati.read_text().strip())
|
||||
note.append(dati.name)
|
||||
|
||||
out.write_text(testo)
|
||||
return ", ".join(note) if note else "nessuna iniezione (gia' completa)"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--out", required=True, help="cartella di destinazione")
|
||||
ap.add_argument("--dati", nargs="*", default=[], metavar="nome=percorso.json")
|
||||
a = ap.parse_args()
|
||||
|
||||
mappa = {}
|
||||
for voce in a.dati:
|
||||
nome, _, perc = voce.partition("=")
|
||||
p = Path(perc)
|
||||
if not p.exists():
|
||||
raise SystemExit(f"dati mancanti per «{nome}»: {p}")
|
||||
mappa[nome] = p
|
||||
|
||||
dest = Path(a.out)
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
for shell in sorted(QUI.glob("pagina_*.html")):
|
||||
nome = shell.stem.replace("pagina_", "")
|
||||
out = dest / f"{nome}.html"
|
||||
try:
|
||||
esito = costruisci(shell, mappa.get(nome), out)
|
||||
except SystemExit as e:
|
||||
print(f" {nome:12} SALTATA — {e}")
|
||||
continue
|
||||
print(f" {nome:12} -> {out} ({esito}, {out.stat().st_size/1024:.0f} KB)")
|
||||
|
||||
for extra in ("engine.js", "smoke.js", "test_engine.js"):
|
||||
shutil.copy(QUI / extra, dest / extra)
|
||||
print(f"\n verifica: node {dest}/smoke.js {dest}/*.html")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,120 @@
|
||||
/* engine.js — il motore di accumulo, ri-implementato per il browser.
|
||||
*
|
||||
* Deve dare LA STESSA RISPOSTA di scripts/research/r0807_growth_yearly.py: stessa costruzione
|
||||
* del block bootstrap, stessa cadenza dei versamenti (ogni 30 giorni), stessa contabilita'
|
||||
* fiscale (imposta annua sulla variazione di valore al netto dei versamenti, minusvalenze in
|
||||
* carry 4 anni, patrimoniale sul valore). Il seme non e' confrontabile fra i due linguaggi:
|
||||
* l'accordo si verifica sui RISULTATI, entro il rumore Monte Carlo (test_engine.js).
|
||||
*/
|
||||
|
||||
function makeEngine(CFG) {
|
||||
const R = CFG.r, NR = R.length, BLOCK = CFG.block, CARRY = CFG.carry;
|
||||
|
||||
/* PRNG seminato (mulberry32): serve la riproducibilita', non la qualita' crittografica */
|
||||
function rng(seed) {
|
||||
let a = seed >>> 0;
|
||||
return function () {
|
||||
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/* Un accumulo completo. Ritorna, per ogni anno, la distribuzione del capitale fra i path.
|
||||
* opts: { lumpEur, depEur, years, tax, patrimoniale, paths, seed } */
|
||||
function run(o) {
|
||||
const nDays = o.years * 365;
|
||||
const rand = rng(o.seed || 807);
|
||||
const dep = o.depEur * CFG.eurusd;
|
||||
const cap0 = CFG.start_usd + o.lumpEur * CFG.eurusd;
|
||||
const nPaths = o.paths;
|
||||
|
||||
/* capitale a fine di ogni anno, per path */
|
||||
const snap = [];
|
||||
for (let y = 0; y < o.years; y++) snap.push(new Float64Array(nPaths));
|
||||
const hitBy = new Int32Array(o.years); /* quanti path hanno toccato il bersaglio entro l'anno y */
|
||||
const versatoY = new Float64Array(o.years); /* deterministico, ma CONTATO come in Python */
|
||||
const carry = new Float64Array(CARRY);
|
||||
|
||||
for (let p = 0; p < nPaths; p++) {
|
||||
let cap = cap0, annoStart = cap0, versatoAnno = 0, hitYear = -1, versato = cap0;
|
||||
carry.fill(0);
|
||||
let blockPos = BLOCK, base = 0;
|
||||
|
||||
for (let t = 0; t < nDays; t++) {
|
||||
if (blockPos === BLOCK) { /* nuovo blocco: stessa costruzione di numpy */
|
||||
base = Math.floor(rand() * (NR - BLOCK));
|
||||
blockPos = 0;
|
||||
}
|
||||
cap *= 1 + R[base + blockPos];
|
||||
blockPos++;
|
||||
|
||||
if (hitYear < 0 && cap >= CFG.target) hitYear = Math.floor(t / 365);
|
||||
|
||||
if (t % 30 === 0 && t > 0 && dep > 0) { cap += dep; versato += dep; versatoAnno += dep; }
|
||||
|
||||
if ((t + 1) % 365 === 0) {
|
||||
const plus = cap - annoStart - versatoAnno;
|
||||
if (o.tax > 0) {
|
||||
let disponibili = 0;
|
||||
for (let k = 0; k < CARRY; k++) disponibili += carry[k];
|
||||
const positivo = plus > 0 ? plus : 0;
|
||||
const usate = Math.min(positivo, disponibili);
|
||||
let resid = usate;
|
||||
for (let k = CARRY - 1; k >= 0; k--) { /* dalla piu' VECCHIA, che scade prima */
|
||||
const presa = Math.min(carry[k], resid);
|
||||
carry[k] -= presa; resid -= presa;
|
||||
}
|
||||
const dovuta = (positivo - usate) * o.tax;
|
||||
cap -= Math.min(dovuta, cap);
|
||||
for (let k = CARRY - 1; k > 0; k--) carry[k] = carry[k - 1]; /* invecchia */
|
||||
carry[0] = plus < 0 ? -plus : 0;
|
||||
}
|
||||
if (o.patrimoniale > 0) cap -= Math.min(cap * o.patrimoniale, cap);
|
||||
annoStart = cap; versatoAnno = 0;
|
||||
const yi = (t + 1) / 365 - 1;
|
||||
snap[yi][p] = cap;
|
||||
if (p === 0) versatoY[yi] = versato;
|
||||
}
|
||||
}
|
||||
if (hitYear >= 0) for (let y = hitYear; y < o.years; y++) hitBy[y]++;
|
||||
}
|
||||
|
||||
const out = [];
|
||||
for (let y = 1; y <= o.years; y++) {
|
||||
const col = snap[y - 1].slice().sort();
|
||||
const med = quantile(col, 0.5);
|
||||
out.push({ anno: y, versato: versatoY[y - 1], guadagno: med - versatoY[y - 1],
|
||||
p25: quantile(col, 0.25), med: med, p75: quantile(col, 0.75),
|
||||
phit: hitBy[y - 1] / nPaths });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* percentile con interpolazione lineare, come np.percentile (default 'linear') */
|
||||
function quantile(sorted, f) {
|
||||
const n = sorted.length;
|
||||
if (n === 0) return 0;
|
||||
const pos = f * (n - 1), lo = Math.floor(pos), hi = Math.ceil(pos);
|
||||
return lo === hi ? sorted[lo] : sorted[lo] + (pos - lo) * (sorted[hi] - sorted[lo]);
|
||||
}
|
||||
|
||||
/* Il versamento mensile che porta P(bersaglio entro `years`) alla confidenza voluta.
|
||||
* Bisezione, come dep_necessario() in r0727_tasse.py. Ritorna null se non basta nemmeno
|
||||
* il tetto: cosi' l'interfaccia dice "fuori portata" invece di stampare un numero enorme. */
|
||||
function solveDeposit(o, pTarget, iters) {
|
||||
let lo = 0, hi = 20000;
|
||||
const P = d => run(Object.assign({}, o, { depEur: d })).slice(-1)[0].phit;
|
||||
if (P(hi) < pTarget) return null;
|
||||
for (let i = 0; i < (iters || 13); i++) {
|
||||
const mid = 0.5 * (lo + hi);
|
||||
if (P(mid) >= pTarget) hi = mid; else lo = mid;
|
||||
}
|
||||
return 0.5 * (lo + hi);
|
||||
}
|
||||
|
||||
return { run, solveDeposit, quantile };
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined") module.exports = { makeEngine };
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python
|
||||
"""export_series.py — esporta la serie del book live per il motore nel browser.
|
||||
|
||||
La pagina interattiva (`pagina_simulatore.html`) simula nel browser, quindi le serve la sequenza
|
||||
dei ritorni giornalieri VERI del book: gli stessi che usa `r0807_growth_yearly.py`, cioe' TP01 75%
|
||||
+ SKH01 25% alle ancore canoniche, gia' de-luckati x0.89 sul drift da `r0726_venue_risk`.
|
||||
|
||||
Esporta anche le costanti che il motore deve condividere col Python (bersaglio, cambio, aliquote,
|
||||
lunghezza dei blocchi): se una di queste vivesse in due posti, prima o poi divergerebbe.
|
||||
|
||||
uv run python scripts/web/export_series.py --out /tmp/series.json
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
|
||||
import r0725_capcurve as CC # noqa: E402
|
||||
import r0726_venue_risk as VR # noqa: E402
|
||||
import r0727_lumpsum_split as LS # noqa: E402
|
||||
import r0727_tasse as TX # noqa: E402
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--out", required=True)
|
||||
a = ap.parse_args()
|
||||
|
||||
s = VR.venue_series()["Deribit"]
|
||||
r = s.values.astype(float)
|
||||
|
||||
dati = dict(
|
||||
n=len(r), start=str(s.index[0].date()), end=str(s.index[-1].date()),
|
||||
start_usd=LS.START, target=LS.TARGET, eurusd=CC.EURUSD, deluck=VR.DELUCK,
|
||||
tax=0.33, patrimoniale=TX.PATRIMONIALE, carry=TX.CARRY_ANNI, block=20,
|
||||
r=[round(x, 8) for x in r],
|
||||
)
|
||||
Path(a.out).write_text(json.dumps(dati))
|
||||
print(f" {len(r)} giorni [{dati['start']} -> {dati['end']}] "
|
||||
f"drift {r.mean()*365:.1%} vol {r.std()*365**0.5:.1%}")
|
||||
print(f" bersaglio ${LS.TARGET:,.0f} · de-luck ×{VR.DELUCK} · "
|
||||
f"scritto {a.out} ({Path(a.out).stat().st_size/1024:.0f} KB)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,489 @@
|
||||
<title>Da dove viene il guadagno — e se invece fosse un ETF</title>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--ground: #f7f7f5; --surface: #fdfdfc; --sunken: #f1f1ee;
|
||||
--rule: #dededa; --rule-soft: #ebebe7;
|
||||
--ink: #16171a; --ink-2: #55575c; --ink-3: #83858b;
|
||||
--s1: #2a78d6; --s2: #eb6834; --s3: #1baf7a; --grid: #e6e6e2;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
color-scheme: dark;
|
||||
--ground: #17181a; --surface: #1d1e21; --sunken: #232427;
|
||||
--rule: #34363a; --rule-soft: #26282b;
|
||||
--ink: #f1f1ee; --ink-2: #a8aab0; --ink-3: #7c7e85;
|
||||
--s1: #3987e5; --s2: #d95926; --s3: #199e70; --grid: #2b2d31;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--ground: #17181a; --surface: #1d1e21; --sunken: #232427;
|
||||
--rule: #34363a; --rule-soft: #26282b;
|
||||
--ink: #f1f1ee; --ink-2: #a8aab0; --ink-3: #7c7e85;
|
||||
--s1: #3987e5; --s2: #d95926; --s3: #199e70; --grid: #2b2d31;
|
||||
}
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
background: var(--ground); color: var(--ink);
|
||||
font-family: Georgia, "Iowan Old Style", "Times New Roman", serif;
|
||||
font-size: 17px; line-height: 1.62; margin: 0; padding: 0 24px 96px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.wrap { max-width: 1080px; margin: 0 auto; display: flex; flex-direction: column; gap: 44px; }
|
||||
.prose { max-width: 68ch; display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.mono, .eyebrow, .lede-meta, .legend, .axis, table, .step, .dotrow {
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
header { padding-top: 60px; display: flex; flex-direction: column; gap: 18px; }
|
||||
.eyebrow { font-size: 11.5px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--ink-3); margin: 0; }
|
||||
h1 { font-size: clamp(29px, 4.2vw, 44px); line-height: 1.12; margin: 0; font-weight: 400;
|
||||
letter-spacing: -0.015em; text-wrap: balance; max-width: 22ch; }
|
||||
.lede { font-size: 19px; color: var(--ink-2); margin: 0; max-width: 62ch; }
|
||||
.lede b { color: var(--ink); font-weight: 700; }
|
||||
.lede-meta { font-size: 12.5px; color: var(--ink-3); line-height: 1.9; margin: 0;
|
||||
padding-top: 14px; border-top: 1px solid var(--rule); }
|
||||
.lede-meta b { color: var(--ink-2); font-weight: 400; }
|
||||
|
||||
h2 { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px;
|
||||
letter-spacing: 0.15em; text-transform: uppercase; color: var(--ink-2); font-weight: 500;
|
||||
margin: 0 0 4px; padding-bottom: 10px; border-bottom: 1px solid var(--rule); }
|
||||
p { margin: 0; }
|
||||
em { font-style: italic; color: var(--ink-2); }
|
||||
strong { font-weight: 700; }
|
||||
|
||||
/* passi del calcolo */
|
||||
.steps { display: flex; flex-direction: column; gap: 1px; background: var(--rule);
|
||||
border: 1px solid var(--rule); border-radius: 3px; overflow: hidden; }
|
||||
.step { background: var(--surface); padding: 15px 18px; display: grid;
|
||||
grid-template-columns: 28px 1fr; gap: 14px; align-items: baseline; font-size: 13.5px;
|
||||
color: var(--ink-2); line-height: 1.65; }
|
||||
.step .n { color: var(--ink-3); font-size: 11px; letter-spacing: 0.1em; }
|
||||
.step b { color: var(--ink); font-weight: 400; }
|
||||
.step code { background: var(--sunken); padding: 1px 5px; border-radius: 2px; font-size: 12.5px; color: var(--ink); }
|
||||
|
||||
figure { margin: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.chart-title { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px;
|
||||
letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink); margin: 0; }
|
||||
.chart-title span { color: var(--ink-3); text-transform: none; letter-spacing: 0; }
|
||||
.chart-box { background: var(--surface); border: 1px solid var(--rule); border-radius: 3px;
|
||||
padding: 18px 16px 10px; overflow-x: auto; }
|
||||
.chart-box svg { display: block; width: 100%; min-width: 620px; height: auto; }
|
||||
.legend { display: flex; gap: 22px; flex-wrap: wrap; font-size: 12px; color: var(--ink-2); align-items: center; }
|
||||
.legend i { width: 20px; height: 3px; border-radius: 2px; display: inline-block; margin-right: 8px; vertical-align: 3px; }
|
||||
.legend .dash { background: none; border-top: 2px dashed var(--ink-3); height: 0; }
|
||||
.figcap { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
|
||||
color: var(--ink-3); line-height: 1.7; max-width: 80ch; }
|
||||
|
||||
table.data { border-collapse: collapse; font-size: 12.5px; width: 100%; min-width: 560px; }
|
||||
table.data th, table.data td { padding: 8px 14px 8px 0; text-align: right; white-space: nowrap; }
|
||||
table.data th:first-child, table.data td:first-child { text-align: left; }
|
||||
table.data thead th { color: var(--ink-3); font-weight: 400; border-bottom: 1px solid var(--rule);
|
||||
font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; }
|
||||
table.data tbody td { border-bottom: 1px solid var(--rule-soft); color: var(--ink-2); }
|
||||
table.data tbody td:first-child { color: var(--ink); }
|
||||
table.data td .sw { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 8px; }
|
||||
.tablewrap { overflow-x: auto; border: 1px solid var(--rule); border-radius: 3px; background: var(--surface); padding: 14px 16px; }
|
||||
caption { caption-side: top; text-align: left; font-size: 12px; color: var(--ink-3); padding-bottom: 12px; }
|
||||
|
||||
/* banda finale */
|
||||
.dots { border: 1px solid var(--rule); border-radius: 3px; background: var(--surface); padding: 18px 18px 8px; }
|
||||
.dotrow { display: grid; grid-template-columns: 90px 1fr; gap: 14px; align-items: center; margin-bottom: 14px; font-size: 12px; color: var(--ink-2); }
|
||||
|
||||
details { border-top: 1px solid var(--rule); padding-top: 14px; }
|
||||
summary { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
|
||||
letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink-2); cursor: pointer; padding: 4px 0; }
|
||||
summary:focus-visible { outline: 2px solid var(--s1); outline-offset: 3px; }
|
||||
|
||||
#tip { position: fixed; z-index: 40; pointer-events: none; opacity: 0; transition: opacity .1s ease;
|
||||
background: var(--surface); border: 1px solid var(--rule); border-radius: 3px; padding: 10px 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; line-height: 1.75;
|
||||
color: var(--ink-2); box-shadow: 0 6px 22px rgba(0,0,0,.14); font-variant-numeric: tabular-nums; max-width: 280px; }
|
||||
#tip .t { color: var(--ink); letter-spacing: 0.08em; text-transform: uppercase; font-size: 10.5px; display: block; margin-bottom: 5px; }
|
||||
#tip .r { display: flex; justify-content: space-between; gap: 18px; }
|
||||
#tip .r b { font-weight: 400; color: var(--ink); }
|
||||
#tip .sw { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 6px; }
|
||||
|
||||
.caveat { border-left: 2px solid var(--s2); padding: 2px 0 2px 18px; color: var(--ink-2);
|
||||
font-size: 16px; max-width: 64ch; }
|
||||
.caveat b { color: var(--ink); font-weight: 700; }
|
||||
|
||||
footer { border-top: 1px solid var(--rule); padding-top: 18px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px;
|
||||
color: var(--ink-3); line-height: 1.85; max-width: 80ch; }
|
||||
footer b { color: var(--ink-2); font-weight: 400; }
|
||||
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
|
||||
</style>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<header>
|
||||
<p class="eyebrow">PythagorasGoal · 2026-08-07</p>
|
||||
<h1>Da dove viene il guadagno, e se invece fosse un ETF</h1>
|
||||
<p class="lede">
|
||||
Due domande. La prima: <b>quel guadagno come si calcola?</b> Non è un tasso ipotizzato — è
|
||||
quello che producono i ritorni veri del book, ricampionati. La seconda: <b>e se gli stessi
|
||||
soldi andassero su un S&P 500 o un MSCI World?</b>
|
||||
</p>
|
||||
<p class="lede-meta" id="meta"></p>
|
||||
</header>
|
||||
|
||||
<section class="prose">
|
||||
<h2>1 · Come si calcola il guadagno</h2>
|
||||
<p>
|
||||
Da nessuna parte c'è un «rendimento atteso» scritto a mano. C'è una sequenza di ritorni
|
||||
giornalieri realmente prodotti dal book live, e la si rimescola.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="steps" id="steps"></div>
|
||||
|
||||
<section class="prose">
|
||||
<p>
|
||||
Il rendimento non è un'ipotesi, ma <em>la scelta della sequenza sì</em>: rimescolare gli
|
||||
ultimi 7 anni assume che il futuro somigli a quel campione. È l'assunzione più forte di tutto
|
||||
il modello, ed è esattamente ciò che la seconda domanda mette alla prova.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="prose">
|
||||
<h2>2 · I tre motori</h2>
|
||||
<p>
|
||||
Stesso piano, stessa macchina, tre sorgenti di ritorni. Le azioni vanno su griglia di
|
||||
calendario con <em>0,0 nei giorni di borsa chiusa</em> — la convenzione già usata per lo
|
||||
sleeve azionario: il capitale è fermo, non riciclato. Senza quella correzione una serie di
|
||||
borsa passata a un motore che annualizza a 365 esce con Sharpe ×1,20.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="tablewrap"><table class="data" id="t-serie"></table></div>
|
||||
|
||||
<section class="prose">
|
||||
<p class="caveat">
|
||||
La riga che conta non è la prima, è la seconda: <b>sulla stessa finestra</b> il book e
|
||||
l'S&P 500 hanno quasi lo stesso rendimento — <span id="x-drift"></span>. Tutta la
|
||||
differenza è nel <b>rischio</b>: volatilità <span id="x-vol"></span> e drawdown massimo
|
||||
<span id="x-dd"></span>. Il book non guadagna di più: perde di meno.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<figure>
|
||||
<p class="chart-title">Capitale mediano per anno <span>— ciascuno ricampionato dalla PROPRIA storia piena</span></p>
|
||||
<div class="legend" id="leg1"></div>
|
||||
<div class="chart-box"><svg id="c1" role="img" aria-label="Curve del capitale mediano per anno, storia piena"></svg></div>
|
||||
<figcaption class="figcap" id="cap1"></figcaption>
|
||||
</figure>
|
||||
|
||||
<figure>
|
||||
<p class="chart-title">Capitale mediano per anno <span>— tutti e tre ricampionati dalla STESSA finestra</span></p>
|
||||
<div class="chart-box"><svg id="c2" role="img" aria-label="Curve del capitale mediano per anno, finestra comune"></svg></div>
|
||||
<figcaption class="figcap" id="cap2"></figcaption>
|
||||
</figure>
|
||||
|
||||
<section class="prose">
|
||||
<h2>3 · Il bersaglio non è lo stesso per tutti</h2>
|
||||
<p>
|
||||
$272.061 è il capitale che serve <em>con la rendita perpetua del book e con l'aliquota del
|
||||
33%</em>. Un ETF ha un'altra distribuzione e un'altra aliquota, quindi ha un altro bersaglio:
|
||||
la rendita sostenibile dipende dal drawdown, non dal rendimento medio. Ricalcolato per
|
||||
ciascuno, con lo stesso metodo.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="tablewrap"><table class="data" id="t-berg"></table></div>
|
||||
|
||||
<section class="prose">
|
||||
<h2>4 · La banda, non il numero</h2>
|
||||
<p>
|
||||
Le curve sopra sono mediane. Ecco dove sta in realtà il capitale finale: la barra è
|
||||
l'intervallo p25–p75, il segno è la mediana, la linea tratteggiata è il bersaglio di
|
||||
quel motore.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="dots" id="dots"></div>
|
||||
|
||||
<section class="prose">
|
||||
<h2>5 · La lettura onesta</h2>
|
||||
<p class="caveat" id="lettura"></p>
|
||||
<p>
|
||||
Il fisco è modellato diverso perché <em>è</em> diverso: il book realizza in continuo e paga
|
||||
ogni anno il 33%; un ETF UCITS ad accumulazione non distribuisce nulla e paga il 26% alla
|
||||
vendita. Il differimento è un vantaggio strutturale dell'ETF, ed è dentro i numeri qui sopra
|
||||
(le curve degli ETF sono valori di <em>liquidazione</em>, già al netto dell'imposta latente).
|
||||
Sono assunzioni dichiarate e <em>non sono un parere fiscale</em>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<details>
|
||||
<summary>Tabella completa — capitale mediano anno per anno, entrambe le lenti</summary>
|
||||
<div class="tablewrap" style="margin-top:14px"><table class="data" id="t-full"></table></div>
|
||||
</details>
|
||||
|
||||
<footer id="foot"></footer>
|
||||
</div>
|
||||
|
||||
<div id="tip" role="status" aria-live="polite"></div>
|
||||
|
||||
<script id="cmp-data" type="application/json">/*__DATA__*/</script>
|
||||
<script>
|
||||
(function () {
|
||||
"use strict";
|
||||
const D = JSON.parse(document.getElementById("cmp-data").textContent);
|
||||
const $ = id => document.getElementById(id);
|
||||
const MOT = ["BOOK", "SP500", "WORLD"];
|
||||
const NOME = { BOOK: "book live (cripto)", SP500: "S&P 500 (SPY)", WORLD: "MSCI World (proxy)" };
|
||||
const COL = { BOOK: "var(--s1)", SP500: "var(--s2)", WORLD: "var(--s3)" };
|
||||
|
||||
const usd = n => "$" + Math.round(n).toLocaleString("it-IT");
|
||||
const usdK = n => n === 0 ? "$0" : (Math.abs(n) >= 1e6
|
||||
? "$" + (n / 1e6).toFixed(n >= 1e7 ? 0 : 1).replace(".", ",") + "M"
|
||||
: "$" + Math.round(n / 1000) + "k");
|
||||
const pc = (x, d) => (100 * x).toFixed(d === undefined ? 1 : d).replace(".", ",") + "%";
|
||||
|
||||
/* ---------- intestazione ---------- */
|
||||
const P = D.piano;
|
||||
$("meta").innerHTML =
|
||||
"piano: <b>$" + P.start + "</b> + <b>€" + P.lump.toLocaleString("it-IT") + "</b> + <b>€" +
|
||||
P.dep.toLocaleString("it-IT") + "/mese</b> per <b>" + P.anni + " anni</b> · " +
|
||||
P.n_paths.toLocaleString("it-IT") + " percorsi, blocchi da 20 giorni · " +
|
||||
"fisco: cripto <b>" + pc(P.tax_crypto, 0) + " annuo</b>, ETF <b>" + pc(P.tax_etf, 0) +
|
||||
" differito alla vendita</b>, patrimoniale <b>" + pc(P.patrim, 1) + "</b> · " +
|
||||
"commissione d'acquisto ETF <b>€" + P.comm_eur + "/mese</b> · MSCI World = " + P.world;
|
||||
|
||||
/* ---------- passi ---------- */
|
||||
const mB = D.metriche.BOOK;
|
||||
const passi = [
|
||||
["01", "Si parte dai <b>ritorni giornalieri veri</b> del book live (TP01 75% + SKH01 25%): " +
|
||||
mB.n.toLocaleString("it-IT") + " giorni dal " + mB.da + " al " + mB.a + ". Non sono simulati: " +
|
||||
"sono quelli che la strategia ha prodotto, già al netto delle commissioni."],
|
||||
["02", "Si toglie la <b>fortuna d'ancora</b>: <code>r − (1−0,89)·media(r)</code>. Il book è " +
|
||||
"stato misurato al 97º percentile delle sue possibili ancore di calendario, e il fattore " +
|
||||
"0,89 è la correzione misurata, non stimata a occhio."],
|
||||
["03", "Si <b>rimescolano a blocchi di 20 giorni</b>: si pescano a caso spezzoni di 20 giorni " +
|
||||
"consecutivi e li si incolla fino a coprire gli anni richiesti. I blocchi servono a non " +
|
||||
"distruggere il raggruppamento della volatilità — un rimescolamento giorno per giorno " +
|
||||
"renderebbe il percorso troppo docile e la sopravvivenza troppo alta."],
|
||||
["04", "Si <b>compone</b>: <code>capitale ×= (1 + r)</code> ogni giorno, più il bonifico ogni " +
|
||||
"30 giorni, meno l'imposta a fine anno. Ripetuto su " + P.n_paths.toLocaleString("it-IT") +
|
||||
" percorsi indipendenti."],
|
||||
["05", "Il <b>guadagno</b> di un anno è <code>mediana(capitale) − versato</code>. Il versato è " +
|
||||
"esatto (i bonifici sono decisi); il resto è quello che ha prodotto il ricampionamento. " +
|
||||
"Drift implicito del book: <b>" + pc(mB.drift) + " annuo</b> con volatilità <b>" +
|
||||
pc(mB.vol) + "</b> e drawdown massimo <b>" + pc(Math.abs(mB.maxdd)) + "</b>."]
|
||||
];
|
||||
$("steps").innerHTML = passi.map(([n, t]) =>
|
||||
'<div class="step"><span class="n">' + n + "</span><span>" + t + "</span></div>").join("");
|
||||
|
||||
/* ---------- tabella serie ---------- */
|
||||
const F = D.finestra_comune;
|
||||
$("t-serie").innerHTML =
|
||||
"<caption>Le prime tre righe usano la storia piena di ciascuno; le seconde tre la sola " +
|
||||
"finestra comune <b>" + F.da + " → " + F.a + "</b>. Drift e volatilità sono annualizzati a 365 " +
|
||||
"giorni su griglia di calendario.</caption>" +
|
||||
"<thead><tr><th>motore</th><th>anni</th><th>da</th><th>rendimento</th><th>volatilità</th>" +
|
||||
"<th>Sharpe</th><th>drawdown max</th></tr></thead><tbody>" +
|
||||
MOT.map(m => { const x = D.metriche[m];
|
||||
return "<tr><td><span class='sw' style='background:" + COL[m] + "'></span>" + NOME[m] +
|
||||
"</td><td>" + x.anni.toFixed(1) + "</td><td>" + x.da + "</td><td>" + pc(x.drift) +
|
||||
"</td><td>" + pc(x.vol) + "</td><td>" + x.sharpe.toFixed(2).replace(".", ",") +
|
||||
"</td><td>" + pc(x.maxdd) + "</td></tr>"; }).join("") +
|
||||
MOT.map(m => { const x = D.metriche_comune[m];
|
||||
return "<tr><td style='color:var(--ink-3)'><span class='sw' style='background:" + COL[m] +
|
||||
";opacity:.5'></span>" + NOME[m] + " · stessa finestra</td><td>" + x.anni.toFixed(1) +
|
||||
"</td><td>" + x.da + "</td><td>" + pc(x.drift) + "</td><td>" + pc(x.vol) + "</td><td>" +
|
||||
x.sharpe.toFixed(2).replace(".", ",") + "</td><td>" + pc(x.maxdd) + "</td></tr>"; }).join("") +
|
||||
"</tbody>";
|
||||
|
||||
const cB = D.metriche_comune.BOOK, cS = D.metriche_comune.SP500;
|
||||
$("x-drift").textContent = pc(cB.drift) + " contro " + pc(cS.drift);
|
||||
$("x-vol").textContent = pc(cB.vol) + " contro " + pc(cS.vol);
|
||||
$("x-dd").textContent = pc(Math.abs(cB.maxdd)) + " contro " + pc(Math.abs(cS.maxdd));
|
||||
|
||||
/* ---------- legenda ---------- */
|
||||
$("leg1").innerHTML = MOT.map(m =>
|
||||
"<span><i style='background:" + COL[m] + "'></i>" + NOME[m] + "</span>").join("") +
|
||||
"<span><i class='dash'></i>capitale-rendita di quel motore</span>";
|
||||
|
||||
/* ---------- grafico a linee ---------- */
|
||||
const NS = "http://www.w3.org/2000/svg";
|
||||
const el = (n, a) => { const e = document.createElementNS(NS, n); for (const k in a) e.setAttribute(k, a[k]); return e; };
|
||||
const tip = $("tip");
|
||||
function moveTip(evt) {
|
||||
const r = tip.getBoundingClientRect();
|
||||
let x = evt.clientX + 16, y = evt.clientY - r.height - 12;
|
||||
if (x + r.width > window.innerWidth - 8) x = evt.clientX - r.width - 16;
|
||||
if (y < 8) y = evt.clientY + 18;
|
||||
tip.style.left = x + "px"; tip.style.top = y + "px";
|
||||
}
|
||||
const hideTip = () => { tip.style.opacity = "0"; };
|
||||
|
||||
function drawLines(svgId, traj, berg, capId, nota) {
|
||||
const svg = $(svgId);
|
||||
const W = 960, H = 400, M = { t: 26, r: 150, b: 40, l: 78 };
|
||||
const pw = W - M.l - M.r, ph = H - M.t - M.b;
|
||||
svg.setAttribute("viewBox", "0 0 " + W + " " + H);
|
||||
svg.innerHTML = "";
|
||||
|
||||
const anni = traj.BOOK.length;
|
||||
const finiti = MOT.map(m => berg[m].cap).filter(c => isFinite(c) && c > 0);
|
||||
const rawMax = Math.max(...MOT.flatMap(m => traj[m].map(r => r.med)), ...finiti);
|
||||
const STEP = [25000, 50000, 100000, 150000, 200000, 250000, 500000].find(s => rawMax / s <= 8) || 1000000;
|
||||
const YMAX = Math.ceil(rawMax / STEP) * STEP;
|
||||
|
||||
const x = a => M.l + (a - 1) / Math.max(1, anni - 1) * pw;
|
||||
const y = v => M.t + ph - (v / YMAX) * ph;
|
||||
|
||||
for (let v = 0; v <= YMAX; v += STEP) {
|
||||
svg.appendChild(el("line", { x1: M.l, x2: M.l + pw, y1: y(v), y2: y(v),
|
||||
stroke: v === 0 ? "var(--ink-3)" : "var(--grid)", "stroke-width": 1 }));
|
||||
const t = el("text", { x: M.l - 12, y: y(v) + 4, "text-anchor": "end", class: "axis",
|
||||
fill: "var(--ink-3)", "font-size": 11 });
|
||||
t.textContent = usdK(v); svg.appendChild(t);
|
||||
}
|
||||
for (let a = 1; a <= anni; a++) {
|
||||
if (anni > 12 && a % 2 === 0 && a !== anni) continue;
|
||||
const t = el("text", { x: x(a), y: H - 16, "text-anchor": "middle", class: "axis",
|
||||
fill: "var(--ink-3)", "font-size": 11 });
|
||||
t.textContent = a; svg.appendChild(t);
|
||||
}
|
||||
const ax = el("text", { x: M.l + pw + 12, y: H - 16, class: "axis", fill: "var(--ink-3)", "font-size": 10.5 });
|
||||
ax.textContent = "anno"; svg.appendChild(ax);
|
||||
|
||||
/* bersagli: tratteggio orizzontale + etichetta a destra */
|
||||
MOT.forEach(m => {
|
||||
const c = berg[m].cap;
|
||||
if (!isFinite(c) || c <= 0 || c > YMAX) return;
|
||||
svg.appendChild(el("line", { x1: M.l, x2: M.l + pw, y1: y(c), y2: y(c), stroke: COL[m],
|
||||
"stroke-width": 1.5, "stroke-dasharray": "4 4", opacity: .55 }));
|
||||
});
|
||||
|
||||
/* linee */
|
||||
MOT.forEach(m => {
|
||||
const pts = traj[m].map(r => x(r.anno) + "," + y(r.med)).join(" ");
|
||||
svg.appendChild(el("polyline", { points: pts, fill: "none", stroke: COL[m],
|
||||
"stroke-width": 2.5, "stroke-linejoin": "round", "stroke-linecap": "round" }));
|
||||
const last = traj[m][traj[m].length - 1];
|
||||
svg.appendChild(el("circle", { cx: x(last.anno), cy: y(last.med), r: 4, fill: COL[m] }));
|
||||
/* etichetta diretta: identita' mai solo colore */
|
||||
const lx = M.l + pw + 12;
|
||||
const a = el("text", { x: lx, y: y(last.med) - 3, class: "axis", fill: COL[m], "font-size": 11 });
|
||||
a.textContent = NOME[m]; svg.appendChild(a);
|
||||
const b = el("text", { x: lx, y: y(last.med) + 12, class: "axis", fill: "var(--ink)", "font-size": 11.5 });
|
||||
b.textContent = usd(last.med); svg.appendChild(b);
|
||||
});
|
||||
|
||||
/* colonne di hover */
|
||||
for (let a = 1; a <= anni; a++) {
|
||||
const g = el("g", { tabindex: "0", "aria-label": "anno " + a });
|
||||
const w = pw / anni;
|
||||
g.appendChild(el("rect", { x: x(a) - w / 2, y: M.t, width: w, height: ph, fill: "transparent" }));
|
||||
MOT.forEach(m => g.appendChild(el("circle", { cx: x(a), cy: y(traj[m][a - 1].med), r: 2.5,
|
||||
fill: COL[m], opacity: .0 })));
|
||||
const mostra = evt => {
|
||||
tip.innerHTML = '<span class="t">anno ' + a + "</span>" +
|
||||
MOT.map(m => { const r = traj[m][a - 1];
|
||||
return '<div class="r"><span><span class="sw" style="background:' + COL[m] + '"></span>' +
|
||||
NOME[m].split(" (")[0] + "</span><b>" + usd(r.med) + "</b></div>" +
|
||||
'<div class="r" style="opacity:.65"><span> banda p25–p75</span><b>' +
|
||||
usdK(r.p25) + " – " + usdK(r.p75) + "</b></div>"; }).join("") +
|
||||
'<div class="r" style="margin-top:5px"><span>versato</span><b>' +
|
||||
usd(traj.BOOK[a - 1].versato) + "</b></div>";
|
||||
tip.style.opacity = "1"; moveTip(evt);
|
||||
};
|
||||
g.addEventListener("pointerenter", mostra);
|
||||
g.addEventListener("pointermove", moveTip);
|
||||
g.addEventListener("pointerleave", hideTip);
|
||||
g.addEventListener("focus", () => mostra({ clientX: x(a) + 60, clientY: 300 }));
|
||||
g.addEventListener("blur", hideTip);
|
||||
svg.appendChild(g);
|
||||
}
|
||||
$(capId).innerHTML = nota;
|
||||
}
|
||||
|
||||
drawLines("c1", D.traiettoria, D.bersaglio, "cap1",
|
||||
"Ogni motore ricampiona la propria storia: il book 7,4 anni, l'S&P 500 trent'anni che " +
|
||||
"contengono il 2000, il 2008 e il 2022. Le linee tratteggiate sono i tre capitali-rendita, " +
|
||||
"diversi fra loro. È la lente che <b>favorisce il book</b>: gli si chiede di battere indici " +
|
||||
"misurati anche nei loro anni peggiori, mai vissuti dalla strategia.");
|
||||
drawLines("c2", D.traiettoria_comune, D.bersaglio_comune, "cap2",
|
||||
"Stessa finestra per tutti (" + F.da + " → " + F.a + "), quindi stesso regime di mercato. " +
|
||||
"Qui l'S&P 500 <b>accumula più del book</b>. I bersagli tratteggiati sono anch'essi " +
|
||||
"ricalcolati su questa finestra — e vanno presi con le molle: una rendita perpetua è una " +
|
||||
"domanda a vent'anni, e sette anni non la risolvono.");
|
||||
|
||||
/* ---------- tabella bersagli ---------- */
|
||||
$("t-berg").innerHTML =
|
||||
"<caption>«Rendita perpetua» = prelievo annuo che mantiene P(capitale a 20 anni ≥ capitale " +
|
||||
"iniziale) ≥ 90%. Il capitale è quello che serve per <b>€50/giorno netti</b> con l'aliquota di " +
|
||||
"quel motore.</caption>" +
|
||||
"<thead><tr><th>motore</th><th>aliquota</th><th>rendita perpetua</th><th>capitale necessario</th>" +
|
||||
"<th>capitale a " + P.anni + " anni</th><th>quota raggiunta</th></tr></thead><tbody>" +
|
||||
MOT.map(m => {
|
||||
const b = D.bersaglio[m], L = D.traiettoria[m][D.traiettoria[m].length - 1];
|
||||
const q = isFinite(b.cap) && b.cap > 0 ? L.med / b.cap : NaN;
|
||||
return "<tr><td><span class='sw' style='background:" + COL[m] + "'></span>" + NOME[m] +
|
||||
"</td><td>" + pc(b.aliquota, 0) + "</td><td>" + pc(b.perp, 2) + "</td><td>" +
|
||||
(isFinite(b.cap) ? usd(b.cap) : "non sostenibile") + "</td><td>" + usd(L.med) + "</td><td>" +
|
||||
(isFinite(q) ? pc(q, 0) : "—") + "</td></tr>"; }).join("") + "</tbody>";
|
||||
|
||||
/* ---------- banda finale ---------- */
|
||||
const allMax = Math.max(...MOT.flatMap(m => [D.traiettoria[m].slice(-1)[0].p75,
|
||||
isFinite(D.bersaglio[m].cap) ? D.bersaglio[m].cap : 0]));
|
||||
$("dots").innerHTML = MOT.map(m => {
|
||||
const L = D.traiettoria[m].slice(-1)[0], b = D.bersaglio[m];
|
||||
const f = v => (100 * v / allMax).toFixed(2) + "%";
|
||||
const berg = isFinite(b.cap) && b.cap <= allMax
|
||||
? '<span style="position:absolute;left:' + f(b.cap) + ';top:-6px;bottom:-6px;border-left:2px dashed var(--ink-3)"></span>' : "";
|
||||
return '<div class="dotrow"><span>' + NOME[m].split(" (")[0] + '</span>' +
|
||||
'<span style="position:relative;display:block;height:22px">' +
|
||||
'<span style="position:absolute;left:' + f(L.p25) + ';width:' + f(L.p75 - L.p25) +
|
||||
';top:7px;height:8px;border-radius:4px;background:' + COL[m] + ';opacity:.32"></span>' +
|
||||
'<span style="position:absolute;left:calc(' + f(L.med) + ' - 1px);top:3px;width:3px;height:16px;background:' + COL[m] + '"></span>' +
|
||||
berg + "</span></div>"; }).join("") +
|
||||
'<p class="figcap" style="margin-top:2px">Barra = intervallo p25–p75 del capitale a ' + P.anni +
|
||||
' anni · segno pieno = mediana · tratteggio = capitale-rendita di quel motore. Scala comune, ' +
|
||||
'da $0 a ' + usdK(allMax) + '.</p>';
|
||||
|
||||
/* ---------- lettura ---------- */
|
||||
const qB = D.traiettoria.BOOK.slice(-1)[0].med / D.bersaglio.BOOK.cap;
|
||||
const qS = D.traiettoria.SP500.slice(-1)[0].med / D.bersaglio.SP500.cap;
|
||||
const qBc = D.traiettoria_comune.BOOK.slice(-1)[0].med / D.bersaglio_comune.BOOK.cap;
|
||||
const qSc = D.traiettoria_comune.SP500.slice(-1)[0].med / D.bersaglio_comune.SP500.cap;
|
||||
$("lettura").innerHTML =
|
||||
"Sulla storia piena di ciascuno il book arriva al <b>" + pc(qB, 0) + "</b> del proprio " +
|
||||
"bersaglio e l'S&P 500 al <b>" + pc(qS, 0) + "</b>. Sulla stessa finestra diventano <b>" +
|
||||
pc(qBc, 0) + "</b> e <b>" + pc(qSc, 0) + "</b>: quasi pari. <b>La differenza fra le due lenti " +
|
||||
"è tutta la domanda</b> — i sette anni del book rappresentano il futuro, o sono una finestra " +
|
||||
"fortunata? L'indice ha attraversato due crolli che la strategia non ha mai vissuto, e il " +
|
||||
"book non ha una storia lunga da opporre: ne ha " + D.metriche.BOOK.anni.toFixed(1) + " anni in tutto.";
|
||||
|
||||
/* ---------- tabella completa ---------- */
|
||||
$("t-full").innerHTML =
|
||||
"<thead><tr><th>anno</th><th>versato</th>" +
|
||||
MOT.map(m => "<th>" + NOME[m].split(" (")[0] + "</th>").join("") +
|
||||
MOT.map(m => "<th>" + NOME[m].split(" (")[0] + " · stessa fin.</th>").join("") +
|
||||
"</tr></thead><tbody>" +
|
||||
D.traiettoria.BOOK.map((r, i) =>
|
||||
"<tr><td>" + r.anno + "</td><td>" + usd(r.versato) + "</td>" +
|
||||
MOT.map(m => "<td>" + usd(D.traiettoria[m][i].med) + "</td>").join("") +
|
||||
MOT.map(m => "<td style='opacity:.7'>" + usd(D.traiettoria_comune[m][i].med) + "</td>").join("") +
|
||||
"</tr>").join("") + "</tbody>";
|
||||
|
||||
$("foot").innerHTML =
|
||||
"Fonti: book live dalle serie d'ancora canoniche del progetto; <b>SPY</b> e <b>EFA</b> da " +
|
||||
"Interactive Brokers, <code>ADJUSTED_LAST</code> (dividendi reinvestiti), certificati. " +
|
||||
"MSCI World è un <b>proxy</b>, non l'indice: gli ETF reali (URTH, ACWI, VT) non sono " +
|
||||
"nell'abbonamento dati del conto, quindi è ricostruito come 70% SPY + 30% EFA — e la quota USA " +
|
||||
"dell'indice vero è passata da ~50% nel 2008 a ~72% oggi, quindi il peso fisso è " +
|
||||
"un'approssimazione (fra 50% e 80% di USA il rendimento si muove di 0,7 punti, il Sharpe di " +
|
||||
"0,05). Il prezzo di un ETF è già al netto del suo TER. Non c'è rischio di cambio nel modello: " +
|
||||
"tutto è in dollari, e un investitore in euro lo corre su entrambi i lati. " +
|
||||
"Script: <b>scripts/research/r0807_asset_compare.py</b>.";
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,531 @@
|
||||
<title>Crescita del capitale: quanto è versamento e quanto è guadagno</title>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--ground: #f7f7f5;
|
||||
--surface: #fdfdfc;
|
||||
--rule: #dededa;
|
||||
--rule-soft: #ebebe7;
|
||||
--ink: #16171a;
|
||||
--ink-2: #55575c;
|
||||
--ink-3: #83858b;
|
||||
--versato: #2a78d6;
|
||||
--guadagno: #eb6834;
|
||||
--grid: #e6e6e2;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
color-scheme: dark;
|
||||
--ground: #17181a;
|
||||
--surface: #1d1e21;
|
||||
--rule: #34363a;
|
||||
--rule-soft: #26282b;
|
||||
--ink: #f1f1ee;
|
||||
--ink-2: #a8aab0;
|
||||
--ink-3: #7c7e85;
|
||||
--versato: #3987e5;
|
||||
--guadagno: #d95926;
|
||||
--grid: #2b2d31;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--ground: #17181a;
|
||||
--surface: #1d1e21;
|
||||
--rule: #34363a;
|
||||
--rule-soft: #26282b;
|
||||
--ink: #f1f1ee;
|
||||
--ink-2: #a8aab0;
|
||||
--ink-3: #7c7e85;
|
||||
--versato: #3987e5;
|
||||
--guadagno: #d95926;
|
||||
--grid: #2b2d31;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
background: var(--ground);
|
||||
color: var(--ink);
|
||||
font-family: Georgia, "Iowan Old Style", "Times New Roman", serif;
|
||||
font-size: 17px;
|
||||
line-height: 1.62;
|
||||
margin: 0;
|
||||
padding: 0 24px 96px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.wrap { max-width: 1080px; margin: 0 auto; display: flex; flex-direction: column; gap: 44px; }
|
||||
.prose { max-width: 68ch; display: flex; flex-direction: column; gap: 18px; }
|
||||
|
||||
.mono, .eyebrow, .lede-meta, .legend, .axis, table, .tile, .switch {
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
header { padding-top: 64px; display: flex; flex-direction: column; gap: 20px; }
|
||||
.eyebrow {
|
||||
font-size: 11.5px; letter-spacing: 0.16em; text-transform: uppercase;
|
||||
color: var(--ink-3); margin: 0;
|
||||
}
|
||||
h1 {
|
||||
font-size: clamp(30px, 4.4vw, 46px); line-height: 1.12; margin: 0;
|
||||
font-weight: 400; letter-spacing: -0.015em; text-wrap: balance; max-width: 20ch;
|
||||
}
|
||||
.lede { font-size: 19px; color: var(--ink-2); margin: 0; max-width: 62ch; }
|
||||
.lede b { color: var(--ink); font-weight: 700; }
|
||||
.lede-meta {
|
||||
font-size: 12.5px; color: var(--ink-3); line-height: 1.9; margin: 0;
|
||||
padding-top: 16px; border-top: 1px solid var(--rule);
|
||||
}
|
||||
.lede-meta b { color: var(--ink-2); font-weight: 400; }
|
||||
|
||||
h2 {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12.5px; letter-spacing: 0.15em; text-transform: uppercase;
|
||||
color: var(--ink-2); font-weight: 500; margin: 0 0 4px;
|
||||
padding-bottom: 10px; border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
p { margin: 0; }
|
||||
strong { font-weight: 700; }
|
||||
em { font-style: italic; color: var(--ink-2); }
|
||||
|
||||
figure { margin: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.figcap {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px; color: var(--ink-3); line-height: 1.7; max-width: 78ch;
|
||||
}
|
||||
.chart-title {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12.5px; letter-spacing: 0.08em; text-transform: uppercase;
|
||||
color: var(--ink); margin: 0;
|
||||
}
|
||||
.chart-title span { color: var(--ink-3); text-transform: none; letter-spacing: 0; }
|
||||
.chart-box {
|
||||
background: var(--surface); border: 1px solid var(--rule); border-radius: 3px;
|
||||
padding: 18px 16px 10px; overflow-x: auto;
|
||||
}
|
||||
.chart-box svg { display: block; width: 100%; min-width: 620px; height: auto; }
|
||||
|
||||
.legend { display: flex; gap: 22px; flex-wrap: wrap; font-size: 12px; color: var(--ink-2); align-items: center; }
|
||||
.legend i { width: 11px; height: 11px; border-radius: 2px; display: inline-block; margin-right: 7px; vertical-align: -1px; }
|
||||
.legend .band { width: 2px; height: 13px; border-radius: 0; background: var(--ink-3); margin-right: 7px; }
|
||||
|
||||
.stack { display: flex; flex-direction: column; gap: 30px; }
|
||||
|
||||
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 1px; background: var(--rule); border: 1px solid var(--rule); border-radius: 3px; overflow: hidden; }
|
||||
.tile { background: var(--surface); padding: 18px 18px 20px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.tile .k { font-size: 11px; letter-spacing: 0.13em; text-transform: uppercase; color: var(--ink-3); }
|
||||
.tile .v { font-size: 27px; letter-spacing: -0.02em; color: var(--ink); line-height: 1.1; }
|
||||
.tile .s { font-size: 12px; color: var(--ink-2); line-height: 1.6; }
|
||||
.tile .v small { font-size: 15px; color: var(--ink-3); letter-spacing: 0; }
|
||||
|
||||
details { border-top: 1px solid var(--rule); padding-top: 14px; }
|
||||
summary {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase;
|
||||
color: var(--ink-2); cursor: pointer; padding: 4px 0;
|
||||
}
|
||||
summary:focus-visible { outline: 2px solid var(--versato); outline-offset: 3px; }
|
||||
.tablewrap { overflow-x: auto; margin-top: 16px; }
|
||||
table { border-collapse: collapse; font-size: 12.5px; width: 100%; min-width: 660px; }
|
||||
th, td { padding: 6px 12px 6px 0; text-align: right; white-space: nowrap; }
|
||||
th:first-child, td:first-child { text-align: left; padding-left: 0; }
|
||||
thead th { color: var(--ink-3); font-weight: 400; border-bottom: 1px solid var(--rule); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; }
|
||||
tbody td { border-bottom: 1px solid var(--rule-soft); color: var(--ink-2); }
|
||||
tbody tr td:first-child { color: var(--ink); }
|
||||
tbody tr.mark td { background: color-mix(in srgb, var(--versato) 7%, transparent); }
|
||||
caption { caption-side: top; text-align: left; font-size: 12px; color: var(--ink-3); padding-bottom: 10px; }
|
||||
|
||||
/* confronto fra piani */
|
||||
.cmp { border: 1px solid var(--rule); border-radius: 3px; overflow: hidden; }
|
||||
.cmp table { min-width: 520px; }
|
||||
.cmp thead th { padding: 12px 14px 10px; background: var(--surface); }
|
||||
.cmp td { padding: 9px 14px; }
|
||||
.cmp tbody tr:last-child td { border-bottom: 0; }
|
||||
.cmp .hi { color: var(--ink); }
|
||||
|
||||
#tip {
|
||||
position: fixed; z-index: 40; pointer-events: none; opacity: 0;
|
||||
transition: opacity .1s ease;
|
||||
background: var(--surface); border: 1px solid var(--rule); border-radius: 3px;
|
||||
padding: 10px 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11.5px; line-height: 1.75; color: var(--ink-2);
|
||||
box-shadow: 0 6px 22px rgba(0,0,0,.14); font-variant-numeric: tabular-nums;
|
||||
max-width: 260px;
|
||||
}
|
||||
#tip .t { color: var(--ink); letter-spacing: 0.08em; text-transform: uppercase; font-size: 10.5px; display: block; margin-bottom: 5px; }
|
||||
#tip .r { display: flex; justify-content: space-between; gap: 18px; }
|
||||
#tip .r b { font-weight: 400; color: var(--ink); }
|
||||
#tip .sw { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 6px; vertical-align: 0; }
|
||||
|
||||
.bargroup { cursor: default; }
|
||||
.bargroup:focus-visible { outline: none; }
|
||||
.bargroup:focus-visible .hit { stroke: var(--ink); stroke-width: 1.5; }
|
||||
|
||||
.caveat {
|
||||
border-left: 2px solid var(--guadagno); padding: 2px 0 2px 18px;
|
||||
color: var(--ink-2); font-size: 16px; max-width: 64ch;
|
||||
}
|
||||
.caveat b { color: var(--ink); font-weight: 700; }
|
||||
|
||||
footer {
|
||||
border-top: 1px solid var(--rule); padding-top: 18px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11.5px; color: var(--ink-3); line-height: 1.85; max-width: 78ch;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
|
||||
</style>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<header>
|
||||
<p class="eyebrow">PythagorasGoal · piano di accumulo · 2026-08-07</p>
|
||||
<h1>Quanto è versamento e quanto è guadagno</h1>
|
||||
<p class="lede">
|
||||
Il piano con <b>€800 al mese</b> — più i €5.000 iniziali sul conto Deribit — anno per anno,
|
||||
con le due componenti del capitale separate: i bonifici, che sono decisi, e il rendimento del
|
||||
book, che è estratto.
|
||||
</p>
|
||||
<p class="lede-meta">
|
||||
book live <b>TP01 + SKH01 75/25</b> alle ancore canoniche, de-luckato <b>×0.89</b> ·
|
||||
block bootstrap <b>4.000 path</b>, blocchi da 20 giorni ·
|
||||
bersaglio <b>$272.061</b> (capitale-rendita per €50/giorno netti) ·
|
||||
<b>scripts/research/r0807_growth_yearly.py --dep 800</b>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="prose">
|
||||
<h2>La forma della curva</h2>
|
||||
<p>
|
||||
Per i primi sette anni il capitale <em>è</em> quasi solo la somma dei bonifici: il book
|
||||
aggiunge poco perché ha poco su cui lavorare. Il sorpasso — l'anno in cui il guadagno
|
||||
cumulato supera tutto il denaro versato — arriva all'<strong id="x-cross-l">8º</strong> anno
|
||||
nel modello lordo e all'<strong id="x-cross-n">11º</strong> col fisco dentro. Da lì la curva
|
||||
cambia natura: smette di essere un piano di risparmio e diventa un capitale che lavora.
|
||||
</p>
|
||||
<p>
|
||||
Versare di più <em>non</em> anticipa il sorpasso — lo ritarda, perché alza l'asticella che il
|
||||
guadagno deve superare. A €500/mese cadeva al 7º anno; qui all'8º. Quello che i €300 in più
|
||||
comprano è il <strong>traguardo</strong>, non il momento in cui la strategia prende il
|
||||
sopravvento: la mediana tocca il bersaglio al <strong id="x-hit-n">12º</strong> anno col
|
||||
fisco, contro il 15º a €500.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="tiles" id="tiles"></div>
|
||||
|
||||
<section class="stack">
|
||||
<figure>
|
||||
<p class="chart-title">Modello lordo <span>— nessuna imposta durante l'accumulo</span></p>
|
||||
<div class="legend" aria-hidden="true">
|
||||
<span><i style="background:var(--versato)"></i>versato (deterministico)</span>
|
||||
<span><i style="background:var(--guadagno)"></i>guadagno del percorso mediano</span>
|
||||
<span><i class="band"></i>banda p25–p75 del capitale</span>
|
||||
</div>
|
||||
<div class="chart-box"><svg id="c-lordo" role="img" aria-label="Capitale per anno con 800 euro al mese, modello lordo, scomposto in versamenti e guadagno"></svg></div>
|
||||
</figure>
|
||||
|
||||
<figure>
|
||||
<p class="chart-title">Con il fisco d'accumulo <span>— plusvalenze 33% annue + patrimoniale 0,2%</span></p>
|
||||
<div class="chart-box"><svg id="c-netto" role="img" aria-label="Capitale per anno con 800 euro al mese e imposte, scomposto in versamenti e guadagno"></svg></div>
|
||||
<figcaption class="figcap">
|
||||
Stessa scala verticale nei due grafici: la differenza di altezza <em>è</em> il costo del fisco.
|
||||
La banda p25–p75 dice quanto il percorso può spostarsi; il guadagno disegnato è quello del
|
||||
percorso mediano, non di un percorso singolo.
|
||||
</figcaption>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section class="prose">
|
||||
<h2>Cosa comprano i €300 in più</h2>
|
||||
<p>
|
||||
Il confronto è a parità di tutto il resto — stesso book, stessi path, stesso lump da €5.000 —
|
||||
e va letto sulla riga della <em>probabilità</em>, non su quella del capitale: la mediana dice
|
||||
dove finisce metà dei percorsi, la probabilità dice quanti ci arrivano.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="cmp"><div class="tablewrap"><table id="cmp"></table></div></div>
|
||||
|
||||
<section class="prose">
|
||||
<h2>Il vincolo dei dieci anni</h2>
|
||||
<p class="caveat">
|
||||
Anche €800 al mese <b>non bastano</b> per il traguardo in dieci anni: la probabilità col fisco
|
||||
dentro è <b id="x-p10">25,5%</b>. Serve circa <b>€880–1.050 al mese</b> — e a quel punto si
|
||||
sono versati oltre $119.000 per arrivare a $272.061, cioè <em>a orizzonte corto non fai
|
||||
lavorare la strategia, compri il capitale coi bonifici</em>.
|
||||
</p>
|
||||
<p>
|
||||
L'imposta durante l'accumulo <strong>non era mai stata contata</strong> in nessuna traiettoria
|
||||
del progetto: l'aliquota compariva in un solo punto, la lordizzazione del bersaglio in fase di
|
||||
<em>prelievo</em>. L'errore è composto — <strong id="x-tax5">−15%</strong> di capitale a 5 anni,
|
||||
<strong id="x-tax10">−29%</strong> a 10, <strong id="x-tax15">−42%</strong> a 15 — quindi
|
||||
cresce proprio sull'orizzonte su cui si sta decidendo. Le assunzioni (33% sulle plusvalenze,
|
||||
minusvalenze riportabili 4 anni, 0,2% annuo sul valore) sono dichiarate e <em>non sono un
|
||||
parere fiscale</em>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<details>
|
||||
<summary>Tabella completa — i due regimi, anno per anno, a €800/mese</summary>
|
||||
<div class="tablewrap">
|
||||
<table id="tbl">
|
||||
<caption>Valori in dollari. «capitale» è la mediana fra i 4.000 percorsi; «P(bersaglio)» è la quota di percorsi che ha toccato $272.061 in qualunque momento entro quell'anno.</caption>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<footer>
|
||||
Il «guadagno» di ogni riga è <b>mediana(capitale) − versato</b>: il rendimento del percorso
|
||||
mediano, non di un percorso singolo — le due componenti non sono indipendenti e non vanno
|
||||
sommate come se lo fossero. I versamenti entrano ogni 30 giorni. Nessun rischio di venue in
|
||||
questo modello: a p=1% annuo di fallimento dell'exchange la probabilità di arrivare scende di
|
||||
~2 punti e quella di <b>perdere tutto</b> sale al 18%, e a p=5% il capitale mediano a 20 anni è
|
||||
zero per qualunque calendario di versamenti. E un piano si giudica sulle sue deviazioni: qui i
|
||||
versamenti non si fermano mai, che è l'unico scenario che non succede.
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
<div id="tip" role="status" aria-live="polite"></div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
/* riga = [anno, versato, guadagno_med, p25, p75, P(bersaglio)] */
|
||||
const D800 = {
|
||||
"lordo":[[1,16514,1769,17245,19578,0.0],[2,26978,5761,30155,35973,0.0],[3,37442,12653,45263,55806,0.0],[4,47906,22674,62395,80542,0.0],[5,58370,36254,82632,109462,0.0],[6,68834,54149,105982,145050,0.001],[7,80170,77582,134771,188140,0.027],[8,90634,107277,166043,240255,0.153],[9,101098,145547,201137,302141,0.406],[10,111562,190624,245158,375704,0.663],[11,122026,246454,293656,463843,0.844],[12,132490,314762,352325,569174,0.942],[13,143826,397855,420145,697480,0.981],[14,154290,497056,504972,849886,0.995],[15,164754,611612,598478,1027590,0.998]],
|
||||
"netto":[[1,16514,1150,16970,18530,0.0],[2,26978,3704,28990,32772,0.0],[3,37442,8016,42367,49006,0.0],[4,47906,14081,56895,67961,0.0],[5,58370,21953,73255,88990,0.0],[6,68834,32239,91315,113319,0.0],[7,80170,45114,112318,141436,0.001],[8,90634,60851,134505,172871,0.014],[9,101098,80839,158029,208664,0.072],[10,111562,102940,185847,248679,0.255],[11,122026,129351,216191,293736,0.508],[12,132490,160661,249046,345007,0.736],[13,143826,196548,286976,403693,0.884],[14,154290,238407,331309,470099,0.961],[15,164754,285353,377454,544074,0.99]]};
|
||||
|
||||
/* il piano precedente, per il confronto */
|
||||
const D500 = {
|
||||
"lordo":[[1,12590,1480,13204,15143,0.0],[2,19130,4439,21604,26058,0.0],[3,25670,9315,31459,39175,0.0],[4,32210,16336,42734,55555,0.0],[5,38750,25573,55941,74606,0.0],[6,45290,37658,71107,98301,0.0],[7,52375,53218,90027,126660,0.001],[8,58915,73347,110476,161177,0.012],[9,65455,98449,133388,202044,0.059],[10,71995,128781,162249,250780,0.2],[11,78535,166083,194409,309087,0.413],[12,85075,210811,232546,378373,0.625],[13,92160,266535,276493,463805,0.787],[14,98700,331836,332045,563596,0.901],[15,105240,406396,392375,682638,0.958]],
|
||||
"netto":[[1,12590,964,12975,14272,0.0],[2,19130,2854,20694,23582,0.0],[3,25670,5911,29311,34169,0.0],[4,32210,10094,38695,46478,0.0],[5,38750,15457,49239,60153,0.0],[6,45290,22328,60828,76054,0.0],[7,52375,30864,74451,94333,0.0],[8,58915,41442,88742,114692,0.0],[9,65455,54390,103868,138004,0.001],[10,71995,68996,121916,164188,0.012],[11,78535,86387,141436,193292,0.051],[12,85075,107166,162790,226868,0.154],[13,92160,130846,187634,264641,0.334],[14,98700,158081,216211,308116,0.537],[15,105240,188583,245868,356333,0.732]]};
|
||||
|
||||
const TARGET = 272061;
|
||||
const I = { ANNO:0, VERS:1, GUAD:2, P25:3, P75:4, PHIT:5 };
|
||||
const D = D800;
|
||||
|
||||
const usd = n => "$" + Math.round(n).toLocaleString("it-IT");
|
||||
const usdK = n => n === 0 ? "$0" : "$" + Math.round(n / 1000) + "k";
|
||||
const pct = n => (100 * n).toFixed(1).replace(".", ",") + "%";
|
||||
const ORD = ["", "1º","2º","3º","4º","5º","6º","7º","8º","9º","10º","11º","12º","13º","14º","15º"];
|
||||
|
||||
const at = (set, key, y) => set[key].find(r => r[I.ANNO] === y);
|
||||
const cap = r => r[I.VERS] + r[I.GUAD];
|
||||
/* primo anno in cui il guadagno cumulato supera tutto il versato */
|
||||
const crossover = key => (D[key].find(r => r[I.GUAD] > r[I.VERS]) || {})[I.ANNO];
|
||||
/* primo anno in cui il capitale MEDIANO tocca il bersaglio */
|
||||
const hitYear = key => (D[key].find(r => cap(r) >= TARGET) || {})[I.ANNO];
|
||||
|
||||
/* ---------------- testo guidato dai dati ---------------- */
|
||||
const put = (id, txt) => { const e = document.getElementById(id); if (e) e.textContent = txt; };
|
||||
put("x-cross-l", ORD[crossover("lordo")]);
|
||||
put("x-cross-n", ORD[crossover("netto")]);
|
||||
put("x-hit-n", ORD[hitYear("netto")]);
|
||||
put("x-p10", pct(at(D, "netto", 10)[I.PHIT]));
|
||||
[5, 10, 15].forEach(y => {
|
||||
const l = cap(at(D, "lordo", y)), n = cap(at(D, "netto", y));
|
||||
put("x-tax" + y, "−" + Math.round(100 * (1 - n / l)) + "%");
|
||||
});
|
||||
|
||||
/* ---------------- tiles ---------------- */
|
||||
const tiles = [5, 10, 15].map(y => {
|
||||
const n = at(D, "netto", y), l = at(D, "lordo", y);
|
||||
return { k: "a " + y + " anni", v: usd(cap(n)),
|
||||
s: "versati " + usd(n[I.VERS]) + " · il guadagno è il " +
|
||||
Math.round(100 * n[I.GUAD] / cap(n)) + "% del capitale · lordo sarebbe " + usd(cap(l)) };
|
||||
}).concat([{ k: "sorpasso", v: crossover("lordo") + "º<small> / " + crossover("netto") + "º anno</small>",
|
||||
s: "l'anno in cui il guadagno cumulato supera tutto il versato — lordo / col fisco" }]);
|
||||
document.getElementById("tiles").innerHTML = tiles.map(t =>
|
||||
'<div class="tile"><span class="k">' + t.k + '</span><span class="v">' + t.v +
|
||||
'</span><span class="s">' + t.s + "</span></div>").join("");
|
||||
|
||||
/* ---------------- confronto 500 vs 800 ---------------- */
|
||||
const cmpRows = [
|
||||
["versato a 10 anni", s => usd(at(s, "netto", 10)[I.VERS])],
|
||||
["capitale a 10 anni", s => usd(cap(at(s, "netto", 10)))],
|
||||
["P(bersaglio) a 10 anni", s => pct(at(s, "netto", 10)[I.PHIT]), true],
|
||||
["versato a 15 anni", s => usd(at(s, "netto", 15)[I.VERS])],
|
||||
["capitale a 15 anni", s => usd(cap(at(s, "netto", 15)))],
|
||||
["P(bersaglio) a 15 anni", s => pct(at(s, "netto", 15)[I.PHIT]), true],
|
||||
["anno del traguardo (mediana)", s => {
|
||||
const r = s.netto.find(x => x[I.VERS] + x[I.GUAD] >= TARGET);
|
||||
return r ? ORD[r[I.ANNO]] + " anno" : "oltre il 15º"; }, true]
|
||||
];
|
||||
document.getElementById("cmp").innerHTML =
|
||||
"<caption>Tutti i valori sono <b>col fisco d'accumulo</b>, il regime realistico. Stesso lump da €5.000, stessi 4.000 percorsi.</caption>" +
|
||||
"<thead><tr><th></th><th>€500/mese</th><th>€800/mese</th></tr></thead><tbody>" +
|
||||
cmpRows.map(([k, f, hi]) =>
|
||||
"<tr><td>" + k + "</td><td" + (hi ? ' class="hi"' : "") + ">" + f(D500) +
|
||||
"</td><td" + (hi ? ' class="hi"' : "") + ">" + f(D800) + "</td></tr>").join("") +
|
||||
"</tbody>";
|
||||
|
||||
/* ---------------- tooltip ---------------- */
|
||||
const tip = document.getElementById("tip");
|
||||
function showTip(evt, serie, row) {
|
||||
tip.innerHTML =
|
||||
'<span class="t">anno ' + row[I.ANNO] + " · " + serie + "</span>" +
|
||||
'<div class="r"><span><span class="sw" style="background:var(--versato)"></span>versato</span><b>' + usd(row[I.VERS]) + "</b></div>" +
|
||||
'<div class="r"><span><span class="sw" style="background:var(--guadagno)"></span>guadagno</span><b>' + usd(row[I.GUAD]) + "</b></div>" +
|
||||
'<div class="r"><span>capitale mediano</span><b>' + usd(cap(row)) + "</b></div>" +
|
||||
'<div class="r"><span>banda p25–p75</span><b>' + usdK(row[I.P25]) + " – " + usdK(row[I.P75]) + "</b></div>" +
|
||||
'<div class="r"><span>P(bersaglio)</span><b>' + pct(row[I.PHIT]) + "</b></div>";
|
||||
tip.style.opacity = "1";
|
||||
moveTip(evt);
|
||||
}
|
||||
function moveTip(evt) {
|
||||
const r = tip.getBoundingClientRect();
|
||||
let x = evt.clientX + 16, y = evt.clientY - r.height - 12;
|
||||
if (x + r.width > window.innerWidth - 8) x = evt.clientX - r.width - 16;
|
||||
if (y < 8) y = evt.clientY + 18;
|
||||
tip.style.left = x + "px";
|
||||
tip.style.top = y + "px";
|
||||
}
|
||||
const hideTip = () => { tip.style.opacity = "0"; };
|
||||
|
||||
/* ---------------- scala verticale, dai dati ---------------- */
|
||||
const rawMax = Math.max(...["lordo", "netto"].flatMap(k => D[k].map(r => r[I.P75])));
|
||||
const STEP = [25000, 50000, 100000, 150000, 200000, 250000, 500000]
|
||||
.find(s => rawMax / s <= 8) || 1000000;
|
||||
const YMAX = Math.ceil(rawMax / STEP) * STEP;
|
||||
|
||||
/* ---------------- chart ---------------- */
|
||||
const NS = "http://www.w3.org/2000/svg";
|
||||
const el = (n, a) => { const e = document.createElementNS(NS, n);
|
||||
for (const k in a) e.setAttribute(k, a[k]); return e; };
|
||||
|
||||
function roundedTop(x, yTop, w, h, r) {
|
||||
if (h <= 0) return "";
|
||||
r = Math.min(r, h, w / 2);
|
||||
return "M" + x + "," + (yTop + h) + "V" + (yTop + r) +
|
||||
"a" + r + "," + r + " 0 0 1 " + r + "," + (-r) +
|
||||
"h" + (w - 2 * r) +
|
||||
"a" + r + "," + r + " 0 0 1 " + r + "," + r +
|
||||
"V" + (yTop + h) + "Z";
|
||||
}
|
||||
|
||||
function draw(svgId, key, label) {
|
||||
const rows = D[key];
|
||||
const W = 960, H = 360, M = { t: 26, r: 108, b: 38, l: 74 };
|
||||
const pw = W - M.l - M.r, ph = H - M.t - M.b;
|
||||
const svg = document.getElementById(svgId);
|
||||
svg.setAttribute("viewBox", "0 0 " + W + " " + H);
|
||||
svg.innerHTML = "";
|
||||
|
||||
const y = v => M.t + ph - (v / YMAX) * ph;
|
||||
const step = pw / rows.length;
|
||||
const bw = Math.min(38, step * 0.62);
|
||||
|
||||
for (let v = 0; v <= YMAX; v += STEP) {
|
||||
svg.appendChild(el("line", { x1: M.l, x2: M.l + pw, y1: y(v), y2: y(v),
|
||||
stroke: v === 0 ? "var(--ink-3)" : "var(--grid)", "stroke-width": 1 }));
|
||||
const t = el("text", { x: M.l - 12, y: y(v) + 4, "text-anchor": "end",
|
||||
class: "axis", fill: "var(--ink-3)", "font-size": 11 });
|
||||
t.textContent = usdK(v);
|
||||
svg.appendChild(t);
|
||||
}
|
||||
|
||||
const ty = y(TARGET);
|
||||
svg.appendChild(el("line", { x1: M.l, x2: M.l + pw, y1: ty, y2: ty,
|
||||
stroke: "var(--ink-2)", "stroke-width": 1.5, "stroke-dasharray": "5 4" }));
|
||||
const tl = el("text", { x: M.l + 8, y: ty - 8, class: "axis",
|
||||
fill: "var(--ink-2)", "font-size": 11 });
|
||||
tl.textContent = "bersaglio $272.061 — capitale-rendita";
|
||||
svg.appendChild(tl);
|
||||
|
||||
rows.forEach((row, i) => {
|
||||
const cx = M.l + step * i + step / 2;
|
||||
const x = cx - bw / 2;
|
||||
const vTop = y(row[I.VERS]), gTop = y(cap(row)), base = y(0);
|
||||
const g = el("g", { class: "bargroup", tabindex: "0",
|
||||
"aria-label": "anno " + row[I.ANNO] + ": versato " + usd(row[I.VERS]) +
|
||||
", guadagno " + usd(row[I.GUAD]) });
|
||||
|
||||
g.appendChild(el("path", { d: roundedTop(x, vTop, bw, base - vTop, 0), fill: "var(--versato)" }));
|
||||
/* distanziatore da 2px solo dove il segmento e' visibile: nei primi anni il guadagno
|
||||
vale pochi pixel e il distanziatore lo cancellerebbe */
|
||||
const rawH = vTop - gTop;
|
||||
const gh = rawH - (rawH > 6 ? 2 : 0);
|
||||
if (gh > 0.3) g.appendChild(el("path", { d: roundedTop(x, gTop, bw, gh, 4), fill: "var(--guadagno)" }));
|
||||
|
||||
const p25 = y(row[I.P25]), p75 = y(row[I.P75]);
|
||||
g.appendChild(el("line", { x1: cx, x2: cx, y1: p75, y2: p25,
|
||||
stroke: "var(--surface)", "stroke-width": 4 }));
|
||||
g.appendChild(el("line", { x1: cx, x2: cx, y1: p75, y2: p25,
|
||||
stroke: "var(--ink-2)", "stroke-width": 2, opacity: .85 }));
|
||||
[p25, p75].forEach(yy => g.appendChild(el("line", { x1: cx - 5, x2: cx + 5, y1: yy, y2: yy,
|
||||
stroke: "var(--ink-2)", "stroke-width": 2, opacity: .85 })));
|
||||
|
||||
const hit = el("rect", { class: "hit", x: M.l + step * i, y: M.t,
|
||||
width: step, height: ph, fill: "transparent" });
|
||||
g.appendChild(hit);
|
||||
g.addEventListener("pointerenter", e => showTip(e, label, row));
|
||||
g.addEventListener("pointermove", moveTip);
|
||||
g.addEventListener("pointerleave", hideTip);
|
||||
g.addEventListener("focus", () => {
|
||||
const b = hit.getBoundingClientRect();
|
||||
showTip({ clientX: b.left + b.width / 2, clientY: b.top + 40 }, label, row);
|
||||
});
|
||||
g.addEventListener("blur", hideTip);
|
||||
svg.appendChild(g);
|
||||
|
||||
const xt = el("text", { x: cx, y: H - 14, "text-anchor": "middle", class: "axis",
|
||||
fill: row[I.ANNO] === crossover(key) ? "var(--ink)" : "var(--ink-3)", "font-size": 11 });
|
||||
xt.textContent = row[I.ANNO];
|
||||
svg.appendChild(xt);
|
||||
});
|
||||
|
||||
/* etichette dirette delle SERIE a destra dell'ultima barra: l'identita' non e' mai solo
|
||||
colore, ed e' il relief richiesto dal contrasto dell'arancio in chiaro */
|
||||
const last = rows[rows.length - 1];
|
||||
const lastTop = y(cap(last)), lastMid = y(last[I.VERS]), lx = M.l + pw + 14;
|
||||
[["guadagno", usd(last[I.GUAD]), (lastTop + lastMid) / 2, "var(--guadagno)"],
|
||||
["versato", usd(last[I.VERS]), (lastMid + y(0)) / 2, "var(--versato)"]
|
||||
].forEach(([nome, val, yy, col]) => {
|
||||
const a = el("text", { x: lx, y: yy - 2, class: "axis", fill: col, "font-size": 11 });
|
||||
a.textContent = nome;
|
||||
svg.appendChild(a);
|
||||
const b = el("text", { x: lx, y: yy + 12, class: "axis", fill: "var(--ink)", "font-size": 11.5 });
|
||||
b.textContent = val;
|
||||
svg.appendChild(b);
|
||||
});
|
||||
|
||||
/* etichetta diretta: il sorpasso */
|
||||
const ci = crossover(key) - 1, cRow = rows[ci];
|
||||
const ccx = M.l + step * ci + step / 2, cy = y(cap(cRow));
|
||||
svg.appendChild(el("line", { x1: ccx, x2: ccx, y1: cy - 6, y2: cy - 26,
|
||||
stroke: "var(--guadagno)", "stroke-width": 1.5 }));
|
||||
const ct = el("text", { x: ccx, y: cy - 32, "text-anchor": "middle", class: "axis",
|
||||
fill: "var(--guadagno)", "font-size": 11 });
|
||||
ct.textContent = "sorpasso";
|
||||
svg.appendChild(ct);
|
||||
|
||||
const ax = el("text", { x: M.l + pw + 14, y: H - 14, class: "axis",
|
||||
fill: "var(--ink-3)", "font-size": 10.5 });
|
||||
ax.textContent = "anno";
|
||||
svg.appendChild(ax);
|
||||
}
|
||||
|
||||
draw("c-lordo", "lordo", "lordo");
|
||||
draw("c-netto", "netto", "col fisco");
|
||||
|
||||
/* ---------------- tabella ---------------- */
|
||||
const tbl = document.getElementById("tbl");
|
||||
const head = "<thead><tr><th>anno</th><th>versato</th>" +
|
||||
"<th>guad. lordo</th><th>capitale lordo</th><th>P(bers.)</th>" +
|
||||
"<th>guad. netto</th><th>capitale netto</th><th>P(bers.)</th><th>costo fisco</th></tr></thead>";
|
||||
const body = D.lordo.map((l, i) => {
|
||||
const n = D.netto[i], cl = cap(l), cn = cap(n);
|
||||
const mark = [5, 10, 15].includes(l[I.ANNO]) ? ' class="mark"' : "";
|
||||
return "<tr" + mark + "><td>" + l[I.ANNO] + "</td><td>" + usd(l[I.VERS]) + "</td><td>" +
|
||||
usd(l[I.GUAD]) + "</td><td>" + usd(cl) + "</td><td>" + pct(l[I.PHIT]) + "</td><td>" +
|
||||
usd(n[I.GUAD]) + "</td><td>" + usd(cn) + "</td><td>" + pct(n[I.PHIT]) + "</td><td>" +
|
||||
(100 * (cn / cl - 1)).toFixed(1).replace(".", ",") + "%</td></tr>";
|
||||
}).join("");
|
||||
tbl.insertAdjacentHTML("beforeend", head + "<tbody>" + body + "</tbody>");
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,393 @@
|
||||
<title>Quale strategia — book, ETF o un mix</title>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--ground: #f7f7f5; --surface: #fdfdfc; --sunken: #f1f1ee;
|
||||
--rule: #dededa; --rule-soft: #ebebe7;
|
||||
--ink: #16171a; --ink-2: #55575c; --ink-3: #83858b;
|
||||
--s1: #2a78d6; --s2: #eb6834; --s3: #1baf7a; --s4: #eda100; --grid: #e6e6e2;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
color-scheme: dark;
|
||||
--ground: #17181a; --surface: #1d1e21; --sunken: #232427;
|
||||
--rule: #34363a; --rule-soft: #26282b;
|
||||
--ink: #f1f1ee; --ink-2: #a8aab0; --ink-3: #7c7e85;
|
||||
--s1: #3987e5; --s2: #d95926; --s3: #199e70; --s4: #c98500; --grid: #2b2d31;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--ground: #17181a; --surface: #1d1e21; --sunken: #232427;
|
||||
--rule: #34363a; --rule-soft: #26282b;
|
||||
--ink: #f1f1ee; --ink-2: #a8aab0; --ink-3: #7c7e85;
|
||||
--s1: #3987e5; --s2: #d95926; --s3: #199e70; --s4: #c98500; --grid: #2b2d31;
|
||||
}
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
body { background: var(--ground); color: var(--ink);
|
||||
font-family: Georgia, "Iowan Old Style", "Times New Roman", serif;
|
||||
font-size: 17px; line-height: 1.62; margin: 0; padding: 0 24px 96px; -webkit-font-smoothing: antialiased; }
|
||||
.wrap { max-width: 1080px; margin: 0 auto; display: flex; flex-direction: column; gap: 44px; }
|
||||
.prose { max-width: 68ch; display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.mono, .eyebrow, .lede-meta, .legend, .axis, table, .tile {
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
|
||||
font-variant-numeric: tabular-nums; }
|
||||
|
||||
header { padding-top: 60px; display: flex; flex-direction: column; gap: 18px; }
|
||||
.eyebrow { font-size: 11.5px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--ink-3); margin: 0; }
|
||||
h1 { font-size: clamp(29px, 4.2vw, 44px); line-height: 1.12; margin: 0; font-weight: 400;
|
||||
letter-spacing: -0.015em; text-wrap: balance; max-width: 21ch; }
|
||||
.lede { font-size: 19px; color: var(--ink-2); margin: 0; max-width: 62ch; }
|
||||
.lede b { color: var(--ink); font-weight: 700; }
|
||||
.lede-meta { font-size: 12.5px; color: var(--ink-3); line-height: 1.9; margin: 0;
|
||||
padding-top: 14px; border-top: 1px solid var(--rule); }
|
||||
.lede-meta b { color: var(--ink-2); font-weight: 400; }
|
||||
|
||||
h2 { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px;
|
||||
letter-spacing: 0.15em; text-transform: uppercase; color: var(--ink-2); font-weight: 500;
|
||||
margin: 0 0 4px; padding-bottom: 10px; border-bottom: 1px solid var(--rule); }
|
||||
p { margin: 0; }
|
||||
em { font-style: italic; color: var(--ink-2); }
|
||||
strong { font-weight: 700; }
|
||||
|
||||
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(215px, 1fr)); gap: 1px;
|
||||
background: var(--rule); border: 1px solid var(--rule); border-radius: 3px; overflow: hidden; }
|
||||
.tile { background: var(--surface); padding: 17px 18px 19px; display: flex; flex-direction: column; gap: 5px; }
|
||||
.tile .k { font-size: 11px; letter-spacing: 0.13em; text-transform: uppercase; color: var(--ink-3); }
|
||||
.tile .v { font-size: 26px; letter-spacing: -0.02em; color: var(--ink); line-height: 1.12; }
|
||||
.tile .v small { font-size: 14px; color: var(--ink-3); letter-spacing: 0; }
|
||||
.tile .s { font-size: 12px; color: var(--ink-2); line-height: 1.55; }
|
||||
|
||||
.tablewrap { overflow-x: auto; border: 1px solid var(--rule); border-radius: 3px;
|
||||
background: var(--surface); padding: 14px 16px; }
|
||||
table.data { border-collapse: collapse; font-size: 12.5px; width: 100%; min-width: 620px; }
|
||||
table.data th, table.data td { padding: 9px 14px 9px 0; text-align: right; white-space: nowrap; }
|
||||
table.data th:first-child, table.data td:first-child { text-align: left; }
|
||||
table.data thead th { color: var(--ink-3); font-weight: 400; border-bottom: 1px solid var(--rule);
|
||||
font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; }
|
||||
table.data tbody td { border-bottom: 1px solid var(--rule-soft); color: var(--ink-2); }
|
||||
table.data tbody td:first-child { color: var(--ink); }
|
||||
table.data tbody tr.best td { background: color-mix(in srgb, var(--s1) 9%, transparent); color: var(--ink); }
|
||||
caption { caption-side: top; text-align: left; font-size: 12px; color: var(--ink-3); padding-bottom: 12px; }
|
||||
.cell { display: inline-block; min-width: 46px; text-align: right; }
|
||||
|
||||
figure { margin: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.chart-title { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px;
|
||||
letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink); margin: 0; }
|
||||
.chart-title span { color: var(--ink-3); text-transform: none; letter-spacing: 0; }
|
||||
.chart-box { background: var(--surface); border: 1px solid var(--rule); border-radius: 3px;
|
||||
padding: 18px 16px 10px; overflow-x: auto; }
|
||||
.chart-box svg { display: block; width: 100%; min-width: 620px; height: auto; }
|
||||
.legend { display: flex; gap: 20px; flex-wrap: wrap; font-size: 12px; color: var(--ink-2); align-items: center; }
|
||||
.legend i { width: 20px; height: 3px; border-radius: 2px; display: inline-block; margin-right: 8px; vertical-align: 3px; }
|
||||
.figcap { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
|
||||
color: var(--ink-3); line-height: 1.7; max-width: 80ch; }
|
||||
|
||||
.caveat { border-left: 2px solid var(--s2); padding: 2px 0 2px 18px; color: var(--ink-2);
|
||||
font-size: 16px; max-width: 64ch; }
|
||||
.caveat b { color: var(--ink); font-weight: 700; }
|
||||
|
||||
#tip { position: fixed; z-index: 40; pointer-events: none; opacity: 0; transition: opacity .1s ease;
|
||||
background: var(--surface); border: 1px solid var(--rule); border-radius: 3px; padding: 10px 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; line-height: 1.75;
|
||||
color: var(--ink-2); box-shadow: 0 6px 22px rgba(0,0,0,.14); font-variant-numeric: tabular-nums; max-width: 270px; }
|
||||
#tip .t { color: var(--ink); letter-spacing: 0.08em; text-transform: uppercase; font-size: 10.5px; display: block; margin-bottom: 5px; }
|
||||
#tip .r { display: flex; justify-content: space-between; gap: 18px; }
|
||||
#tip .r b { font-weight: 400; color: var(--ink); }
|
||||
#tip .sw { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 6px; }
|
||||
|
||||
footer { border-top: 1px solid var(--rule); padding-top: 18px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px;
|
||||
color: var(--ink-3); line-height: 1.85; max-width: 80ch; }
|
||||
footer b { color: var(--ink-2); font-weight: 400; }
|
||||
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
|
||||
</style>
|
||||
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<p class="eyebrow">PythagorasGoal · scelta di portafoglio · 2026-08-07</p>
|
||||
<h1 id="h1">Quale strategia, a 12 anni</h1>
|
||||
<p class="lede" id="lede"></p>
|
||||
<p class="lede-meta" id="meta"></p>
|
||||
</header>
|
||||
|
||||
<section class="prose">
|
||||
<h2>1 · Perché la domanda non ha una risposta sola</h2>
|
||||
<p id="p-intro"></p>
|
||||
<p class="caveat" id="p-asim"></p>
|
||||
</section>
|
||||
|
||||
<div class="tiles" id="tiles"></div>
|
||||
|
||||
<figure>
|
||||
<p class="chart-title">Quota del proprio capitale-rendita raggiunta <span>— per peso sul book, in ciascuno scenario</span></p>
|
||||
<div class="legend" id="leg"></div>
|
||||
<div class="chart-box"><svg id="c1" role="img" aria-label="Quota del bersaglio raggiunta per peso sul book, quattro scenari"></svg></div>
|
||||
<figcaption class="figcap" id="cap1"></figcaption>
|
||||
</figure>
|
||||
|
||||
<section class="prose">
|
||||
<h2>2 · La tabella della decisione</h2>
|
||||
<p id="p-tab"></p>
|
||||
</section>
|
||||
|
||||
<div class="tablewrap"><table class="data" id="t-dec"></table></div>
|
||||
|
||||
<section class="prose">
|
||||
<h2>3 · Perché il mix non è un compromesso</h2>
|
||||
<p id="p-corr"></p>
|
||||
</section>
|
||||
|
||||
<div class="tablewrap"><table class="data" id="t-det"></table></div>
|
||||
|
||||
<section class="prose">
|
||||
<h2>4 · La risposta</h2>
|
||||
<p class="caveat" id="p-risp"></p>
|
||||
<p id="p-limiti"></p>
|
||||
</section>
|
||||
|
||||
<footer id="foot"></footer>
|
||||
</div>
|
||||
|
||||
<div id="tip" role="status" aria-live="polite"></div>
|
||||
|
||||
<script id="best-data" type="application/json">/*__DATA__*/</script>
|
||||
<script>
|
||||
(function () {
|
||||
"use strict";
|
||||
const D = JSON.parse(document.getElementById("best-data").textContent);
|
||||
const $ = id => document.getElementById(id);
|
||||
const P = D.piano;
|
||||
const SCEN = Object.keys(D.scenari);
|
||||
const COL = ["var(--s1)", "var(--s3)", "var(--s2)", "var(--s4)"];
|
||||
/* ⚠️ le chiavi dei pesi si LEGGONO dal JSON, non si ricostruiscono: Python le scrive come
|
||||
"0.0"/"1.0", mentre String(P.pesi[0]) in JS darebbe "0" — mismatch silenzioso che rendeva
|
||||
ogni lookup undefined. Vale per qualunque dizionario Python con chiavi numeriche. */
|
||||
const W = Object.keys(D.scenari[SCEN[0]]).sort((a, b) => Number(a) - Number(b));
|
||||
const kOf = w => W.find(k => Number(k) === Number(w));
|
||||
|
||||
const usd = n => "$" + Math.round(n).toLocaleString("it-IT");
|
||||
const usdK = n => "$" + Math.round(n / 1000) + "k";
|
||||
const pc = (x, d) => (100 * x).toFixed(d === undefined ? 0 : d).replace(".", ",") + "%";
|
||||
const wlab = w => Number(w) === 0 ? "solo ETF" : (Number(w) === 1 ? "solo book" :
|
||||
pc(Number(w)) + " book");
|
||||
|
||||
const S = (n, w) => D.scenari[n][kOf(w)];
|
||||
|
||||
/* ---------- intestazione ---------- */
|
||||
$("h1").textContent = "Quale strategia, a " + P.anni + " anni";
|
||||
$("lede").innerHTML =
|
||||
"Con <b>€" + P.lump.toLocaleString("it-IT") + "</b> iniziali e <b>€" +
|
||||
P.dep.toLocaleString("it-IT") + "/mese</b> per <b>" + P.anni + " anni</b>: tutto sul book, " +
|
||||
"tutto su un ETF S&P 500, o un mix? La risposta dipende da un'assunzione che " +
|
||||
"<b>nessun dato può risolvere</b> — e allora si sceglie ciò che regge comunque.";
|
||||
$("meta").innerHTML =
|
||||
"finestra <b>" + D.finestra.da + " → " + D.finestra.a + "</b> (" + D.finestra.giorni +
|
||||
" giorni): è l'unica dove un mix è definito · drift misurati: book <b>" + pc(D.drift.book, 1) +
|
||||
"</b>, S&P su questa finestra <b>" + pc(D.drift.eq_comune, 1) + "</b>, S&P su 30 anni <b>" +
|
||||
pc(D.drift.eq_30, 1) + "</b> · " + P.n_paths.toLocaleString("it-IT") + " percorsi · " +
|
||||
"fisco cripto <b>" + pc(P.tax_crypto) + " annuo</b>, ETF <b>" + pc(P.tax_etf) + " differito</b>";
|
||||
|
||||
$("p-intro").innerHTML =
|
||||
"Il book ha <b>" + (D.finestra.giorni / 365).toFixed(1) + " anni</b> di storia in tutto, " +
|
||||
"l'indice ne ha trenta. Sugli stessi sette anni i due rendono quasi uguale (" +
|
||||
pc(D.drift.book, 1) + " contro " + pc(D.drift.eq_comune, 1) + "); ma quei trent'anni " +
|
||||
"contengono il 2000 e il 2008, e lì l'indice rende " + pc(D.drift.eq_30, 1) + ". " +
|
||||
"Quindi la domanda vera non è «quale ha reso di più», è <em>quanto di ciò che ho misurato " +
|
||||
"sopravvive ai prossimi dodici anni</em>. Non essendoci un dato che lo decida, si guarda la " +
|
||||
"stessa scelta sotto quattro scenari e si tiene quella che non crolla in nessuno.";
|
||||
$("p-asim").innerHTML =
|
||||
"⚠️ <b>L'asimmetria fra i due scenari prudenti è essa stessa il risultato.</b> Quello " +
|
||||
"sull'indice è <b>misurato</b> — trent'anni di storia esistono e dicono " + pc(D.drift.eq_30, 1) +
|
||||
". Quello sul book è <b>giudiziale</b>: metà del drift, scelto a mano, perché " +
|
||||
(D.finestra.giorni / 365).toFixed(1) + " anni sono tutta la storia che ha e non c'è nulla con " +
|
||||
"cui stressarlo. Si può discutere la taglia di quel taglio; non lo si può togliere.";
|
||||
|
||||
/* ---------- tiles ---------- */
|
||||
const peg = D.peggiori, rim = D.rimpianti;
|
||||
const wMax = D.minimax, wReg = D.minregret;
|
||||
const base = SCEN[0];
|
||||
/* il criterio del caso peggiore puo' non distinguere due pesi: se lo scarto e' dentro il
|
||||
rumore Monte Carlo (~1%) va detto, invece di far sembrare che uno vinca */
|
||||
const ordPeg = W.slice().sort((a, b) => peg[b] - peg[a]);
|
||||
const pareggio = Math.abs(peg[ordPeg[0]] - peg[ordPeg[1]]) < 0.01;
|
||||
|
||||
$("tiles").innerHTML = [
|
||||
{ k: "massimizza il caso peggiore", v: pareggio ? wlab(ordPeg[0]) + " ≈ " + wlab(ordPeg[1]) : wlab(wMax),
|
||||
s: pareggio
|
||||
? "pareggio dentro il rumore (" + pc(peg[ordPeg[0]], 1) + " contro " + pc(peg[ordPeg[1]], 1) +
|
||||
"): questo criterio non li distingue"
|
||||
: "nello scenario a lei più ostile arriva al " + pc(peg[kOf(wMax)]) + " del proprio bersaglio" },
|
||||
{ k: "minimizza il rimpianto", v: wlab(wReg),
|
||||
s: "non perde mai più di " + pc(rim[kOf(wReg)]) + " rispetto alla scelta migliore di ogni scenario" },
|
||||
{ k: "correlazione book ↔ indice", v: D.correlazione.aperta.toFixed(2).replace(".", ","),
|
||||
s: "nei giorni di ribasso " + D.correlazione.ribasso.toFixed(2).replace(".", ",") +
|
||||
"; nel 5% peggiore dell'indice (" + pc(D.correlazione.coda_indice, 2) + ") il book fa " +
|
||||
pc(D.correlazione.coda_book, 2) },
|
||||
{ k: "scenario base · solo book", v: pc(S(base, 1).quota),
|
||||
s: "capitale " + usd(S(base, 1).med) + " contro un bersaglio di " + usd(S(base, 1).cap) }
|
||||
].map(t => '<div class="tile"><span class="k">' + t.k + '</span><span class="v">' + t.v +
|
||||
'</span><span class="s">' + t.s + "</span></div>").join("");
|
||||
|
||||
/* ---------- grafico: quota vs peso ---------- */
|
||||
$("leg").innerHTML = SCEN.map((n, i) =>
|
||||
"<span><i style='background:" + COL[i] + "'></i>" + n + "</span>").join("");
|
||||
|
||||
(function () {
|
||||
const svg = $("c1");
|
||||
const Wd = 960, H = 400, M = { t: 26, r: 150, b: 46, l: 66 };
|
||||
const pw = Wd - M.l - M.r, ph = H - M.t - M.b;
|
||||
svg.setAttribute("viewBox", "0 0 " + Wd + " " + H);
|
||||
svg.innerHTML = "";
|
||||
const NS = "http://www.w3.org/2000/svg";
|
||||
const el = (n, a) => { const e = document.createElementNS(NS, n); for (const k in a) e.setAttribute(k, a[k]); return e; };
|
||||
|
||||
const vals = SCEN.flatMap(n => W.map(w => S(n, w).quota));
|
||||
const YMAX = Math.ceil(Math.max(1.0, ...vals) / 0.2) * 0.2;
|
||||
const x = w => M.l + Number(w) * pw;
|
||||
const y = v => M.t + ph - (v / YMAX) * ph;
|
||||
|
||||
for (let v = 0; v <= YMAX + 1e-9; v += 0.2) {
|
||||
svg.appendChild(el("line", { x1: M.l, x2: M.l + pw, y1: y(v), y2: y(v),
|
||||
stroke: v === 0 ? "var(--ink-3)" : "var(--grid)", "stroke-width": 1 }));
|
||||
const t = el("text", { x: M.l - 12, y: y(v) + 4, "text-anchor": "end", class: "axis",
|
||||
fill: "var(--ink-3)", "font-size": 11 });
|
||||
t.textContent = pc(v); svg.appendChild(t);
|
||||
}
|
||||
/* la riga del 100%: il bersaglio raggiunto */
|
||||
svg.appendChild(el("line", { x1: M.l, x2: M.l + pw, y1: y(1), y2: y(1),
|
||||
stroke: "var(--ink-2)", "stroke-width": 1.5, "stroke-dasharray": "5 4" }));
|
||||
const bl = el("text", { x: M.l + 8, y: y(1) - 8, class: "axis", fill: "var(--ink-2)", "font-size": 11 });
|
||||
bl.textContent = "bersaglio raggiunto"; svg.appendChild(bl);
|
||||
|
||||
W.forEach(w => {
|
||||
const t = el("text", { x: x(w), y: H - 20, "text-anchor": "middle", class: "axis",
|
||||
fill: "var(--ink-3)", "font-size": 11 });
|
||||
t.textContent = pc(Number(w)); svg.appendChild(t);
|
||||
});
|
||||
const ax = el("text", { x: M.l + pw / 2, y: H - 4, "text-anchor": "middle", class: "axis",
|
||||
fill: "var(--ink-3)", "font-size": 10.5 });
|
||||
ax.textContent = "quota sul book (il resto su ETF S&P 500)"; svg.appendChild(ax);
|
||||
|
||||
SCEN.forEach((n, i) => {
|
||||
const pts = W.map(w => x(w) + "," + y(S(n, w).quota)).join(" ");
|
||||
svg.appendChild(el("polyline", { points: pts, fill: "none", stroke: COL[i],
|
||||
"stroke-width": 2.5, "stroke-linejoin": "round" }));
|
||||
W.forEach(w => svg.appendChild(el("circle", { cx: x(w), cy: y(S(n, w).quota), r: 3.5, fill: COL[i] })));
|
||||
const last = S(n, W[W.length - 1]);
|
||||
const a = el("text", { x: M.l + pw + 12, y: y(last.quota) - 3, class: "axis", fill: COL[i], "font-size": 11 });
|
||||
a.textContent = n; svg.appendChild(a);
|
||||
const b = el("text", { x: M.l + pw + 12, y: y(last.quota) + 12, class: "axis", fill: "var(--ink)", "font-size": 11.5 });
|
||||
b.textContent = pc(last.quota); svg.appendChild(b);
|
||||
});
|
||||
|
||||
/* hover per colonna */
|
||||
const tip = $("tip");
|
||||
const move = evt => { const r = tip.getBoundingClientRect();
|
||||
let X = evt.clientX + 16, Y = evt.clientY - r.height - 12;
|
||||
if (X + r.width > window.innerWidth - 8) X = evt.clientX - r.width - 16;
|
||||
if (Y < 8) Y = evt.clientY + 18;
|
||||
tip.style.left = X + "px"; tip.style.top = Y + "px"; };
|
||||
W.forEach(w => {
|
||||
const g = el("g", { tabindex: "0", "aria-label": wlab(w) });
|
||||
g.appendChild(el("rect", { x: x(w) - pw / (W.length * 2), y: M.t,
|
||||
width: pw / (W.length - 1) * 0.9, height: ph, fill: "transparent" }));
|
||||
const show = evt => {
|
||||
tip.innerHTML = '<span class="t">' + wlab(w) + "</span>" +
|
||||
SCEN.map((n, i) => '<div class="r"><span><span class="sw" style="background:' + COL[i] +
|
||||
'"></span>' + n + "</span><b>" + pc(S(n, w).quota) + "</b></div>").join("") +
|
||||
'<div class="r" style="margin-top:5px"><span>capitale (base)</span><b>' +
|
||||
usd(S(base, w).med) + "</b></div>" +
|
||||
'<div class="r"><span>bersaglio (base)</span><b>' + usd(S(base, w).cap) + "</b></div>";
|
||||
tip.style.opacity = "1"; move(evt); };
|
||||
g.addEventListener("pointerenter", show);
|
||||
g.addEventListener("pointermove", move);
|
||||
g.addEventListener("pointerleave", () => tip.style.opacity = "0");
|
||||
g.addEventListener("focus", () => show({ clientX: x(w) + 40, clientY: 320 }));
|
||||
g.addEventListener("blur", () => tip.style.opacity = "0");
|
||||
svg.appendChild(g);
|
||||
});
|
||||
$("cap1").innerHTML =
|
||||
"Ogni punto è: capitale mediano a " + P.anni + " anni diviso il capitale-rendita di " +
|
||||
"<em>quella</em> composizione — perché il bersaglio cambia col portafoglio, non è un numero " +
|
||||
"fisso. Il 100% è la linea da superare. Le quattro curve sono lo stesso calcolo sotto quattro " +
|
||||
"assunzioni di rendimento; le volatilità e la forma del rischio non sono mai state toccate.";
|
||||
})();
|
||||
|
||||
/* ---------- tabella decisione ---------- */
|
||||
$("t-dec").innerHTML =
|
||||
"<caption>Quota del proprio capitale-rendita raggiunta a " + P.anni + " anni. «Caso peggiore» " +
|
||||
"= il minimo della riga. «Rimpianto massimo» = quanto si perde, nello scenario in cui va " +
|
||||
"peggio, rispetto alla scelta che <em>lì</em> sarebbe stata la migliore.</caption>" +
|
||||
"<thead><tr><th>scelta</th>" + SCEN.map(n => "<th>" + n + "</th>").join("") +
|
||||
"<th>caso peggiore</th><th>rimpianto max</th></tr></thead><tbody>" +
|
||||
W.map(w => {
|
||||
const cls = (kOf(wMax) === w || kOf(wReg) === w) ? ' class="best"' : "";
|
||||
return "<tr" + cls + "><td>" + wlab(w) + "</td>" +
|
||||
SCEN.map(n => "<td><span class='cell'>" + pc(S(n, w).quota) + "</span></td>").join("") +
|
||||
"<td><span class='cell'>" + pc(peg[w]) + "</span></td>" +
|
||||
"<td><span class='cell'>" + pc(rim[w]) + "</span></td></tr>"; }).join("") + "</tbody>";
|
||||
|
||||
$("p-tab").innerHTML =
|
||||
"Le colonne non sono probabilità: sono quattro mondi possibili, e non si sa quale sia il " +
|
||||
"nostro. Una scelta buona è una che sta in alto in tutti — non una che vince nel mondo che " +
|
||||
"preferiamo.";
|
||||
|
||||
/* ---------- perché il mix ---------- */
|
||||
$("p-corr").innerHTML =
|
||||
"La correlazione fra book e indice è <b>" + D.correlazione.aperta.toFixed(2).replace(".", ",") +
|
||||
"</b> nei giorni di borsa aperta e <b>" + D.correlazione.ribasso.toFixed(2).replace(".", ",") +
|
||||
"</b> nei giorni di ribasso. Nel 5% di giornate peggiori dell'indice — quelle in cui perde in " +
|
||||
"media <b>" + pc(D.correlazione.coda_indice, 2) + "</b> — il book fa <b>" +
|
||||
pc(D.correlazione.coda_book, 2) + "</b>. Per questo un mix non è «un po' di ciascuno per non " +
|
||||
"sbagliare»: due motori scorrelati messi insieme hanno una volatilità più bassa della media " +
|
||||
"delle loro, e la rendita sostenibile dipende dal drawdown, non dal rendimento medio. È il " +
|
||||
"motivo per cui nella tabella qui sotto il <em>bersaglio</em> di un mix può essere più basso " +
|
||||
"di quello di entrambi i puri.";
|
||||
|
||||
$("t-det").innerHTML =
|
||||
"<caption>Dettaglio nello scenario «" + base + "». La rendita perpetua è il prelievo annuo " +
|
||||
"che mantiene P(capitale a 20 anni ≥ capitale iniziale) ≥ 90%.</caption>" +
|
||||
"<thead><tr><th>scelta</th><th>rendita perpetua</th><th>capitale-rendita</th>" +
|
||||
"<th>capitale a " + P.anni + " anni</th><th>banda p25–p75</th><th>quota</th></tr></thead><tbody>" +
|
||||
W.map(w => { const r = S(base, w);
|
||||
return "<tr><td>" + wlab(w) + "</td><td>" + pc(r.perp, 2) + "</td><td>" + usd(r.cap) +
|
||||
"</td><td>" + usd(r.med) + "</td><td>" + usdK(r.p25) + " – " + usdK(r.p75) +
|
||||
"</td><td>" + pc(r.quota) + "</td></tr>"; }).join("") + "</tbody>";
|
||||
|
||||
/* ---------- risposta ---------- */
|
||||
$("p-risp").innerHTML = ""; /* riempita sotto, dipende dai numeri */
|
||||
const qBookPeg = peg[kOf(1)];
|
||||
const qEtfPeg = peg[kOf(0)];
|
||||
$("p-risp").innerHTML =
|
||||
"<b>" + wlab(wReg) + "</b>. Non perché vinca — vince solo nello scenario base — ma perché è la " +
|
||||
"scelta il cui <b>rimpianto massimo</b> è più basso (" + pc(rim[kOf(wReg)]) + " contro il " +
|
||||
pc(rim[kOf(1)]) + " del book puro e il " + pc(rim[kOf(0)]) +
|
||||
" dell'ETF puro): qualunque dei quattro mondi si avveri, resta vicina alla scelta che sarebbe " +
|
||||
"stata giusta lì. Tutto sul book arriva al " + pc(qBookPeg) + " del bersaglio nel mondo che " +
|
||||
"gli è ostile, tutto sull'ETF al " + pc(qEtfPeg) + " nel suo. " +
|
||||
(pareggio ? "⚠️ Il criterio del solo caso peggiore <em>non</em> distingue " + wlab(ordPeg[0]) +
|
||||
" da " + wlab(ordPeg[1]) + " (" + pc(peg[ordPeg[0]], 1) + " contro " + pc(peg[ordPeg[1]], 1) +
|
||||
", dentro il rumore): a separarli è il rimpianto, non il caso peggiore." : "");
|
||||
$("p-limiti").innerHTML =
|
||||
"⚠️ Cosa questa analisi <em>non</em> dice. Non c'è il rischio di venue (a p=1% annuo la " +
|
||||
"probabilità di perdere tutto il lato cripto è ~18% su vent'anni, e sul lato ETF quel rischio " +
|
||||
"praticamente non esiste — il che spinge ancora verso il mix, ma non è nel conto). Il mix " +
|
||||
"assume due salvadanai separati senza ribilanciamento, perché ribilanciare vuol dire vendere " +
|
||||
"e realizzare. Il taglio del 50% al drift del book è una scelta, non una misura. E gli scenari " +
|
||||
"sono quattro mondi, non quattro probabilità: la tabella aiuta a non farsi male, non a " +
|
||||
"indovinare.";
|
||||
|
||||
$("foot").innerHTML =
|
||||
"Script: <b>scripts/research/r0807_best_strategy.py</b> (decisione) e " +
|
||||
"<b>r0807_asset_compare.py</b> (serie e fisco). Il book è TP01 75% + SKH01 25% alle ancore " +
|
||||
"canoniche, de-luckato ×0.89; l'ETF è SPY con <code>ADJUSTED_LAST</code> da Interactive " +
|
||||
"Brokers, su griglia di calendario con 0,0 a borsa chiusa. Fisco: cripto " + pc(P.tax_crypto) +
|
||||
" annuo sulle plusvalenze più " + pc(0.002, 1) + " sul valore; ETF UCITS ad accumulazione " +
|
||||
pc(P.tax_etf) + " differito alla vendita più " + pc(0.002, 1) + " — assunzioni dichiarate, " +
|
||||
"<b>non un parere fiscale</b>. Commissione d'acquisto ETF €" + P.comm + "/mese.";
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,602 @@
|
||||
<title>Simulatore del piano di accumulo — PythagorasGoal</title>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--ground: #f7f7f5;
|
||||
--surface: #fdfdfc;
|
||||
--sunken: #f1f1ee;
|
||||
--rule: #dededa;
|
||||
--rule-soft: #ebebe7;
|
||||
--ink: #16171a;
|
||||
--ink-2: #55575c;
|
||||
--ink-3: #83858b;
|
||||
--versato: #2a78d6;
|
||||
--guadagno: #eb6834;
|
||||
--grid: #e6e6e2;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
color-scheme: dark;
|
||||
--ground: #17181a; --surface: #1d1e21; --sunken: #232427;
|
||||
--rule: #34363a; --rule-soft: #26282b;
|
||||
--ink: #f1f1ee; --ink-2: #a8aab0; --ink-3: #7c7e85;
|
||||
--versato: #3987e5; --guadagno: #d95926; --grid: #2b2d31;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--ground: #17181a; --surface: #1d1e21; --sunken: #232427;
|
||||
--rule: #34363a; --rule-soft: #26282b;
|
||||
--ink: #f1f1ee; --ink-2: #a8aab0; --ink-3: #7c7e85;
|
||||
--versato: #3987e5; --guadagno: #d95926; --grid: #2b2d31;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
background: var(--ground); color: var(--ink);
|
||||
font-family: Georgia, "Iowan Old Style", "Times New Roman", serif;
|
||||
font-size: 17px; line-height: 1.62; margin: 0; padding: 0 24px 96px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.wrap { max-width: 1080px; margin: 0 auto; display: flex; flex-direction: column; gap: 40px; }
|
||||
.prose { max-width: 68ch; display: flex; flex-direction: column; gap: 16px; }
|
||||
|
||||
.mono, .eyebrow, .lede-meta, .legend, .axis, table, .tile, .ctl, .solve, .badge, button, input {
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
header { padding-top: 60px; display: flex; flex-direction: column; gap: 18px; }
|
||||
.eyebrow { font-size: 11.5px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--ink-3); margin: 0; }
|
||||
h1 { font-size: clamp(29px, 4.2vw, 44px); line-height: 1.12; margin: 0; font-weight: 400;
|
||||
letter-spacing: -0.015em; text-wrap: balance; max-width: 21ch; }
|
||||
.lede { font-size: 19px; color: var(--ink-2); margin: 0; max-width: 62ch; }
|
||||
.lede b { color: var(--ink); font-weight: 700; }
|
||||
.lede-meta { font-size: 12.5px; color: var(--ink-3); line-height: 1.9; margin: 0;
|
||||
padding-top: 14px; border-top: 1px solid var(--rule); }
|
||||
.lede-meta b { color: var(--ink-2); font-weight: 400; }
|
||||
|
||||
h2 { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px;
|
||||
letter-spacing: 0.15em; text-transform: uppercase; color: var(--ink-2); font-weight: 500;
|
||||
margin: 0 0 4px; padding-bottom: 10px; border-bottom: 1px solid var(--rule); }
|
||||
p { margin: 0; }
|
||||
em { font-style: italic; color: var(--ink-2); }
|
||||
|
||||
/* ---------- comandi ---------- */
|
||||
.panel { background: var(--surface); border: 1px solid var(--rule); border-radius: 3px; }
|
||||
.ctl { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 1px;
|
||||
background: var(--rule); border: 1px solid var(--rule); border-radius: 3px; overflow: hidden; }
|
||||
.ctl > div { background: var(--surface); padding: 16px 18px 18px; display: flex; flex-direction: column; gap: 9px; }
|
||||
.ctl label { font-size: 11px; letter-spacing: 0.13em; text-transform: uppercase; color: var(--ink-3); }
|
||||
.row { display: flex; align-items: baseline; gap: 8px; }
|
||||
.row .unit { color: var(--ink-3); font-size: 14px; }
|
||||
input[type="number"] {
|
||||
background: var(--sunken); border: 1px solid var(--rule); border-radius: 2px; color: var(--ink);
|
||||
font-size: 21px; padding: 5px 8px; width: 100%; max-width: 140px; letter-spacing: -0.01em;
|
||||
}
|
||||
input[type="number"]:focus-visible { outline: 2px solid var(--versato); outline-offset: 1px; }
|
||||
input[type="range"] { width: 100%; accent-color: var(--versato); margin: 0; }
|
||||
.seg { display: flex; gap: 0; border: 1px solid var(--rule); border-radius: 2px; overflow: hidden; width: fit-content; }
|
||||
.seg button { background: var(--surface); border: 0; border-right: 1px solid var(--rule);
|
||||
color: var(--ink-2); font-size: 12px; padding: 8px 13px; cursor: pointer; }
|
||||
.seg button:last-child { border-right: 0; }
|
||||
.seg button[aria-pressed="true"] { background: var(--versato); color: #fff; }
|
||||
.seg button:focus-visible { outline: 2px solid var(--ink); outline-offset: -2px; }
|
||||
|
||||
/* ---------- tiles ---------- */
|
||||
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1px;
|
||||
background: var(--rule); border: 1px solid var(--rule); border-radius: 3px; overflow: hidden; }
|
||||
.tile { background: var(--surface); padding: 17px 18px 19px; display: flex; flex-direction: column; gap: 5px; }
|
||||
.tile .k { font-size: 11px; letter-spacing: 0.13em; text-transform: uppercase; color: var(--ink-3); }
|
||||
.tile .v { font-size: 26px; letter-spacing: -0.02em; color: var(--ink); line-height: 1.12; }
|
||||
.tile .v small { font-size: 14px; color: var(--ink-3); letter-spacing: 0; }
|
||||
.tile .s { font-size: 12px; color: var(--ink-2); line-height: 1.55; }
|
||||
|
||||
/* ---------- risolutore ---------- */
|
||||
.solve { border: 1px solid var(--rule); border-radius: 3px; overflow: hidden; }
|
||||
.solve table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.solve th { background: var(--surface); color: var(--ink-3); font-weight: 400; font-size: 11px;
|
||||
letter-spacing: 0.08em; text-transform: uppercase; text-align: right; padding: 11px 14px; }
|
||||
.solve th:first-child { text-align: left; }
|
||||
.solve td { padding: 11px 14px; text-align: right; border-top: 1px solid var(--rule-soft); color: var(--ink-2); }
|
||||
.solve td:first-child { text-align: left; color: var(--ink); }
|
||||
.solve td.big { color: var(--ink); font-size: 17px; }
|
||||
.solve tr.now td { background: color-mix(in srgb, var(--versato) 8%, transparent); }
|
||||
.apply { background: transparent; border: 1px solid var(--rule); border-radius: 2px; color: var(--ink-2);
|
||||
font-size: 11px; padding: 5px 11px; cursor: pointer; letter-spacing: 0.06em; text-transform: uppercase; }
|
||||
.apply:hover { border-color: var(--versato); color: var(--versato); }
|
||||
.apply:focus-visible { outline: 2px solid var(--versato); outline-offset: 2px; }
|
||||
.apply[disabled] { opacity: .35; cursor: default; }
|
||||
.working { color: var(--ink-3); font-style: italic; }
|
||||
|
||||
/* ---------- grafico ---------- */
|
||||
figure { margin: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.chart-title { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px;
|
||||
letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink); margin: 0; }
|
||||
.chart-title span { color: var(--ink-3); text-transform: none; letter-spacing: 0; }
|
||||
.chart-box { background: var(--surface); border: 1px solid var(--rule); border-radius: 3px;
|
||||
padding: 18px 16px 10px; overflow-x: auto; }
|
||||
.chart-box svg { display: block; width: 100%; min-width: 620px; height: auto; }
|
||||
.legend { display: flex; gap: 22px; flex-wrap: wrap; font-size: 12px; color: var(--ink-2); align-items: center; }
|
||||
.legend i { width: 11px; height: 11px; border-radius: 2px; display: inline-block; margin-right: 7px; vertical-align: -1px; }
|
||||
.legend .band { width: 2px; height: 13px; background: var(--ink-3); margin-right: 7px; }
|
||||
.figcap { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
|
||||
color: var(--ink-3); line-height: 1.7; max-width: 80ch; }
|
||||
|
||||
/* ---------- tabella ---------- */
|
||||
details { border-top: 1px solid var(--rule); padding-top: 14px; }
|
||||
summary { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
|
||||
letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink-2); cursor: pointer; padding: 4px 0; }
|
||||
summary:focus-visible { outline: 2px solid var(--versato); outline-offset: 3px; }
|
||||
.tablewrap { overflow-x: auto; margin-top: 14px; }
|
||||
table.data { border-collapse: collapse; font-size: 12.5px; width: 100%; min-width: 640px; }
|
||||
table.data th, table.data td { padding: 6px 12px 6px 0; text-align: right; white-space: nowrap; }
|
||||
table.data th:first-child, table.data td:first-child { text-align: left; }
|
||||
table.data thead th { color: var(--ink-3); font-weight: 400; border-bottom: 1px solid var(--rule);
|
||||
font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; }
|
||||
table.data tbody td { border-bottom: 1px solid var(--rule-soft); color: var(--ink-2); }
|
||||
table.data tbody td:first-child { color: var(--ink); }
|
||||
|
||||
#tip { position: fixed; z-index: 40; pointer-events: none; opacity: 0; transition: opacity .1s ease;
|
||||
background: var(--surface); border: 1px solid var(--rule); border-radius: 3px; padding: 10px 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; line-height: 1.75;
|
||||
color: var(--ink-2); box-shadow: 0 6px 22px rgba(0,0,0,.14); font-variant-numeric: tabular-nums; max-width: 260px; }
|
||||
#tip .t { color: var(--ink); letter-spacing: 0.08em; text-transform: uppercase; font-size: 10.5px; display: block; margin-bottom: 5px; }
|
||||
#tip .r { display: flex; justify-content: space-between; gap: 18px; }
|
||||
#tip .r b { font-weight: 400; color: var(--ink); }
|
||||
#tip .sw { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 6px; }
|
||||
.bargroup:focus-visible .hit { stroke: var(--ink); stroke-width: 1.5; }
|
||||
|
||||
.caveat { border-left: 2px solid var(--guadagno); padding: 2px 0 2px 18px; color: var(--ink-2);
|
||||
font-size: 16px; max-width: 64ch; }
|
||||
.caveat b { color: var(--ink); font-weight: 700; }
|
||||
|
||||
footer { border-top: 1px solid var(--rule); padding-top: 18px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px;
|
||||
color: var(--ink-3); line-height: 1.85; max-width: 80ch; }
|
||||
footer b { color: var(--ink-2); font-weight: 400; }
|
||||
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
|
||||
</style>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<header>
|
||||
<p class="eyebrow">PythagorasGoal · simulatore del piano · 2026-08-07</p>
|
||||
<h1>Imposta il piano, guarda la curva</h1>
|
||||
<p class="lede">
|
||||
Capitale iniziale, versamento mensile e durata. La simulazione gira <b>qui dentro</b>, sui
|
||||
ritorni reali del book live: 2.692 giorni dal 2019 al 2026, ricampionati a blocchi di 20
|
||||
giorni per non distruggere il raggruppamento della volatilità.
|
||||
</p>
|
||||
<p class="lede-meta">
|
||||
book live <b>TP01 + SKH01 75/25</b>, de-luckato <b>×0.89</b> · bersaglio <b>$272.061</b>
|
||||
(capitale-rendita per €50/giorno netti) · motore verificato contro
|
||||
<b>r0807_growth_yearly.py</b>: differenza fra le medie <b>−0,01%</b> su 8 semi per parte
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="ctl">
|
||||
<div>
|
||||
<label for="n-lump">Capitale iniziale</label>
|
||||
<div class="row"><span class="unit">€</span><input type="number" id="n-lump" min="0" max="200000" step="500" value="5000"></div>
|
||||
<input type="range" id="r-lump" min="0" max="50000" step="500" value="5000">
|
||||
</div>
|
||||
<div>
|
||||
<label for="n-dep">Versamento mensile</label>
|
||||
<div class="row"><span class="unit">€</span><input type="number" id="n-dep" min="0" max="10000" step="25" value="500"></div>
|
||||
<input type="range" id="r-dep" min="0" max="2500" step="25" value="500">
|
||||
</div>
|
||||
<div>
|
||||
<label for="n-anni">Durata prevista</label>
|
||||
<div class="row"><input type="number" id="n-anni" min="1" max="30" step="1" value="15"><span class="unit">anni</span></div>
|
||||
<input type="range" id="r-anni" min="1" max="30" step="1" value="15">
|
||||
</div>
|
||||
<div>
|
||||
<label>Regime</label>
|
||||
<div class="seg" role="group" aria-label="Regime fiscale">
|
||||
<button type="button" id="b-netto" aria-pressed="true">col fisco</button>
|
||||
<button type="button" id="b-lordo" aria-pressed="false">lordo</button>
|
||||
</div>
|
||||
<p class="s" style="font-size:12px;color:var(--ink-3);line-height:1.5;margin:0">
|
||||
«col fisco» = plusvalenze 33% annue + patrimoniale 0,2%. È il regime realistico.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tiles" id="tiles"></div>
|
||||
|
||||
<section class="prose">
|
||||
<h2>La curva migliore a tempo fissato</h2>
|
||||
<p>
|
||||
Fissata la durata, il versamento mensile è <em>l'unica leva che resta</em>: il rendimento non
|
||||
si comanda e il capitale iniziale è quello che è. Qui sotto il versamento cercato per
|
||||
bisezione — lo stesso metodo di <em>dep_necessario</em> in <em>r0727_tasse.py</em> — perché la
|
||||
probabilità di toccare il bersaglio entro la durata impostata arrivi a 1 su 2, 3 su 4, 9 su 10.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="solve"><table id="solve"></table></div>
|
||||
|
||||
<figure>
|
||||
<p class="chart-title" id="ct">Capitale per anno</p>
|
||||
<div class="legend" aria-hidden="true">
|
||||
<span><i style="background:var(--versato)"></i>versato (deterministico)</span>
|
||||
<span><i style="background:var(--guadagno)"></i>guadagno del percorso mediano</span>
|
||||
<span><i class="band"></i>banda p25–p75 del capitale</span>
|
||||
</div>
|
||||
<div class="chart-box"><svg id="chart" role="img" aria-label="Capitale per anno, scomposto in versamenti e guadagno"></svg></div>
|
||||
<figcaption class="figcap" id="figcap"></figcaption>
|
||||
</figure>
|
||||
|
||||
<details>
|
||||
<summary>Tabella completa — anno per anno</summary>
|
||||
<div class="tablewrap"><table class="data" id="tbl"></table></div>
|
||||
</details>
|
||||
|
||||
<section class="prose">
|
||||
<h2>Cosa questo strumento non sa</h2>
|
||||
<p class="caveat">
|
||||
Il «guadagno» disegnato è <b>mediana(capitale) − versato</b>: il rendimento del percorso
|
||||
mediano, non di un percorso singolo — le due componenti non sono indipendenti e non vanno
|
||||
sommate come se lo fossero. La banda p25–p75 dice quanto il percorso può spostarsi.
|
||||
</p>
|
||||
<p>
|
||||
Non c'è il <strong>rischio di venue</strong>: a p=1% annuo di fallimento dell'exchange la
|
||||
probabilità di arrivare scende di ~2 punti e quella di <em>perdere tutto</em> sale al 18%; a
|
||||
p=5% il capitale mediano a 20 anni è zero per qualunque calendario di versamenti. E i
|
||||
versamenti qui non si fermano mai, che è l'unico scenario che non succede: interromperli al
|
||||
5º anno porta la probabilità di centrare il bersaglio dal 90% al 53%.
|
||||
</p>
|
||||
<p>
|
||||
Le assunzioni fiscali (33% sulle plusvalenze, minusvalenze riportabili 4 anni, 0,2% annuo sul
|
||||
valore) sono dichiarate e <em>non sono un parere fiscale</em>; il modello tassa la variazione
|
||||
annua di valore, quindi è un limite superiore rispetto alla pura realizzazione — stretto,
|
||||
però, perché questo book realizza quasi tutto entro l'anno. E i ritorni passati del book non
|
||||
sono una previsione: sono <em>l'unico campione che abbiamo</em>, ricampionato.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<footer id="foot"></footer>
|
||||
</div>
|
||||
|
||||
<div id="tip" role="status" aria-live="polite"></div>
|
||||
|
||||
<script id="series-data" type="application/json">/*__SERIES__*/</script>
|
||||
<script>
|
||||
/*__ENGINE__*/
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const CFG = JSON.parse(document.getElementById("series-data").textContent);
|
||||
const E = makeEngine(CFG);
|
||||
const TARGET = CFG.target;
|
||||
|
||||
const PATHS_MAIN = 3000; /* ~150 ms a 20 anni: la curva si ridisegna mentre si trascina */
|
||||
/* Il risolutore fa 13 corse per livello. Misurato: la banda del risultato resta ±1% da 1.200 a
|
||||
3.000 percorsi -> non domina il Monte Carlo ma la granularita' della bisezione (20.000/2^12
|
||||
= ~€5). Percio' si tiene basso e si dichiara l'incertezza, invece di pagare tempo per una
|
||||
precisione che non arriva. */
|
||||
const PATHS_SOLVE = 1500;
|
||||
const SEED = 20260807;
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
const usd = n => "$" + Math.round(n).toLocaleString("it-IT");
|
||||
const usdK = n => n === 0 ? "$0" : (Math.abs(n) >= 1e6
|
||||
? "$" + (n / 1e6).toFixed(n >= 1e7 ? 0 : 1).replace(".", ",") + "M"
|
||||
: "$" + Math.round(n / 1000) + "k");
|
||||
const eur = n => "€" + Math.round(n).toLocaleString("it-IT");
|
||||
const pct = n => (100 * n).toFixed(1).replace(".", ",") + "%";
|
||||
const ORD = n => n + "º";
|
||||
|
||||
let state = { lump: 5000, dep: 500, anni: 15, netto: true };
|
||||
let cur = null, curAlt = null;
|
||||
|
||||
/* ---------------- comandi ---------------- */
|
||||
function bind(name, key, max) {
|
||||
const n = $("n-" + name), r = $("r-" + name);
|
||||
const set = (v, from) => {
|
||||
v = Math.max(Number(n.min), Math.min(Number(n.max), Math.round(v)));
|
||||
state[key] = v;
|
||||
if (from !== "n") n.value = v;
|
||||
if (from !== "r") r.value = Math.min(Number(r.max), v);
|
||||
schedule();
|
||||
};
|
||||
n.addEventListener("input", () => { if (n.value !== "") set(Number(n.value), "n"); });
|
||||
r.addEventListener("input", () => set(Number(r.value), "r"));
|
||||
}
|
||||
bind("lump", "lump");
|
||||
bind("dep", "dep");
|
||||
bind("anni", "anni");
|
||||
|
||||
$("b-netto").addEventListener("click", () => setRegime(true));
|
||||
$("b-lordo").addEventListener("click", () => setRegime(false));
|
||||
function setRegime(netto) {
|
||||
state.netto = netto;
|
||||
$("b-netto").setAttribute("aria-pressed", String(netto));
|
||||
$("b-lordo").setAttribute("aria-pressed", String(!netto));
|
||||
schedule();
|
||||
}
|
||||
|
||||
let timer = null;
|
||||
function schedule() {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(recompute, 110);
|
||||
}
|
||||
|
||||
function opts(netto, paths) {
|
||||
return { lumpEur: state.lump, depEur: state.dep, years: state.anni,
|
||||
tax: netto ? CFG.tax : 0, patrimoniale: netto ? CFG.patrimoniale : 0,
|
||||
paths: paths, seed: SEED };
|
||||
}
|
||||
|
||||
/* ---------------- ciclo principale ---------------- */
|
||||
function recompute() {
|
||||
cur = E.run(opts(state.netto, PATHS_MAIN));
|
||||
curAlt = E.run(opts(!state.netto, PATHS_MAIN));
|
||||
renderTiles(); renderChart(); renderTable(); renderFoot();
|
||||
startSolver();
|
||||
}
|
||||
|
||||
const cap = r => r.med;
|
||||
const lastRow = () => cur[cur.length - 1];
|
||||
|
||||
function crossover(rows) { const r = rows.find(x => x.guadagno > x.versato); return r ? r.anno : null; }
|
||||
function hitYear(rows) { const r = rows.find(x => x.med >= TARGET); return r ? r.anno : null; }
|
||||
|
||||
function renderTiles() {
|
||||
const L = lastRow(), alt = curAlt[curAlt.length - 1];
|
||||
const cx = crossover(cur), hy = hitYear(cur);
|
||||
const t = [
|
||||
{ k: "capitale a " + state.anni + " anni", v: usd(L.med),
|
||||
s: "banda p25–p75 " + usdK(L.p25) + " – " + usdK(L.p75) },
|
||||
{ k: "versato in totale", v: usd(L.versato),
|
||||
s: "il guadagno è il " + Math.round(100 * L.guadagno / L.med) + "% del capitale finale" },
|
||||
{ k: "P(bersaglio) entro " + state.anni + " anni", v: pct(L.phit),
|
||||
s: hy ? "la mediana lo tocca al " + ORD(hy) + " anno" : "la mediana non lo tocca in questa durata" },
|
||||
{ k: "sorpasso", v: cx ? ORD(cx) + "<small> anno</small>" : "—",
|
||||
s: cx ? "l'anno in cui il guadagno supera tutto il versato"
|
||||
: "il guadagno non supera il versato entro la durata" },
|
||||
{ k: state.netto ? "costo del fisco" : "costo del fisco (se acceso)",
|
||||
v: (state.netto ? "−" + Math.round(100 * (1 - L.med / alt.med)) : "−" + Math.round(100 * (1 - alt.med / L.med))) + "%",
|
||||
s: state.netto ? "senza imposte sarebbe " + usd(alt.med) : "col fisco sarebbe " + usd(alt.med) }
|
||||
];
|
||||
$("tiles").innerHTML = t.map(x =>
|
||||
'<div class="tile"><span class="k">' + x.k + '</span><span class="v">' + x.v +
|
||||
'</span><span class="s">' + x.s + "</span></div>").join("");
|
||||
}
|
||||
|
||||
/* ---------------- risolutore: una bisezione per frame, cosi' la pagina resta viva ---------------- */
|
||||
const LIVELLI = [
|
||||
{ p: 0.50, etichetta: "1 percorso su 2", nota: "la mediana arriva" },
|
||||
{ p: 0.75, etichetta: "3 su 4", nota: "margine ragionevole" },
|
||||
{ p: 0.90, etichetta: "9 su 10", nota: "quasi certo, e quasi tutto comprato coi bonifici" }
|
||||
];
|
||||
let solverToken = 0;
|
||||
|
||||
function startSolver() {
|
||||
const token = ++solverToken;
|
||||
const risultati = LIVELLI.map(() => ({ stato: "calcolo", val: null }));
|
||||
renderSolve(risultati);
|
||||
|
||||
let i = 0;
|
||||
function next() {
|
||||
if (token !== solverToken) return; /* un nuovo input ha invalidato la corsa */
|
||||
if (i >= LIVELLI.length) return;
|
||||
const o = opts(state.netto, PATHS_SOLVE);
|
||||
const P = d => E.run(Object.assign({}, o, { depEur: d })).slice(-1)[0].phit;
|
||||
let lo = 0, hi = 20000, step = 0;
|
||||
if (P(hi) < LIVELLI[i].p) { risultati[i] = { stato: "fuori", val: null }; i++; renderSolve(risultati); requestAnimationFrame(next); return; }
|
||||
function bisect() {
|
||||
if (token !== solverToken) return;
|
||||
const mid = 0.5 * (lo + hi);
|
||||
if (P(mid) >= LIVELLI[i].p) hi = mid; else lo = mid;
|
||||
step++;
|
||||
risultati[i] = { stato: step >= 12 ? "ok" : "calcolo", val: 0.5 * (lo + hi) };
|
||||
renderSolve(risultati);
|
||||
if (step < 12) requestAnimationFrame(bisect);
|
||||
else { i++; requestAnimationFrame(next); }
|
||||
}
|
||||
requestAnimationFrame(bisect);
|
||||
}
|
||||
requestAnimationFrame(next);
|
||||
}
|
||||
|
||||
function renderSolve(res) {
|
||||
const L = lastRow();
|
||||
const rows = LIVELLI.map((liv, i) => {
|
||||
const r = res[i];
|
||||
let cella;
|
||||
if (r.stato === "fuori") cella = '<span class="working">fuori portata</span>';
|
||||
else if (r.val === null) cella = '<span class="working">calcolo…</span>';
|
||||
else cella = eur(Math.round(r.val / 5) * 5) + (r.stato === "calcolo" ? ' <span class="working">…</span>' : "");
|
||||
const applicabile = r.stato === "ok" && r.val !== null;
|
||||
return "<tr><td>" + liv.etichetta + "</td><td class='big'>" + cella + "</td><td>" +
|
||||
liv.nota + "</td><td>" + (applicabile
|
||||
? '<button class="apply" data-v="' + Math.round(r.val / 5) * 5 + '">usa</button>'
|
||||
: '<button class="apply" disabled>usa</button>') + "</td></tr>";
|
||||
}).join("");
|
||||
$("solve").innerHTML =
|
||||
"<thead><tr><th>perché il bersaglio sia colpito in " + state.anni + " anni</th>" +
|
||||
"<th>versamento mensile</th><th></th><th></th></tr></thead>" +
|
||||
"<tbody><tr class='now'><td>il piano impostato ora</td><td class='big'>" + eur(state.dep) +
|
||||
"</td><td>dà P = " + pct(L.phit) + " · capitale mediano " + usd(L.med) +
|
||||
"</td><td></td></tr>" + rows + "</tbody>";
|
||||
$("solve").querySelectorAll("button.apply[data-v]").forEach(b =>
|
||||
b.addEventListener("click", () => {
|
||||
const v = Number(b.dataset.v);
|
||||
$("n-dep").value = v; $("r-dep").value = Math.min(Number($("r-dep").max), v);
|
||||
state.dep = v; schedule();
|
||||
}));
|
||||
}
|
||||
|
||||
/* ---------------- grafico ---------------- */
|
||||
const NS = "http://www.w3.org/2000/svg";
|
||||
const el = (n, a) => { const e = document.createElementNS(NS, n); for (const k in a) e.setAttribute(k, a[k]); return e; };
|
||||
const tip = $("tip");
|
||||
|
||||
function showTip(evt, row) {
|
||||
tip.innerHTML = '<span class="t">anno ' + row.anno + " · " + (state.netto ? "col fisco" : "lordo") + "</span>" +
|
||||
'<div class="r"><span><span class="sw" style="background:var(--versato)"></span>versato</span><b>' + usd(row.versato) + "</b></div>" +
|
||||
'<div class="r"><span><span class="sw" style="background:var(--guadagno)"></span>guadagno</span><b>' + usd(row.guadagno) + "</b></div>" +
|
||||
'<div class="r"><span>capitale mediano</span><b>' + usd(row.med) + "</b></div>" +
|
||||
'<div class="r"><span>banda p25–p75</span><b>' + usdK(row.p25) + " – " + usdK(row.p75) + "</b></div>" +
|
||||
'<div class="r"><span>P(bersaglio)</span><b>' + pct(row.phit) + "</b></div>";
|
||||
tip.style.opacity = "1"; moveTip(evt);
|
||||
}
|
||||
function moveTip(evt) {
|
||||
const r = tip.getBoundingClientRect();
|
||||
let x = evt.clientX + 16, y = evt.clientY - r.height - 12;
|
||||
if (x + r.width > window.innerWidth - 8) x = evt.clientX - r.width - 16;
|
||||
if (y < 8) y = evt.clientY + 18;
|
||||
tip.style.left = x + "px"; tip.style.top = y + "px";
|
||||
}
|
||||
const hideTip = () => { tip.style.opacity = "0"; };
|
||||
|
||||
function roundedTop(x, yTop, w, h, r) {
|
||||
if (h <= 0) return "";
|
||||
r = Math.min(r, h, w / 2);
|
||||
return "M" + x + "," + (yTop + h) + "V" + (yTop + r) +
|
||||
"a" + r + "," + r + " 0 0 1 " + r + "," + (-r) + "h" + (w - 2 * r) +
|
||||
"a" + r + "," + r + " 0 0 1 " + r + "," + r + "V" + (yTop + h) + "Z";
|
||||
}
|
||||
|
||||
function renderChart() {
|
||||
const rows = cur;
|
||||
const svg = $("chart");
|
||||
const W = 960, H = 380, M = { t: 26, r: 108, b: 38, l: 78 };
|
||||
const pw = W - M.l - M.r, ph = H - M.t - M.b;
|
||||
svg.setAttribute("viewBox", "0 0 " + W + " " + H);
|
||||
svg.innerHTML = "";
|
||||
|
||||
/* scala verticale dai dati: deve contenere il bersaglio anche quando il piano non lo raggiunge */
|
||||
const rawMax = Math.max(TARGET * 1.05, ...rows.map(r => r.p75));
|
||||
const STEP = [5000, 10000, 25000, 50000, 100000, 150000, 200000, 250000, 500000, 1000000, 2500000]
|
||||
.find(s => rawMax / s <= 8) || 5000000;
|
||||
const YMAX = Math.ceil(rawMax / STEP) * STEP;
|
||||
|
||||
const y = v => M.t + ph - (v / YMAX) * ph;
|
||||
const step = pw / rows.length;
|
||||
const bw = Math.min(38, step * 0.62);
|
||||
|
||||
for (let v = 0; v <= YMAX; v += STEP) {
|
||||
svg.appendChild(el("line", { x1: M.l, x2: M.l + pw, y1: y(v), y2: y(v),
|
||||
stroke: v === 0 ? "var(--ink-3)" : "var(--grid)", "stroke-width": 1 }));
|
||||
const t = el("text", { x: M.l - 12, y: y(v) + 4, "text-anchor": "end", class: "axis",
|
||||
fill: "var(--ink-3)", "font-size": 11 });
|
||||
t.textContent = usdK(v);
|
||||
svg.appendChild(t);
|
||||
}
|
||||
|
||||
const ty = y(TARGET);
|
||||
svg.appendChild(el("line", { x1: M.l, x2: M.l + pw, y1: ty, y2: ty, stroke: "var(--ink-2)",
|
||||
"stroke-width": 1.5, "stroke-dasharray": "5 4" }));
|
||||
const tl = el("text", { x: M.l + 8, y: ty - 8, class: "axis", fill: "var(--ink-2)", "font-size": 11 });
|
||||
tl.textContent = "bersaglio $272.061 — capitale-rendita";
|
||||
svg.appendChild(tl);
|
||||
|
||||
const cx0 = crossover(rows);
|
||||
rows.forEach((row, i) => {
|
||||
const cx = M.l + step * i + step / 2, x = cx - bw / 2;
|
||||
const vTop = y(row.versato), gTop = y(row.med), base = y(0);
|
||||
const g = el("g", { class: "bargroup", tabindex: "0",
|
||||
"aria-label": "anno " + row.anno + ": versato " + usd(row.versato) + ", guadagno " + usd(row.guadagno) });
|
||||
|
||||
g.appendChild(el("path", { d: roundedTop(x, vTop, bw, base - vTop, 0), fill: "var(--versato)" }));
|
||||
const rawH = vTop - gTop, gh = rawH - (rawH > 6 ? 2 : 0);
|
||||
if (gh > 0.3) g.appendChild(el("path", { d: roundedTop(x, gTop, bw, gh, 4), fill: "var(--guadagno)" }));
|
||||
/* un guadagno NEGATIVO (percorso mediano sotto il versato) si disegna sotto la linea del versato */
|
||||
if (rawH < 0) g.appendChild(el("path", { d: roundedTop(x, vTop, bw, -rawH, 0), fill: "var(--guadagno)", opacity: .45 }));
|
||||
|
||||
const p25 = y(row.p25), p75 = y(row.p75);
|
||||
g.appendChild(el("line", { x1: cx, x2: cx, y1: p75, y2: p25, stroke: "var(--surface)", "stroke-width": 4 }));
|
||||
g.appendChild(el("line", { x1: cx, x2: cx, y1: p75, y2: p25, stroke: "var(--ink-2)", "stroke-width": 2, opacity: .85 }));
|
||||
[p25, p75].forEach(yy => g.appendChild(el("line", { x1: cx - 5, x2: cx + 5, y1: yy, y2: yy,
|
||||
stroke: "var(--ink-2)", "stroke-width": 2, opacity: .85 })));
|
||||
|
||||
const hit = el("rect", { class: "hit", x: M.l + step * i, y: M.t, width: step, height: ph, fill: "transparent" });
|
||||
g.appendChild(hit);
|
||||
g.addEventListener("pointerenter", e => showTip(e, row));
|
||||
g.addEventListener("pointermove", moveTip);
|
||||
g.addEventListener("pointerleave", hideTip);
|
||||
g.addEventListener("focus", () => { const b = hit.getBoundingClientRect();
|
||||
showTip({ clientX: b.left + b.width / 2, clientY: b.top + 40 }, row); });
|
||||
g.addEventListener("blur", hideTip);
|
||||
svg.appendChild(g);
|
||||
|
||||
/* con molte barre si etichetta un anno su due, per non impilare i numeri */
|
||||
const passo = rows.length > 20 ? 5 : (rows.length > 12 ? 2 : 1);
|
||||
if (row.anno % passo === 0 || row.anno === rows.length || row.anno === cx0) {
|
||||
const xt = el("text", { x: cx, y: H - 14, "text-anchor": "middle", class: "axis",
|
||||
fill: row.anno === cx0 ? "var(--ink)" : "var(--ink-3)", "font-size": 11 });
|
||||
xt.textContent = row.anno;
|
||||
svg.appendChild(xt);
|
||||
}
|
||||
});
|
||||
|
||||
/* etichette dirette delle serie: l'identita' non e' mai solo colore */
|
||||
const last = rows[rows.length - 1];
|
||||
const lastTop = y(last.med), lastMid = y(last.versato), lx = M.l + pw + 14;
|
||||
[["guadagno", usd(last.guadagno), (Math.min(lastTop, lastMid) + lastMid) / 2, "var(--guadagno)"],
|
||||
["versato", usd(last.versato), (lastMid + y(0)) / 2, "var(--versato)"]
|
||||
].forEach(([nome, val, yy, col]) => {
|
||||
const a = el("text", { x: lx, y: Math.max(20, yy - 2), class: "axis", fill: col, "font-size": 11 });
|
||||
a.textContent = nome; svg.appendChild(a);
|
||||
const b = el("text", { x: lx, y: Math.max(34, yy + 12), class: "axis", fill: "var(--ink)", "font-size": 11.5 });
|
||||
b.textContent = val; svg.appendChild(b);
|
||||
});
|
||||
|
||||
if (cx0) {
|
||||
const ci = cx0 - 1, cRow = rows[ci];
|
||||
const ccx = M.l + step * ci + step / 2, cy = y(cRow.med);
|
||||
svg.appendChild(el("line", { x1: ccx, x2: ccx, y1: cy - 6, y2: cy - 26, stroke: "var(--guadagno)", "stroke-width": 1.5 }));
|
||||
const ct = el("text", { x: ccx, y: cy - 32, "text-anchor": "middle", class: "axis",
|
||||
fill: "var(--guadagno)", "font-size": 11 });
|
||||
ct.textContent = "sorpasso"; svg.appendChild(ct);
|
||||
}
|
||||
|
||||
const ax = el("text", { x: M.l + pw + 14, y: H - 14, class: "axis", fill: "var(--ink-3)", "font-size": 10.5 });
|
||||
ax.textContent = "anno"; svg.appendChild(ax);
|
||||
|
||||
$("ct").innerHTML = "Capitale per anno <span>— " + eur(state.lump) + " iniziali + " +
|
||||
eur(state.dep) + "/mese · " + state.anni + " anni · " +
|
||||
(state.netto ? "col fisco d'accumulo" : "senza imposte") + "</span>";
|
||||
$("figcap").textContent =
|
||||
"Scala verticale dai dati, sempre estesa almeno fino al bersaglio. " +
|
||||
PATHS_MAIN.toLocaleString("it-IT") + " percorsi ricampionati a blocchi di 20 giorni; " +
|
||||
"il guadagno disegnato è quello del percorso mediano, non di un percorso singolo.";
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const body = cur.map((r, i) => {
|
||||
const a = curAlt[i];
|
||||
return "<tr><td>" + r.anno + "</td><td>" + usd(r.versato) + "</td><td>" + usd(r.guadagno) +
|
||||
"</td><td>" + usd(r.med) + "</td><td>" + usdK(r.p25) + "</td><td>" + usdK(r.p75) +
|
||||
"</td><td>" + pct(r.phit) + "</td><td>" + usd(a.med) + "</td></tr>";
|
||||
}).join("");
|
||||
$("tbl").innerHTML = "<thead><tr><th>anno</th><th>versato</th><th>guadagno</th>" +
|
||||
"<th>capitale mediano</th><th>p25</th><th>p75</th><th>P(bersaglio)</th><th>" +
|
||||
(state.netto ? "senza imposte" : "col fisco") + "</th></tr></thead><tbody>" + body + "</tbody>";
|
||||
}
|
||||
|
||||
function renderFoot() {
|
||||
const L = lastRow();
|
||||
$("foot").innerHTML =
|
||||
"Serie: <b>" + CFG.n.toLocaleString("it-IT") + "</b> giorni dal <b>" + CFG.start +
|
||||
"</b> al <b>" + CFG.end + "</b> · cambio <b>EUR/USD " + CFG.eurusd +
|
||||
"</b> · conto di partenza <b>$" + CFG.start_usd + "</b> (quello vero, oggi) · " +
|
||||
"blocchi da <b>" + CFG.block + "</b> giorni · <b>" + PATHS_MAIN.toLocaleString("it-IT") +
|
||||
"</b> percorsi per la curva, <b>" + PATHS_SOLVE.toLocaleString("it-IT") +
|
||||
"</b> per il risolutore — il versamento suggerito porta <b>~±1%</b> di incertezza " +
|
||||
"(bisezione + Monte Carlo), quindi si legge come «circa», non come una cifra esatta. " +
|
||||
"Il risolutore e' stato confrontato con <b>dep_necessario</b> di r0727_tasse.py: scarto " +
|
||||
"−0,4% / −0,6% / −1,1% su tre configurazioni di riferimento. " +
|
||||
"Con questo piano si versano <b>" + usd(L.versato - CFG.start_usd - state.lump * CFG.eurusd) +
|
||||
"</b> di bonifici oltre al capitale iniziale.";
|
||||
}
|
||||
|
||||
recompute();
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,84 @@
|
||||
/* smoke.js — esegue DAVVERO lo script di una pagina, con un DOM finto.
|
||||
*
|
||||
* Perche' serve: `node --check` valida la SINTASSI e non tocca l'esecuzione. Ho pubblicato una
|
||||
* pagina che passava il controllo di sintassi e moriva alla prima riga utile, perche' le chiavi
|
||||
* dei pesi arrivavano da Python come "0.0"/"1.0" e la pagina le ricostruiva con String(0) -> "0".
|
||||
* Un test che non esegue non vede niente di tutto cio'.
|
||||
*
|
||||
* node smoke.js pagina.html [...]
|
||||
*/
|
||||
const fs = require("fs");
|
||||
|
||||
function makeEl(tag) {
|
||||
const el = {
|
||||
tagName: tag, _html: "", textContent: "", value: "", dataset: {},
|
||||
style: new Proxy({}, { get: () => "", set: () => true }),
|
||||
children: [], attributes: {}, min: "0", max: "1000000",
|
||||
setAttribute(k, v) { this.attributes[k] = v; },
|
||||
getAttribute(k) { return this.attributes[k]; },
|
||||
appendChild(c) { this.children.push(c); return c; },
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
getBoundingClientRect() { return { left: 0, top: 0, width: 100, height: 40, right: 100, bottom: 40 }; },
|
||||
insertAdjacentHTML(pos, html) { this._html += html; },
|
||||
querySelectorAll() { return { forEach() {} }; },
|
||||
querySelector() { return makeEl("div"); },
|
||||
contains() { return false; },
|
||||
focus() {}
|
||||
};
|
||||
Object.defineProperty(el, "innerHTML", {
|
||||
get() { return this._html; },
|
||||
set(v) { if (v === undefined || String(v).includes("undefined")) el._undef = true; this._html = String(v); }
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
const known = new Map();
|
||||
global.document = {
|
||||
_byId: known,
|
||||
getElementById(id) {
|
||||
if (!known.has(id)) known.set(id, makeEl("div"));
|
||||
return known.get(id);
|
||||
},
|
||||
createElementNS(ns, tag) { return makeEl(tag); },
|
||||
createElement(tag) { return makeEl(tag); },
|
||||
querySelectorAll() { return { forEach() {} }; },
|
||||
addEventListener() {}
|
||||
};
|
||||
global.window = { innerWidth: 1400, innerHeight: 900, addEventListener() {} };
|
||||
global.requestAnimationFrame = fn => { /* una sola passata: basta per vedere se esplode */ };
|
||||
global.setTimeout = (fn, ms) => { try { fn(); } catch (e) { throw e; } return 0; };
|
||||
global.clearTimeout = () => {};
|
||||
|
||||
let bad = 0;
|
||||
for (const file of process.argv.slice(2)) {
|
||||
known.clear();
|
||||
const html = fs.readFileSync(file, "utf8");
|
||||
const blocchi = [...html.matchAll(/<script(?:\s[^>]*)?>([\s\S]*?)<\/script>/g)];
|
||||
/* i blocchi type="application/json" sono DATI: il DOM finto deve restituirli */
|
||||
for (const m of blocchi) {
|
||||
const tagAttr = m[0].slice(0, m[0].indexOf(">"));
|
||||
const idm = tagAttr.match(/id="([^"]+)"/);
|
||||
if (tagAttr.includes("application/json") && idm) {
|
||||
const el = document.getElementById(idm[1]);
|
||||
el.textContent = m[1];
|
||||
}
|
||||
}
|
||||
const code = blocchi.filter(m => !m[0].includes("application/json")).map(m => m[1]).join("\n");
|
||||
try {
|
||||
new Function(code)();
|
||||
/* un id mai riempito, o riempito con "undefined", e' un difetto silenzioso */
|
||||
const vuoti = [...known.entries()].filter(([, e]) => e._undef);
|
||||
if (vuoti.length) {
|
||||
console.log("⚠ " + file + " — gira, ma scrive 'undefined' in: " +
|
||||
vuoti.map(([k]) => k).join(", "));
|
||||
bad++;
|
||||
} else {
|
||||
console.log("✓ " + file + " — esegue senza errori (" + known.size + " elementi toccati)");
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("✗ " + file + " — " + e.constructor.name + ": " + e.message);
|
||||
bad++;
|
||||
}
|
||||
}
|
||||
process.exit(bad ? 1 : 0);
|
||||
@@ -0,0 +1,84 @@
|
||||
/* test_engine.js — il motore JS deve riprodurre r0807_growth_yearly.py.
|
||||
*
|
||||
* Non si confrontano i semi (non sono confrontabili fra linguaggi): si confrontano i RISULTATI
|
||||
* entro il rumore Monte Carlo, su due configurazioni gia' calcolate in Python, e si controlla
|
||||
* che le identita' deterministiche (il versato) coincidano ESATTAMENTE — quelle non hanno rumore.
|
||||
*
|
||||
* ⚠️ TRAPPOLA, in cui sono caduto scrivendo questo test: il riferimento Python e' UNA estrazione
|
||||
* (seme 807), non un valore vero. Confrontandoci 8 estrazioni JS ho visto "8/8 sopra il
|
||||
* riferimento, media +0.75%" e l'ho letto come distorsione sistematica — ma 8 segni concordi
|
||||
* rispetto a UN punto rumoroso non sono 8 osservazioni indipendenti: dicono solo che quel punto
|
||||
* sta in basso. Misurato per bene (8 semi PER LINGUAGGIO, distribuzione contro distribuzione):
|
||||
* differenza delle medie -0.01%, t = -0.04, e i due campionatori cadono entrambi entro 1.7 SE
|
||||
* dall'atteso ANALITICO della media di blocco. Le tolleranze qui sotto sono percio' tarate sul
|
||||
* rumore di UN confronto punto-a-punto (~1 SE = 0.7%), non su una distorsione che non esiste.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const { makeEngine } = require("./engine.js");
|
||||
|
||||
const CFG = JSON.parse(fs.readFileSync("series.json", "utf8"));
|
||||
const E = makeEngine(CFG);
|
||||
|
||||
/* valori di riferimento prodotti da Python (4.000 path, seme 807) */
|
||||
const REF = {
|
||||
"500": { dep: 500, py: JSON.parse(fs.readFileSync("growth.json", "utf8")) },
|
||||
"800": { dep: 800, py: JSON.parse(fs.readFileSync("growth800.json", "utf8")) }
|
||||
};
|
||||
|
||||
const PATHS = 4000;
|
||||
let fail = 0;
|
||||
const pct = x => (100 * x).toFixed(1) + "%";
|
||||
|
||||
for (const nome of Object.keys(REF)) {
|
||||
const { dep, py } = REF[nome];
|
||||
for (const regime of ["lordo", "netto"]) {
|
||||
const js = E.run({ lumpEur: 5000, depEur: dep, years: 15,
|
||||
tax: regime === "netto" ? CFG.tax : 0,
|
||||
patrimoniale: regime === "netto" ? CFG.patrimoniale : 0,
|
||||
paths: PATHS, seed: 20260807 });
|
||||
console.log(`\n--- €${dep}/mese · ${regime} ---`);
|
||||
console.log("anno versato PY versato JS med PY med JS scarto P PY P JS");
|
||||
for (const y of [1, 5, 10, 15]) {
|
||||
const a = py[regime][y - 1], b = js[y - 1];
|
||||
const capPy = a.versato + a.guadagno_med;
|
||||
const d = capPy > 0 ? b.med / capPy - 1 : 0;
|
||||
/* il versato e' deterministico: deve coincidere al centesimo */
|
||||
const vOk = Math.abs(a.versato - b.versato) < 0.01;
|
||||
/* le mediane hanno rumore Monte Carlo: tolleranza 3% */
|
||||
const mOk = Math.abs(d) < 0.03;
|
||||
/* le probabilita' hanno SE <= 0.8pp a 4.000 path: tolleranza 3pp */
|
||||
const pOk = Math.abs(a.p_traguardo - b.phit) < 0.03;
|
||||
if (!vOk || !mOk || !pOk) fail++;
|
||||
console.log(
|
||||
String(y).padStart(4) +
|
||||
("$" + Math.round(a.versato).toLocaleString("en-US")).padStart(13) +
|
||||
("$" + Math.round(b.versato).toLocaleString("en-US")).padStart(14) +
|
||||
("$" + Math.round(capPy).toLocaleString("en-US")).padStart(12) +
|
||||
("$" + Math.round(b.med).toLocaleString("en-US")).padStart(12) +
|
||||
(d >= 0 ? "+" : "") + (100 * d).toFixed(1).padStart(9) + "%" +
|
||||
pct(a.p_traguardo).padStart(8) + pct(b.phit).padStart(8) +
|
||||
(vOk && mOk && pOk ? "" : " <-- FUORI TOLLERANZA"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* controllo positivo: il test deve saper fallire. Un motore con l'imposta spenta quando
|
||||
dovrebbe essere accesa deve risultare FUORI tolleranza rispetto al riferimento netto. */
|
||||
const rotto = E.run({ lumpEur: 5000, depEur: 500, years: 15, tax: 0, patrimoniale: 0,
|
||||
paths: 1500, seed: 1 });
|
||||
const refNetto15 = REF["500"].py.netto[14];
|
||||
const capRef = refNetto15.versato + refNetto15.guadagno_med;
|
||||
const scartoRotto = rotto[14].med / capRef - 1;
|
||||
console.log("\ncontrollo positivo (motore volutamente sbagliato: fisco spento): scarto " +
|
||||
(100 * scartoRotto).toFixed(1) + "% -> " +
|
||||
(Math.abs(scartoRotto) > 0.03 ? "rilevato, il test sa fallire" : "NON RILEVATO: il test e' cieco"));
|
||||
if (Math.abs(scartoRotto) <= 0.03) fail++;
|
||||
|
||||
/* velocita': l'interfaccia deve restare utilizzabile */
|
||||
const t0 = Date.now();
|
||||
E.run({ lumpEur: 5000, depEur: 500, years: 20, tax: CFG.tax,
|
||||
patrimoniale: CFG.patrimoniale, paths: 3000, seed: 5 });
|
||||
console.log("\n20 anni x 3.000 path: " + (Date.now() - t0) + " ms");
|
||||
|
||||
console.log(fail === 0 ? "\nOK — il motore JS riproduce il Python" : "\n" + fail + " CONTROLLI FALLITI");
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
Reference in New Issue
Block a user