diff --git a/scripts/web/build.py b/scripts/web/build.py new file mode 100644 index 0000000..44073d1 --- /dev/null +++ b/scripts/web/build.py @@ -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() diff --git a/scripts/web/engine.js b/scripts/web/engine.js new file mode 100644 index 0000000..3ef4dfa --- /dev/null +++ b/scripts/web/engine.js @@ -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 }; diff --git a/scripts/web/export_series.py b/scripts/web/export_series.py new file mode 100644 index 0000000..27b5754 --- /dev/null +++ b/scripts/web/export_series.py @@ -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() diff --git a/scripts/web/pagina_confronto.html b/scripts/web/pagina_confronto.html new file mode 100644 index 0000000..28dc598 --- /dev/null +++ b/scripts/web/pagina_confronto.html @@ -0,0 +1,489 @@ +Da dove viene il guadagno — e se invece fosse un ETF + + + +
+ +
+

PythagorasGoal · 2026-08-07

+

Da dove viene il guadagno, e se invece fosse un ETF

+

+ Due domande. La prima: quel guadagno come si calcola? Non è un tasso ipotizzato — è + quello che producono i ritorni veri del book, ricampionati. La seconda: e se gli stessi + soldi andassero su un S&P 500 o un MSCI World? +

+

+
+ +
+

1 · Come si calcola il guadagno

+

+ 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. +

+
+ +
+ +
+

+ Il rendimento non è un'ipotesi, ma la scelta della sequenza sì: 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. +

+
+ +
+

2 · I tre motori

+

+ Stesso piano, stessa macchina, tre sorgenti di ritorni. Le azioni vanno su griglia di + calendario con 0,0 nei giorni di borsa chiusa — 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. +

+
+ +
+ +
+

+ La riga che conta non è la prima, è la seconda: sulla stessa finestra il book e + l'S&P 500 hanno quasi lo stesso rendimento — . Tutta la + differenza è nel rischio: volatilità e drawdown massimo + . Il book non guadagna di più: perde di meno. +

+
+ +
+

Capitale mediano per anno — ciascuno ricampionato dalla PROPRIA storia piena

+
+
+
+
+ +
+

Capitale mediano per anno — tutti e tre ricampionati dalla STESSA finestra

+
+
+
+ +
+

3 · Il bersaglio non è lo stesso per tutti

+

+ $272.061 è il capitale che serve con la rendita perpetua del book e con l'aliquota del + 33%. 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. +

+
+ +
+ +
+

4 · La banda, non il numero

+

+ 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. +

+
+ +
+ +
+

5 · La lettura onesta

+

+

+ Il fisco è modellato diverso perché è 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 liquidazione, già al netto dell'imposta latente). + Sono assunzioni dichiarate e non sono un parere fiscale. +

+
+ +
+ Tabella completa — capitale mediano anno per anno, entrambe le lenti +
+
+ + +
+ +
+ + + diff --git a/scripts/web/pagina_crescita.html b/scripts/web/pagina_crescita.html new file mode 100644 index 0000000..1cc90ad --- /dev/null +++ b/scripts/web/pagina_crescita.html @@ -0,0 +1,531 @@ +Crescita del capitale: quanto è versamento e quanto è guadagno + + + +
+ +
+

PythagorasGoal · piano di accumulo · 2026-08-07

+

Quanto è versamento e quanto è guadagno

+

+ Il piano con €800 al mese — 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. +

+

+ book live TP01 + SKH01 75/25 alle ancore canoniche, de-luckato ×0.89 · + block bootstrap 4.000 path, blocchi da 20 giorni · + bersaglio $272.061 (capitale-rendita per €50/giorno netti) · + scripts/research/r0807_growth_yearly.py --dep 800 +

+
+ +
+

La forma della curva

+

+ Per i primi sette anni il capitale è 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' anno + nel modello lordo e all'11º col fisco dentro. Da lì la curva + cambia natura: smette di essere un piano di risparmio e diventa un capitale che lavora. +

+

+ Versare di più non 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 traguardo, non il momento in cui la strategia prende il + sopravvento: la mediana tocca il bersaglio al 12º anno col + fisco, contro il 15º a €500. +

+
+ +
+ +
+
+

Modello lordo — nessuna imposta durante l'accumulo

+ +
+
+ +
+

Con il fisco d'accumulo — plusvalenze 33% annue + patrimoniale 0,2%

+
+
+ Stessa scala verticale nei due grafici: la differenza di altezza è 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. +
+
+
+ +
+

Cosa comprano i €300 in più

+

