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

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

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

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

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

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

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

121 lines
4.9 KiB
JavaScript

/* 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 };