ed2532b991
scripts/web/ — motore in JavaScript (engine.js) sui ritorni VERI del book esportati da export_series.py, pagine assemblate da build.py. Da "sistema dinamico dove imposti valore iniziale, mensile e durata, e calcola la best curva fissando il tempo": la simulazione gira nel browser, quindi il motore va riscritto e VA PROVATO che dia la stessa risposta. test_engine.js confronta mediane e probabilita' con r0807_growth_yearly.py su due configurazioni, esige che il versato (deterministico) coincida al centesimo, e include un controllo positivo — un motore col fisco spento DEVE risultare fuori tolleranza, perche' un test che non sa fallire non e' un test. Il risolutore e' validato contro dep_necessario di r0727_tasse.py: -0.4 / -0.6 / -1.1%. smoke.js ESEGUE le pagine con un DOM finto. Serve perche' node --check valida solo la sintassi: ho pubblicato una pagina che lo passava e moriva alla prima riga utile (chiavi Python "0.0" ricostruite in JS come String(0) = "0"; i pesi intermedi funzionavano per caso). Altri due errori che questi strumenti hanno intercettato: - due anni di dati di un grafico scritti A MEMORIA perche' tail aveva troncato l'output -> ora i dati si INIETTANO da JSON (build.py), il passaggio manuale non esiste piu'; - una "distorsione sistematica" del motore JS (+0.75%, 8 semi tutti positivi) che erano 8 estrazioni contro UN punto Python rumoroso. Misurato bene, 8 semi per parte: -0.01%, t = -0.04; ed entrambi i campionatori cadono entro 1.7 SE dall'atteso ANALITICO della media di blocco. Scelta guidata da una misura: la banda del versamento suggerito resta +-1% da 1.200 a 3.000 percorsi -> non domina il Monte Carlo ma la granularita' della bisezione (~5 EUR). Percorsi tenuti bassi e incertezza dichiarata, invece di pagare tempo per una precisione che non arriva. Nessun impatto sulla produzione: non tocca book, pesi, cron o config. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
121 lines
4.9 KiB
JavaScript
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 };
|