+ Il confronto è a parità di tutto il resto — stesso book, stessi path, stesso lump da €5.000 — + e va letto sulla riga della probabilità, non su quella del capitale: la mediana dice + dove finisce metà dei percorsi, la probabilità dice quanti ci arrivano. +

+
+ +
+ +
+

Il vincolo dei dieci anni

+

+ Anche €800 al mese non bastano per il traguardo in dieci anni: la probabilità col fisco + dentro è 25,5%. Serve circa €880–1.050 al mese — e a quel punto si + sono versati oltre $119.000 per arrivare a $272.061, cioè a orizzonte corto non fai + lavorare la strategia, compri il capitale coi bonifici. +

+

+ L'imposta durante l'accumulo non era mai stata contata in nessuna traiettoria + del progetto: l'aliquota compariva in un solo punto, la lordizzazione del bersaglio in fase di + prelievo. L'errore è composto — −15% di capitale a 5 anni, + −29% a 10, −42% 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 non sono un + parere fiscale. +

+
+ +
+ Tabella completa — i due regimi, anno per anno, a €800/mese +
+ + +
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.
+
+
+ + + +
+ +
+ + diff --git a/scripts/web/pagina_scelta.html b/scripts/web/pagina_scelta.html new file mode 100644 index 0000000..0567b7e --- /dev/null +++ b/scripts/web/pagina_scelta.html @@ -0,0 +1,393 @@ +Quale strategia — book, ETF o un mix + + + +
+
+

PythagorasGoal · scelta di portafoglio · 2026-08-07

+

Quale strategia, a 12 anni

+

+

+
+ +
+

1 · Perché la domanda non ha una risposta sola

+

+

+
+ +
+ +
+

Quota del proprio capitale-rendita raggiunta — per peso sul book, in ciascuno scenario

+
+
+
+
+ +
+

2 · La tabella della decisione

+

+
+ +
+ +
+

3 · Perché il mix non è un compromesso

+

+
+ +
+ +
+

4 · La risposta

+

+

+
+ + +
+ +
+ + + diff --git a/scripts/web/pagina_simulatore.html b/scripts/web/pagina_simulatore.html new file mode 100644 index 0000000..6a84082 --- /dev/null +++ b/scripts/web/pagina_simulatore.html @@ -0,0 +1,602 @@ +Simulatore del piano di accumulo — PythagorasGoal + + + +
+ +
+

PythagorasGoal · simulatore del piano · 2026-08-07

+

Imposta il piano, guarda la curva

+

+ Capitale iniziale, versamento mensile e durata. La simulazione gira qui dentro, 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à. +

+

+ book live TP01 + SKH01 75/25, de-luckato ×0.89 · bersaglio $272.061 + (capitale-rendita per €50/giorno netti) · motore verificato contro + r0807_growth_yearly.py: differenza fra le medie −0,01% su 8 semi per parte +

+
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
anni
+ +
+
+ +
+ + +
+

+ «col fisco» = plusvalenze 33% annue + patrimoniale 0,2%. È il regime realistico. +

+
+
+ +
+ +
+

La curva migliore a tempo fissato

+

+ Fissata la durata, il versamento mensile è l'unica leva che resta: il rendimento non + si comanda e il capitale iniziale è quello che è. Qui sotto il versamento cercato per + bisezione — lo stesso metodo di dep_necessario in r0727_tasse.py — perché la + probabilità di toccare il bersaglio entro la durata impostata arrivi a 1 su 2, 3 su 4, 9 su 10. +

+
+ +
+ +
+

Capitale per anno

+ +
+
+
+ +
+ Tabella completa — anno per anno +
+
+ +
+

Cosa questo strumento non sa

+

+ Il «guadagno» disegnato è mediana(capitale) − versato: 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. +

+

+ Non c'è il rischio di venue: a p=1% annuo di fallimento dell'exchange la + probabilità di arrivare scende di ~2 punti e quella di perdere tutto 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%. +

+

+ Le assunzioni fiscali (33% sulle plusvalenze, minusvalenze riportabili 4 anni, 0,2% annuo sul + valore) sono dichiarate e non sono un parere fiscale; 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 l'unico campione che abbiamo, ricampionato. +

+
+ + +
+ +
+ + + diff --git a/scripts/web/smoke.js b/scripts/web/smoke.js new file mode 100644 index 0000000..c3c5b88 --- /dev/null +++ b/scripts/web/smoke.js @@ -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(/]*)?>([\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); diff --git a/scripts/web/test_engine.js b/scripts/web/test_engine.js new file mode 100644 index 0000000..4ae16d9 --- /dev/null +++ b/scripts/web/test_engine.js @@ -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);