A high-performance, browser-based retirement forecasting engine built with Svelte 5 and powered by Markov regime-switching Monte Carlo simulation with historical bootstrap resampling. All computation runs in a custom-built Rust WebAssembly (Wasm) engine mounted in a Web Worker to keep the UI perfectly responsive during 100,000+ simulation runs.
The calculator projects portfolio balances from a starting age through a configurable end-of-life horizon using monthly time steps, resolving all outputs into real (inflation-adjusted) terms. It generates probability-weighted outcome bands (P10–P90), estimates ruin probability, computes Financial Independence (FI) targets, and visualizes sequence-of-returns risk.
Nothing you enter leaves your browser. The app is a static bundle with no backend,
no analytics, no telemetry and no cookies. The only network request it makes at runtime is
for its own assets/historical-market-data.json, served from the same origin.
Fonts are self-hosted under static/fonts/ (Inter and JetBrains Mono, both SIL OFL, licence
files included) rather than loaded from a CDN, so no third party observes a page load.
Charts use a custom Plotly bundle (src/lib/plotly.ts), not a prebuilt distribution:
plotly.js/lib/core plus exactly the four traces the app draws — scatter, bar, heatmap and
contour. Everything else Plotly can render is never imported, and so is never in the output.
That excludes the geo, mapbox and gl modules, which is where the full build's map-tile hosts
(mapbox, OpenStreetMap, carto, openmaptiles) live — removed from the shipped output rather
than relying on them never being reached. The bundle is about 1.12 MB (384 KB gzipped), against
4.7 MB for the full distribution and 1.42 MB for the prebuilt cartesian one this replaced.
What remains in it is one inert schema default (topojsonURL, consumable only by geo traces
that are not in the bundle) plus licence and XML-namespace strings. The other external URLs in
the build are ordinary <a href> links to the project repository, the author's home page,
and data sources in the methodology panel; they are contacted only if you click them.
"Copy share link" encodes your inputs into a URL fragment (#s=…). Fragments are not
transmitted to servers, so a shared link stays between you and whoever you send it to — but
it does contain your figures, so treat it as you would the numbers themselves.
| Aspect | Approach |
|---|---|
| Return generation | Regime-conditioned block bootstrap from historical data, with parametric Cornish-Fisher fallback. Optional forward-looking means anchored to the latest embedded 2025–2026 yields (§4.4.1) |
| Inflation | Bootstrapped jointly with returns from the same historical month, preserving their correlation and inflation persistence; regime-conditioned parametric draws as fallback (§5.3) |
| Cash flows | Age-gated income/spending periods with real/nominal controls; lump-sum events entered in today's purchasing power |
| Withdrawals | Fixed real spending, simplified withdrawal-rate guardrails inspired by Guyton-Klinger, or percent-of-portfolio (§5.1.1) |
| Reproducibility | Optional seeded PRNG (mulberry32); default unseeded behavior when no seed is provided |
| Output | Percentile fan chart, FI targets (schedule-aware SWR and simulation-based P95), ruin-surface heatmap, and sequence-risk quintile analysis |
| Multi-asset | Three-asset allocation (stocks / bonds / cash) with configurable equity-bond correlation |
| Performance | All simulation runs in a Web Worker with real-time progress reporting; nothing simulates on the main thread (the old noisy live preview was replaced by the stale-results flow, §2) |
The interface uses progressive disclosure: the main calculator describes the decision a number supports, while the precise statistical or financial term remains available in an advanced section, tooltip, or this methodology. This changes presentation, not the model or its definitions.
| Main interface | Technical term retained in advanced/methodology text |
|---|---|
| Spending-rule estimate | Safe withdrawal rate (SWR) target |
| Simulation-based estimate (95% success) | P95 FI target |
| Age you could stop regular saving | Coast FIRE age |
| Lower / upper 10% of outcomes | P10 / P90 percentiles |
| Historical replay length | Bootstrap block length |
| Historical data with adjustments | Moment-targeted block bootstrap |
| Return distribution diagnostics | Mean, volatility, skewness, kurtosis and CAGR |
Skewness and kurtosis are model-shape controls and diagnostics, not ordinary planning inputs. They therefore appear only after opening Advanced assumptions or the advanced return diagnostics. Likewise, acronyms such as FIRE, SWR and P95 are not required to read the headline results. Technical names remain here so calculations can be audited and discussed without weakening their definitions.
scripts/
import-retirement-market-data.mjs ← Fetches raw monthly prices from Stooq + FRED
preprocess-retirement-market-data.mjs ← Converts to annual/monthly return series + moments
data/retirement/raw/*.csv ← Monthly equity/bond closes, cash rate, CPI, bond yield per region
static/assets/
historical-market-data.json ← Preprocessed returns consumed at runtime
(SvelteKit serves `static/`; this is the path
the planner fetches at `/assets/…`)
rust-engine/
src/ ← Rust source code for the Monte Carlo engine
lib.rs ← WASM entry point (wasm-bindgen exports)
simulation.rs ← Main simulation loop with reservoir sampling
calculations.rs ← Math abstractions, RNG (mulberry32/random), percentiles
engine.rs ← Markov models, regime state transitions, output structs
engine2.rs ← Regime detection, bootstrap pooling, cashflow arrays
stats.rs ← Sequence risk analysis, ruin surface replay
structs.rs ← Svelte↔Rust API boundary types (serde)
pkg/ ← Compiled WebAssembly outputs (wasm-pack build --target web)
src/lib/
retirementWorker.ts ← Web Worker calling `run_monte_carlo` via wasm-bindgen
workerHelper.ts ← Worker construction helper
retirementEngine.ts ← TypeScript reference engine (see §12) + shared types/validation
calculations.ts ← Portfolio blending, moments, correlation, PRNG, dataset types
RetirementPlanner.svelte ← Main application state, Worker controller, Plotly integration
components/
PlannerInputPanel.svelte ← All user inputs (Svelte 5 $props + $bindable)
PlannerOutputCards.svelte ← Summary metric cards
PlannerTimelinePlot.svelte ← Plotly fan chart
PlannerSecondaryPlot.svelte ← Ruin surface heatmap + sequence risk chart
retirementEngine.test.ts ← Engine unit + regression tests
enginesParity.test.ts ← TS ↔ Rust cross-engine parity suite (§12)
The application relies entirely on the high-performance Web Worker for all simulations, prioritizing precision over noisy live previews:
- Initial Baseline Run: On load, a 20,000-path baseline simulation runs automatically to populate the charts rapidly using the Rust engine.
- Stale State Protection: When any input changes, the UI charts gray out (stale state) and a warning banner appears. This explicitly prevents confusion from stale data while avoiding the extreme statistical noise (±30% jumps in ending balance) typical of small-sample "live" previews.
- Full Simulation: The user clicks "Run Monte Carlo" to trigger a new simulation. The Web Worker initializes the WASM module via
init(), invokesrun_monte_carlo()with a progress callback (~10 incremental updates), executes over contiguous heap memory without garbage collection, and structured-clones back only the aggregated result — theSummaryStats(a few KB) plus the five percentile bands (5 × months floats, tens of KB). The per-path balances, which would run to hundreds of megabytes, never leave the worker.
All Svelte components use Svelte 5 runes ($props, $effect, $state, $derived, $bindable).
| Region | Equity | Bond | Cash |
|---|---|---|---|
| USD | S&P 500 ^SPX price index, plus estimated dividends |
Synthetic 10Y total return from GS10, duration 7y |
TB3MS |
| GBP | FTSE 100 ^UKX price index, plus estimated dividends |
Synthetic UK 10Y total return from IRLTLT01GBM156N, duration 7y |
IR3TIB01GBM156N |
| EUR | 60% DAX + 40% CAC, with a 3% estimated CAC dividend | Synthetic German 10Y total return from IRLTLT01DEM156N, duration 7y |
Euro-area 3m rate stitched with German pre-euro |
| WORLD | 55% US + 15% EUR + 5% UK + 15% Japan (^NKX) + 10% Asia/EM (^HSI, backfilled with Nikkei). Every foreign equity leg is converted to USD. |
Weighted US/UK/German 10Y bond returns | Average of US/UK/EUR cash rates |
Regional CPI comes from FRED: CPIAUCSL
for USD/WORLD, GBRCPIALLMINMEI
for GBP, and euro-area CP0000EZ19M086NEST
stitched to German DEUCPIALLMINMEI
before 1997. World equity FX conversion uses monthly FRED series
EXUSUK,
EXGEUS,
EXUSEU,
EXJPUS, and
EXHKUS. Constant approximations to the
prevailing Bretton Woods parities fill the pre-1971 GBP and German-mark FX history; they
should not be read as a reconstruction of every historical parity adjustment.
^SPX, ^UKX, ^NKX and ^HSI are price indices. To approximate total returns,
the preprocessing step adds decade-level synthetic dividend yields to the price-only
equity series (geometric monthly convention,
| Decade | USD | GBP | WORLD (blended) |
|---|---|---|---|
| 1960s | 3.1% | 5.0% | 2.9% |
| 1970s | 4.1% | 5.5% | 3.2% |
| 1980s | 4.3% | 4.5% | 3.1% |
| 1990s | 2.5% | 3.8% | 2.0% |
| 2000s | 1.8% | 3.3% | 1.7% |
| 2010s | 2.0% | 3.8% | 1.9% |
| 2020s | 1.5% | 3.7% | 1.7% |
Sources: Shiller S&P 500 dividend data (US), Barclays Equity Gilt Study ranges (UK). The WORLD schedule is the component-weighted blend of US/UK/JP/HK yields; its EUR share contributes zero because the EUR proxy is already total-return at import time (DAX is TR by construction; CAC receives +3%/yr in the import script). The EUR region therefore receives no adjustment in preprocessing. Resulting nominal equity moments (1961–2025): US 12.0% arithmetic / 10.6% geometric — consistent with published S&P 500 total-return figures for that window.
Monthly bond returns are computed from yield changes using a duration + convexity model:
where
- Monthly equity returns:
$P_t / P_{t-1} - 1$ plus the synthetic dividend yield (§3.1.1) - Annual returns: compound product of 12 monthly returns within each calendar year
- Cash: monthly return = annual rate / 1200
- All series anchored to 1960+
- Month contiguity is asserted per region — a mid-series gap aborts preprocessing
- Statistical moments (mean, σ, skewness, kurtosis) computed with population formulas
Two market regimes: State 0 (Growth) and State 1 (Crisis).
Transition matrix:
Stationary distribution:
Initial state is drawn from the stationary distribution. Default USD values:
When historical returns are available (≥ 25 annual or ≥ 120 monthly observations), regimes are detected empirically:
- Compute long-run mean
$\mu$ and standard deviation$\sigma$ - Crisis threshold:
$\mu - 0.65\sigma$ (annual) or$\mu - 0.75\sigma$ (monthly) - A period is labelled crisis if:
- Its return falls below the crisis threshold, or
- Rolling 3-year (annual) / 6-month (monthly) volatility exceeds
$1.15\sigma$ (annual) /$1.2\sigma$ (monthly)
- Single non-crisis periods sandwiched between two crises are reclassified as crisis (gap-filling)
- Markov stay-probabilities are estimated by counting consecutive same-state transitions
After detection, returns are partitioned into growth and crisis pools for block bootstrap sampling.
The three simulation-mode buttons in Advanced assumptions map onto the sampling modes below. The distinction between the first two is the one users most often miss, so it is stated explicitly here and in the UI:
| UI button | Sampling | Return moments | Inflation | Use it when |
|---|---|---|---|---|
| Historical Data Sampling | Mode A block bootstrap of real months | Taken from history — the assumption table is read-only | Joint: read from the same real month as the return (§5.3 Mode A) | You want the most faithful replay of what markets actually did |
| Historical (with Adjustments) | Mode A block bootstrap of the same real months | Your targets — the sampled series is affine-shifted to your mean/σ (§4.4) | Falls back to the modelled parametric draw, because the shift breaks the month-for-month pairing | You want history's shape but different average returns — "what if the next 30 years are worse?" |
| Parametric (User Inputs) | Mode C — no historical data at all | Your inputs, via the regime-switching generator | Modelled parametric draw | You want a purely hypothetical market |
The "Use 2025–2026 yields" preset (§4.4.1) switches to Historical (with Adjustments), because anchoring returns to current yields is exactly a moment-targeting operation.
Mode A — Monthly Circular Block Bootstrap (≥ 120 monthly data points):
- A contiguous block of
blockLengthmonths (default 6, see below) is drawn from the current regime's pool; sequential months within the block preserve autocorrelation, momentum and volatility clustering - The block index wraps (
% length), making this a circular block bootstrap (Politis & Romano), whose defining property is that the resampled mean is unbiased for the sample mean — unlike the moving block bootstrap, which under-samples the ends - The Markov chain transitions each month but is only consulted at block boundaries, to choose which pool the next block comes from
How much is the regime layer actually doing? Very little — see the note below.
Fixed 2026-07-21 (TODO 0.11). The block used to be abandoned whenever the regime switched, not only when it ran out. Because a freshly drawn block always starts on a month matching the new regime, and crisis runs are shorter than growth runs, crisis months were over-drawn by 1.06–1.09×, costing 0.64–1.29pp/yr of return depending on the region. Letting blocks finish removed the bias — simulated returns now track the source series to within ±0.07pp/yr across all four regions — and also improved preservation of autocorrelation (EUR lag-1: source 0.064, old sampler 0.038, now 0.053), since blocks cut short carry less of the clustering they exist to reproduce.
The cost is that a regime switch is felt at the next block boundary rather than immediately, a lag of at most
blockLengthmonths. A regression test pins the mean-preserving property.
blockLength is the main control over how long historical runs remain intact. There is no
single estimator that makes it optimal for every retirement output. As one diagnostic,
scripts/analyze-block-length.mjs (pnpm run data:retirement:blocklength) implements the
automatic block-length selection of Politis & White (2004) with the Patton, Politis &
White (2009) correction. Exact tests cover the corrected m_hat convention, fallback
behaviour, invalid inputs, and the published theoretical AR(1) circular-bootstrap values.
On the shipped data PWSD returns a median of 2.9 months (range 1.0–12.7 across regions and allocations). That range describes different time series; it is not a confidence interval for a universal default. The current default remains 6 months as a provisional, slightly longer baseline, not because PWSD selects six.
Note for readers comparing against other tools: practitioner planning tooling sometimes uses much longer blocks (e.g. a 120-month average with the stationary bootstrap). That is not a contradiction — PWSD optimises long-run-variance estimation for a statistic, whereas a retirement planner cares about drawdown duration, sequence risk and ruin. These monthly series carry little short-lag autocorrelation (
m_hatis 1 almost everywhere), so PWSD's answer is short for its objective. It does not prove that short blocks reproduce multi-year retirement paths. Re-run it when data changes and treat length as a sensitivity.
Before calling the default calibrated, compare a grid such as 3/6/12/24/60/120 months on source-versus-resampled rolling 1/3/5/10-year return tails, drawdown depth and duration, and representative-plan success/FI-target sensitivity. Material variation is model uncertainty, not Monte Carlo sampling noise.
Measured limitation of the regime layer in Mode A (2026-07-21). After removing the sampling bias, the regime-conditioned bootstrap was compared against a plain circular block bootstrap with no regime conditioning at all, on the shipped data. They are statistically indistinguishable — mean, crisis-month frequency, and both the median and 5th-percentile worst rolling 12-month window all agree to within noise. The reason is structural rather than a bug: the regime probabilities are estimated from the same labels being resampled, so selecting a pool with the stationary probability is equivalent to sampling unconditionally. Measured regime persistence at consecutive block starts is 56.5% against 55.4% expected by chance, because crisis runs (~3 months) are shorter than the 6-month block, so the chain fully mixes inside a single block.
The clustering and sequence risk this model produces come from the block bootstrap, not from the regime switching. That is still a real and valuable property — blocks replay genuine historical runs — but the regime layer should not be credited for it. Making the regimes bite would require regime runs long relative to
blockLength, or a transition matrix specified independently of the empirical label frequencies. SeeTODO.md0.12.
Mode B — Annual Bootstrap with Intra-Year Spread (fallback when monthly data unavailable):
-
Every 12 months, a historical annual return
$r_a$ is drawn from the regime pool -
The year is then spread across its 12 months rather than held at one constant rate. Each month draws a Cornish-Fisher shaped multiplicative shock
$f_i = 1 + \sigma_m z'_i$ , and the twelve shocks are renormalised to unit geometric mean:$$ r_{m,i} = (1 + r_a)^{1/12} \cdot \frac{f_i}{\left(\prod_j f_j\right)^{1/12}} - 1 \qquad\Longrightarrow\qquad \prod_{i=1}^{12}(1 + r_{m,i}) = 1 + r_a$$
so the year still compounds to exactly the return that was drawn — the bootstrap's fidelity to history and all annual moments are untouched — while the path within the year moves. $$
-
Annual return-pool selection uses an annual-frequency Markov chain estimated from the detected annual labels. Inflation keeps a separate monthly chain derived from the input template; mixing those chains would either reweight the return pools or apply annual persistence at monthly frequency.
Fixed 2026-07-21 (TODO 0.7). Mode B previously repeated one constant monthly rate for all twelve months, so a path that dipped below zero mid-year and recovered by December was invisible to monthly-granularity ruin. On a 25-year drawdown at a 6% withdrawal rate, restoring the intra-year path lifts measured ruin by roughly 0.8pp (success 77.3% → 76.5%) while the simulated annual mean return is unchanged to four decimal places, confirming the renormalisation preserves the annual draw exactly.
Mode B is not reachable from the shipped app — all four regions carry ≥ 781 monthly observations, comfortably past the 120-month threshold for Mode A — but it is reachable through the library with annual-only data, and would become reachable if a region with short monthly history were added (see
TODO.md5.2).
Mode C — Parametric (no historical data / parametric mode selected):
-
buildBootstrapHistory()generates a synthetic 120-year annual return series using:- Student-t draws (degrees of freedom from kurtosis:
$df = 4 + 6/\kappa_{excess}$ ) - Skewness shift term
- Regime-switching mean/std
- Student-t draws (degrees of freedom from kurtosis:
- The finite synthetic pool is affine-targeted to the requested annual mean and volatility before it is split into regime pools. This retains the generated ordering, tails and regime clustering while preventing one pool's sampling error from becoming a fixed return-level offset shared by every simulation path.
- Annual pool selection uses transition probabilities estimated from those same detected labels, so its stationary weights remain consistent with the growth/crisis split. The sampled annual return is then spread across twelve months without an additional regime shock; it is already regime-conditioned, and a second overlay would double-count the crisis effect and invalidate the calibrated moments.
Fixed 2026-07-26. Previously the 120-year pool was used as drawn. Its mean error (about
$\sigma/\sqrt{120}$ , or 1.4pp at 15% volatility) was frozen across the entire run and therefore did not diminish with more simulations. A 7.00% request could report an effective 5.01% solely because of the seed's one calibration draw. Parametric effective mean and volatility now match the request (apart from safety-clamp effects at extreme inputs); the seed continues to control ordering and higher moments.
Fixed 2026-07-26. Pool targeting alone did not guarantee that generated paths kept those moments: detected labels could put 30% of the pool in crisis while the unrelated template chain selected crisis only 18% of the time. In a measured 7.00% pool this reweighting implied a 9.06% sampled arithmetic return. Annual pool selection now estimates its chain from the pool's own labels, independently of the monthly inflation chain, and the redundant parametric crisis overlay has been removed. Regression tests measure generated wealth-path CAGR across seeds rather than only inspecting the source pool.
When historicalMomentTargeting is enabled in Historical mode, each bootstrap sample is affine-transformed to match user-specified moments:
This preserves the ordering and autocorrelation of historical sequences while shifting their first two moments to the user's targets.
Monthly targets are the compounding-inverse of the annual inputs. The user's inputs are
annual moments, but on the production path (monthly calibration) the transform rewrites
monthly observations that are then compounded into years. The monthly targets must
therefore be the ones whose 12-fold compounding reproduces the annual request
(monthlyTargetsForAnnualMoments):
Both identities require only independence across months, not normality, since the expectation of a product of independent factors is the product of expectations — higher moments of the retargeted history do not enter.
The naive
Residual: serial dependence, which is deliberate. Removing the compounding artifact does not make realized annual moments equal the target, because the block bootstrap preserves the historical autocorrelation that inflates annual variance above 12× the monthly variance. Measured over 400,000 bootstrapped years per cell at the shipped block length of 6 (§4.3), on the 60/30/10 portfolio, requesting 5% / 15%:
| Region | iid control (block=1) | Shipped (block=6) | Residual σ |
|---|---|---|---|
| WORLD | 4.98% / 15.01% | 5.22% / 15.97% | +0.97pp |
| USD | 4.97% / 15.01% | 5.11% / 15.29% | +0.29pp |
| GBP | 4.98% / 15.02% | 5.18% / 16.49% | +1.49pp |
| EUR | 4.98% / 15.01% | 5.19% / 15.84% | +0.84pp |
The iid column confirms the transform is now exact in the independent limit. The residual is the variance-ratio effect of §4.3 and scales with each region's own autocorrelation, so it cannot be removed without also deflating the monthly volatility below the historical dependence structure the block bootstrap exists to reproduce. Whether to do so is an open modelling decision (TODO 0.15), not a bug.
Read the volatility input as "annual volatility of the underlying monthly process", not as a guarantee about realized annual σ. Realized annual σ runs roughly 0.3–1.5pp above the requested figure depending on region, in the conservative direction.
The results show both the requested annual moments captured for the run and the effective moments measured from the transformed annual bootstrap source, so this residual is visible for the selected portfolio and region rather than hidden in methodology.
Historical averages are a poor forecast of bond returns: much of the 1960–2026 bond
return came from yields falling from double digits, which cannot repeat from the embedded
2025–2026 level. The Use 2025–2026 yields preset therefore rebuilds return means
institutional capital-market assumptions do, from the latest observed yields shipped in
the dataset (currentConditions, sourced from the bond_yield_pct / cash_rate_pct
columns):
| Asset | Forward mean |
|---|---|
| Cash | current short rate |
| Bonds | current long yield |
| Equity | current long yield + historical equity risk premium (equity mean − bond mean) |
Only the means change. Volatility, skewness, kurtosis and the bootstrap's real historical sequencing are retained — the shape of the distribution is the part history estimates well. The preset runs in Historical-with-Adjustments mode, so the moment targeting of §4.4 shifts the sampled historical series onto these targets.
Using the latest observations in the shipped dataset (currently 2026-01; these are not live quotes):
| Region | Cash | Bonds | Equity (forward) | Equity (historical) |
|---|---|---|---|---|
| USD | 3.6% | 4.2% | 9.9% | 12.0% |
| EUR | 2.0% | 2.8% | 6.1% | 9.3% |
| GBP | 3.7% | 4.5% | 7.7% | 11.3% |
| WORLD | 3.1% | 3.8% | 9.6% | 12.3% |
Because this mode uses moment targeting, the joint (return, inflation) bootstrap of §5.3 is inactive and inflation falls back to the regional parametric assumption, which remains user-editable. Anchoring inflation to market-implied breakevens is future work.
For parametric draws (inflation and Mode B/C returns), the engine uses a 4-term Cornish-Fisher expansion:
where
| What | Min | Max |
|---|---|---|
| Annual return | −95% | +120% |
| Monthly return | −60% | +60% |
| Transition probability | 0.001 | 0.999 |
For each of N simulation paths (minimum 400):
balance = currentSavings
for each month m in [0 .. totalMonths):
1. Regime transition (monthly Markov chain)
2. Sample monthly asset return (block bootstrap or parametric)
3. Apply AUM fee: growth = (1 + r) × (1 − annualFeePercent / 12)
4. Sample monthly inflation (regime-conditioned Cornish-Fisher draw)
5. Net flow = (income_at_age − spending_at_age) / 12 + lump_sums
6. balance += net_flow
7. yearly_pnl += balance × (growth − 1) ← investment P&L, net of fees
8. balance *= growth
9. balance /= (1 + monthly_inflation); yearly_pnl /= (1 + monthly_inflation)
10. At each year boundary (and final partial year):
tax = taxOnGainsPercent × max(0, yearly_pnl)
balance −= tax
yearly_pnl = 0
11. if balance < 0: record the unmet amount as shortfall, balance = 0, mark depleted
12. Record balance; retain the exogenous asset-return/inflation tape for exact replays
-
Spending periods:
[fromAge, toAge), yearly amount,inflationAdjustedflag (default: true)- Inflation-adjusted: used at face value in real terms
- Nominal: divided by
$E[\text{inflation index}] = (1 + \mu_{inf})^{age - currentAge}$ - Nominal items are divided by that path's realized cumulative inflation index, so
a fixed annuity or nominal pension really does lose purchasing power faster on a
high-inflation path. The index is taken through month
$m-1$ , since the flow is applied before that month's deflation. Balances and nominal flows therefore share one price index; previously the flows used$(1+\mu)^{m/12}$ while balances compounded the monthly draws. - Scenario replays consume the same realized inflation tape as the main path and rerun this accounting, so nominal items use the correct path-specific price index everywhere.
-
Income sources: identical structure; default salary
[currentAge, retirementAge), default pension[67, simulateUntilAge) - Lump-sum events: one-time addition/subtraction at a specific age, entered directly in today's purchasing power
During the retirement phase (age ≥ retirementAge), spending can respond to the running
balance. A stateful WithdrawalRunner (identical logic in the Rust and TS engines, and
re-applied in the ruin-surface replay so the surface stays consistent) evaluates one of:
Strategy (withdrawalStrategy.kind) |
Behaviour |
|---|---|
fixed (default) |
Planned real-terms spending, unchanged. The classic "4% rule" assumption. |
guardrails |
Simplified withdrawal-rate guardrails inspired by Guyton-Klinger. Once per retirement year, if the portfolio-funded withdrawal rate (max(0, spending − income) ÷ balance) has drifted above initialRate × (1 + guardrailBand), the portfolio-funded portion is cut by adjustment; below initialRate × (1 − guardrailBand), it is raised. The multiplier is itself bounded to [spendingFloor, spendingCeiling], then total spending is clamped to the same ratios of planned spending. Because the multiplier applies only to the portfolio-funded portion, income narrows the effective total-spending range. When income covers planned spending, the plan is followed and surplus income is saved. Defaults: band 0.2, step 0.1, floor 0.6, ceiling 1.4. |
percentOfPortfolio |
Each retirement year targets total spending of income + withdrawalPercent × current balance, clamped to [spendingFloor, spendingCeiling] × the spending active in the current schedule. If income starts or ends between reviews, it offsets the held portfolio withdrawal immediately so total spending does not jump. The portfolio-funded amount cannot be negative, but the floor, losses, tax, and other outflows still allow depletion. Default withdrawalPercent 0.04. |
Both adaptive strategies can raise success probability by reducing spending after poor
outcomes, but this is not guaranteed for every input. A regression test
(retirementEngine.test.ts, "withdrawal strategies") asserts guardrails ≥ fixed and
percentOfPortfolio ≥ fixed only on its calibrated stressed scenario.
Pre-retirement spending is always the planned amount regardless of strategy.
Engine-internal strategy constructors remain defensive, but public execution boundaries
reject malformed strategy values before simulation. The same validation covers assumptions,
cashflow ranges, event ages, historical-series finiteness and derived timeline lengths in
the UI controller, worker and exported Wasm function.
The SWR FI target uses the full future portfolio-funded spending schedule rather than only
the retirement-date snapshot. Delayed pensions therefore fund later years, while temporary
income cannot make the target disappear. The schedule is discounted monthly at the selected
SWR; the final simulated month's gap is treated as the continuing terminal pattern. A level
schedule still produces the familiar annual spending gap ÷ SWR target exactly.
| Parameter | Default | Application |
|---|---|---|
annualFeePercent |
0.5% | Deducted monthly from AUM: |
taxOnGainsPercent |
15% | Applied annually to positive net investment P&L after monthly deflation and fees; losses are untaxed and not carried forward. This is a configurable drag on real gains, not a jurisdictional capital-gains-tax calculation. |
Annual P&L is tracked in the same deflated units as the balance, so tax is charged on positive real investment gains. Most actual capital-gains systems tax realized nominal gains and include account types, allowances, basis, disposal timing, and sometimes loss carryforwards; none are modelled here. Treat the input as a rough return drag, not a tax bill. Jurisdiction-specific modes (including Dutch Box 3) remain future work.
Mode A — Joint historical bootstrap (default in Historical mode). When the planner
supplies historicalMonthlyInflation (realized regional CPI change, index-aligned with
historicalMonthlyReturns), each simulated month takes its inflation from the same
historical month as its return. This preserves two properties the parametric draw
cannot:
- Return/inflation correlation. Measured in the shipped dataset (1960–2026): equity −0.08 / bond −0.17 for USD — high-inflation months really were weak market months.
- Inflation persistence. Realized monthly inflation has AR(1) ≈ 0.62 (USD); because bootstrap blocks are contiguous, simulated paths inherit sustained inflationary periods rather than i.i.d. noise.
The regime inflation spread is deliberately not applied on top in this mode — the
historical series already embeds that co-movement. Joint sampling is used only in
Historical mode without moment targeting (otherwise the user's explicit inflation
assumptions would be silently ignored), and only when the two series are the same
length; a misaligned series is ignored rather than mispaired. A regression test
(retirementEngine.test.ts, "joint return/inflation bootstrap") asserts that persistent
stagflation produces a fatter left tail than independent inflation at the same mean —
the tail risk TODO 0.4 identified as understated.
Mode B — Parametric fallback (Parametric mode, moment targeting, or no CPI data). Monthly inflation is drawn from a regime-conditioned Cornish-Fisher distribution:
Where:
- Growth regime:
$\mu_{growth} = \mu_{inf} - \pi_C \cdot \text{spread}$ - Crisis regime:
$\mu_{crisis} = \mu_{inf} + \pi_G \cdot \text{spread}$ - Default
inflationCrisisSpread= 1.5% - The spread is capped at 80% of maximum variance-preserving spread
All output balances are in real (today's purchasing power) terms.
For large simulation counts (10,000+), storing all balances per month per simulation would require hundreds of megabytes. The engine uses reservoir sampling (Algorithm R) with K=5,000 samples per month:
- For sim ≤ K: all balances are kept exactly
- For sim > K: each new balance replaces a random existing sample with probability K/(sim+1)
This guarantees a uniform random sample per month, and bounds memory at K × months × 8 bytes regardless of simulation count — ≈26 MB for the default 35→90 horizon, ≈47 MB at the maximum 12→110 horizon. Percentile accuracy at K=5,000 is excellent (±0.5% for P10–P90).
Exogenous return/inflation tapes for scenario replay are capped at 2000 paths (~23 MB at
720 months), matching the subsampling used by build_ruin_surface. Replays rerun the same
accounting evaluator as the main simulation, including realized inflation and gains tax.
Users set allocation via two slider boundaries: stocks % and (stocks + bonds) %. The complement is cash. Default: 60% stocks / 30% bonds / 10% cash.
Cash correlation terms are treated as 0. When historical market data is available,
Skewness uses the third central moment of a sum. For independent components the cross
terms vanish (
Kurtosis needs the cross terms, which are the bulk of a sum's fourth moment:
This is exact for jointly normal components (verified against Monte Carlo) and exact for
independent components whatever their marginal shape, since the cross moments then
factor. With both correlation and non-normal marginals it is a normal-theory
approximation — joint fourth moments are not determined by
Fixed 2026-07-21 (TODO 0.5). The
$\sum a_i^4 \kappa_i$ term was previously used on its own. That made a blend of independent normals report$\kappa_p = 1.5$ instead of 3 — thinner than normal — which then told the Student-t mapping (§4.3 Mode C) there was no excess kurtosis to reproduce, generating thinner tails than intended. The error grew as the portfolio became more balanced, so it hit conservative allocations hardest: for EUR at 20/40/40 the reported kurtosis was 1.74 against a corrected 2.90, while a 100% single-asset portfolio was unaffected and the 60/30/10 default moved only ~1% (its fourth moment is dominated by the equity term).
The UI constructs regime parameters from blended portfolio metrics and a region-specific template:
- Stationary probabilities
$\pi_G, \pi_C$ from template stay-probabilities - Growth/crisis means separated by a
meanSpread(adjusted for excess kurtosis and skewness) - Compute regime mean variance:
$V_{mean} = \pi_G(\mu_G - \mu_p)^2 + \pi_C(\mu_C - \mu_p)^2$ - Remaining variance:
$V_{within} = \sigma_p^2 - V_{mean}$ - Shared scale factor:
$s = \sqrt{V_{within} / (\pi_G m_G^2 + \pi_C m_C^2)}$ - Regime standard deviations:
$\sigma_G = s \cdot m_G$ ,$\sigma_C = s \cdot m_C$
This ensures the total unconditional variance exactly matches the portfolio variance.
P10, P25, P50 (median), P75, P90 balance trajectories over the full time horizon.
| Target | Definition |
|---|---|
| FI Target (SWR) | Present value of the future positive spending-minus-income schedule, discounted monthly at the selected SWR; the last month's gap continues as the terminal pattern. A level schedule reduces exactly to annual gap ÷ SWR. |
| FI Target (P95) | Minimum balance at retirement that fully funds spending in ≥ 95% of fixed post-retirement return/inflation tape replays. Found by varying only retirement-date capital and bisecting to the smallest passing amount. |
| Coast FIRE age | Earliest age at which contributions could stop while still clearing 95% success. |
The P95 construction is the same whether retirement is today or in the future: accumulation
wealth is discarded at the retirement boundary, while each path's realized inflation prefix
is retained so nominal pensions and expenses keep the correct purchasing power. This avoids
conditioning the target on the same pre-retirement returns that generated the observed
retirement balance. Coast FIRE still requires an accumulation phase; it is null when the
plan is already in drawdown — see §7.6.
Coast FIRE. From the candidate age onward, the engine removes only positive monthly
pre-retirement contributions (max(income − spending, 0)). Deficit months and lump sums
remain scheduled. This handles irregular cash flows without making Coast FIRE erase a
future expense. Retirement age and retirement spending are unchanged.
It reuses the exact ruin-surface replay (§7.4). With fixed spending, moving the date later only restores non-negative contributions, so binary search is valid. Adaptive withdrawal policies can react to higher wealth by spending more, so those strategies scan every candidate month instead of assuming monotonicity. Nominal cash flows, dynamic spending and tax are recomputed on each replay.
Reported as null in two cases, both surfaced explicitly in the UI rather than hidden:
when there are no positive pre-retirement contributions to stop,
and when 95% is unreachable even by contributing right up to retirement.
- Success probability: fraction of paths that fully fund every month's spending
- Shortfall: cumulative deficit for depleted paths (P10, P50, P90)
- Depleted years: total years spent at zero balance (P10, P50, P90)
The survival card separates two kinds of uncertainty that must not be conflated:
-
Monte Carlo noise is the approximate 95% run-to-run margin
$1.96\sqrt{p(1-p)/N}$ with the data, assumptions and model held fixed. IncreasingNreduces only this computational sampling noise. - Evidence/model robustness is not estimated by that margin. In Historical mode the UI reports the number of source months and the corresponding count of non-overlapping block-length chunks, while explicitly marking regional, sub-period and block-length robustness as unmeasured. The chunk count describes the finite evidence base; it is not an effective sample size because blocks overlap and returns remain dependent. Parametric mode likewise prompts users to vary its return, inflation and model assumptions.
Consequently the app does not label the Monte Carlo margin a confidence interval for the plan. A larger simulation count can make repeated runs agree closely while leaving the forecast no more trustworthy outside the single historical sample or chosen model.
Simulations are sorted by mean real return in the first 10 post-retirement years, then grouped into 5 quintiles. For each: mean early return, ruin probability, ending median balance. This directly validates the Kitces/Pfau sequence-of-returns thesis.
Where the window starts. Annual real returns are bucketed relative to the exact
retirement month. Retiring at month 30 makes months 30–41 the first sequence-risk year,
months 42–53 the second, and so on. This includes a crash on the retirement date without
mixing any accumulation months into the first bucket. When fewer than 10 years remain the
window shortens to the available post-retirement period; for someone already retired
(retireMonth == 0, §7.6), it begins today.
Why not the first 10 years from today. Those are two different questions, and only the post-retirement one is sequence risk. Before retirement a net saver is buying, so an early crash is bought into at lower prices and partly recovers over the remaining accumulation years — the effect on ending wealth is weak and can carry the opposite sign. After retirement the same crash is sold into to fund withdrawals, permanently removing the shares that would have recovered. Grouping an accumulator's paths by ages 35–45 and captioning it as sequence risk therefore measures the wrong phenomenon, not merely the right one over a shifted window.
"What if a crisis hits tomorrow, given the capital I already have?" is a legitimate and separate question — a shock-timing stress test, not a quintile analysis — and it is partly served by the ruin surface (§7.5), which varies retirement age and spending against the same stored paths. For someone at or near retirement the two windows coincide anyway.
A 9×9 grid of ruin probabilities across:
- Retirement ages: nine points from
retAge−6toretAge+6, every 18 months - Spending multipliers: nine points from
0.8to1.2, every 5 percentage points
(9×1, spending only, when the plan is already in drawdown — see §7.6.)
Each non-baseline cell replays up to 2000 stored exogenous return/inflation paths through the same
accounting evaluator as the main simulation. Nominal cash flows use realized inflation;
dynamic withdrawals and balance-dependent gains tax are recomputed. Income source
is-default (salary) has toAge adjusted per cell; other income sources remain unchanged.
The unchanged-plan cell is replaced with successProbability from the full simulation so
the heatmap, headline card and recommendations all start from the same probability instead
of a noisier replay subsample.
Sampling precision. Each non-baseline cell is a proportion over sampleCount replayed
paths, so it carries binomial error
- The replay sample is capped independently of the
simulationssetting, so raising the simulation count sharpens the summary cards and the unchanged-plan cell, but not the other heatmap cells. - All non-baseline cells replay the same stored paths (common random numbers), so differences between neighbouring replay cells are considerably steadier than each cell's absolute margin suggests. The chart is meant to be read for the shape of the retire-earlier / spend-more trade-off rather than for any single replay cell's exact value.
Ticking "I am already retired" in the input panel sets retirementAge = currentAge,
which makes retireMonth 0 and removes the accumulation phase entirely. There is no
separate flag anywhere in the model: retirementAge <= currentAge is the encoding
(isAlreadyRetired / is_already_retired), so share links, the worker payload and the
wasm boundary carry the mode for free and the two engines cannot disagree about what it
means. Unticking restores the retirement age the user had before.
The UI drops the salary row (is-default) from the payload rather than zeroing it — its
interval currentAge → retirementAge is empty in this mode, and incomeAtAge is inclusive
at both ends, so leaving it in would pay exactly one month of salary at month 0. The row
keeps its amount in the panel state so unticking restores it. Pension and any user-added
income sources are untouched: a retiree usually has some.
Three outputs assume an accumulation phase and are switched rather than left to degenerate. This is the whole reason the mode is a documented branch rather than just a validation relaxation:
| Output | Accumulating | Already retired |
|---|---|---|
| Coast FIRE age | earliest age to stop contributing | null — no contributions left to stop |
| FI Target (P95) | retirement-boundary capital replay clearing 95% (§7.2) | same replay, with the boundary at today |
| Ruin surface | 9×9 over retirement age × spending (§7.5) | 9×1 over spending only |
| "Chance to reach FI" cards | fraction of paths clearing the target | today's capital vs. the target — a yes/no fact |
P95 target construction. Both modes hold the simulated tapes fixed, replace wealth at the retirement boundary with a candidate amount, and bisect for the smallest capital clearing 95%. Success is monotone non-decreasing in starting capital along fixed paths, so the bisection is exact up to its tolerance. For future retirees, the evaluator walks the pre-retirement inflation prefix without carrying forward the accumulation balance; nominal cash flows therefore retain the correct path-specific purchasing power without making the target conditional on accumulation success. The replay recomputes withdrawal decisions, fees and balance-dependent gains tax at every candidate capital.
Why the ruin surface loses an axis. Sweeping retirement age for someone already retired
would clamp every candidate to currentAge + 1 … +6 and caption the result "retire later".
But with the salary gone, a later retirement age changes nothing except when the withdrawal
strategy switches on — a difference with no real-world counterpart. The surface keeps the
axis that still means something. The chart relabels itself accordingly rather than
presenting a one-column grid as if it were a two-way trade-off.
The timeline chart drops its retirement marker and "FI target year" annotation in this mode: retirement is the left edge of the x-axis, so the line divides nothing and the label would name a year that has already passed.
RandomSourcestruct incalculations.rswraps eithermulberry32(whenseedis provided) or thread-local random- Box-Muller transform for normal draws, with spare cache encapsulated per instance (no global mutable state)
- Student-t generation via ratio of normal to chi-squared (only used in
buildBootstrapHistory— 120 draws per run, not in the hot loop); the resulting pool is moment-targeted before reuse - When
seedis set, a given engine and simulation count are deterministic and reproducible. The TypeScript and Rust engines consume the same seeded stream only up to Rust's 5,000-path reservoir threshold; above it, Rust's reservoir-replacement draws advance its stream and cross-engine equality is not claimed (TODO 0.25).
| Item | Unit | Status |
|---|---|---|
| All returns | Decimal (0.08 = 8%) | ✓ Consistent throughout |
| Annual → monthly | ✓ Correct compounding | |
| Annual σ → monthly σ | ✓ Standard scaling | |
| Inflation deflation | ✓ Correct real-terms conversion | |
| Cash flows | Annual amounts ÷ 12 | ✓ Consistent monthly stepping |
| SWR target | Annual spending ÷ SWR rate | ✓ Standard definition |
| Percentile interpolation | Linear between ranks | ✓ Matches NumPy default |
| Stationary distribution | ✓ Correct from |
|
| Box-Muller cache | Per-instance, no global mutation | ✓ |
| Regime gap-filling | Single non-crisis sandwiched → crisis | ✓ |
The checklist above covers unit/formula consistency. The correctness issues raised in the
2026-07-07 review — dividend handling, the tax model and return/inflation coupling — have
since been fixed (§3.1.1, §5.2, §5.3), as have realized-inflation deflation of nominal
cashflows (0.3, §5.1) and the kurtosis-blending cross terms (0.5, §6.2). What remains open
is tracked in TODO.md Priority 0, chiefly the guardrail-bound semantics (0.27), the
already-retired snapshot offset (0.24), cross-engine seeded behavior above the reservoir
threshold (0.25), and the log-domain inflation model (0.20 follow-up). §10 below lists the
standing simplifications.
| Assumption | Risk | Notes |
|---|---|---|
| Block bootstrap within regime (block = 6 months) | Preserves short-run autocorrelation | Much better than i.i.d. sampling |
| Equity-bond correlation configurable; cash correlation = 0 | Partial | Improves portfolio σ realism |
| Regime-conditioned inflation (crisis spread) | Captures main channel | Not full multivariate inflation model |
| Spending strategy (fixed / guardrails / percent-of-portfolio) | Fixed default is conservative | Adaptive strategies available (§5.1.1), added 2026-07-20 |
| Split fee/tax costs | Improved | More interpretable than single drag |
| Monthly time step | Good | Sufficient for retirement horizon |
| Two regimes (Growth/Crisis) | Contributes nothing measurable in Mode A | Clustering comes from the block bootstrap; regime layer is near-vacuous once unbiased (§4.3, TODO 0.12). Still drives Mode C's parametric generator |
| Historical bootstrap for calibration | Good | Data-driven, adapts to region |
| Variance-preserving regime decomposition | Good | Total σ is preserved exactly |
| Return clamping (−95% to +120% annual) | Conservative | Prevents simulation blow-ups |
| Synthetic decade-level dividend yields on price-only indices | Approximation (±0.3%/yr) | Fixed 2026-07-20 (§3.1.1); replaces the former price-only bias of 2.5–3.5%/yr |
| Annual net-gain taxation, no loss carryforward | Slightly conservative in loss-heavy sequences | Fixed 2026-07-20 (§5.2); carryforward + Box 3 mode are TODO 2.3 |
| Tax drag applies annually to positive real, unrealized investment P&L | Differs materially from systems that tax realized nominal gains or wealth; no basis, loss carryforward, account wrappers, withdrawal ordering, or RMDs | Treat this as a configurable real-return drag, not a capital-gains rate or tax bill — TODO 2.3 |
| Tax base is the real gain, whereas most tax codes tax the nominal gain | Under-taxes materially on high-inflation paths, where much of the nominal gain is inflation | Deliberate real-terms consistency: the whole engine reports in real terms. Revisit with the three-bucket model (TODO 2.3) |
| Nominal cashflows deflated by each path's realized inflation | Fixed annuities/pensions now erode faster on high-inflation paths, as they should | Fixed in main paths 2026-07-21 and exact scenario replays 2026-07-25 (§5.1, §7) |
| Regime block bootstrap is mean-preserving | Simulated returns track the source series (all four regions within ±0.07pp/yr) | Fixed 2026-07-21 (§4.3 Mode A); guarded by a regression test |
| Parametric pool is moment-targeted before reuse | Requested mean/std no longer inherit a fixed seed-dependent error from one 120-draw pool | Fixed 2026-07-26 (§4.3 Mode C); guarded in both engines |
| Joint (return, CPI) bootstrap in Historical mode; parametric i.i.d. inflation only in Parametric / moment-targeting modes | Correlation and persistence now preserved where it matters | Fixed 2026-07-21 (§5.3); GBP CPI ends 2025-03 so its monthly series stops there |
| Kurtosis blending includes the 4th-moment cross terms | Exact for jointly normal or independent components; normal-theory approximation when correlation meets non-normal marginals | Fixed 2026-07-21 (§6.2, TODO 0.5); the third moment still omits its cross terms |
guardrails spent all income when income ≥ planned spending |
Surplus pension was consumed rather than invested, unlike fixed and percentOfPortfolio |
Fixed 2026-08-06 — TODO 0.28 |
| Guardrail multiplier bounds apply the total-spending floor/ceiling to the portfolio-funded portion | Effective total-spending band is narrower than the stated [0.6, 1.4] | Open — TODO 0.27 |
| Ruin-surface spending multiplier scales the whole plan, accumulation included | Intended: "spend less" is a change in standard of living from now on, not a retirement-only cut | Clarified 2026-08-06: the card says "from now" and quotes the exact plan-wide percentage — TODO 0.26 |
| Depletion begins when spending cannot be fully funded and is sticky (later recovery does not erase the shortfall) | Captures whether the plan ever failed to meet spending; zero balance alone is not ruin | TODO 2.7 |
| Annual-mode bootstrap spreads the year across 12 months, preserving the annual draw exactly | Intra-year path restored; shape of the spread is parametric, not historical | Fixed 2026-07-21 (§4.3 Mode B); fallback mode only, unreachable from the shipped app |
| Fixed planning horizon; no mortality weighting | Measures "ruin by your chosen age", not ruin-before-death, so it is conservative relative to a lifetime measure | Deliberate product decision, not an omission — survival statistics are the wrong register for a personal tool. TODO 2.2 (declined) |
| Allocation is fixed for life; no glide path | Cannot model de-risking into retirement | TODO 2.5 |
| Single-person model | No second person's age, income or pension start | TODO 2.6 |
| "Use 2025–2026 yields" runs via moment targeting, which disables the joint inflation bootstrap | Inflation reverts to the parametric draw in that mode | TODO 2.8 |
| Ruin surface adjusts only the default salary's end age per cell | Other income sources are held fixed across cells | TODO 6.2. The "work N months longer" recommendation is suppressed when a non-default income row ends inside the swept age range, so the advice is never derived from an axis that would misprice it |
| Tax caveat is stated in the UI, not only in these docs | A user changing the rate sees what it does and does not model, next to the input | Assumptions table, "Tax drag on real gains" row |
| No third-party requests at runtime | Nothing about a user's plan leaves the browser; fonts are self-hosted | Only fetch is the local historical-market-data.json. Share links are client-side URL fragments and are never sent anywhere |
| Ruin-surface non-baseline cells replay a capped 2000-path subsample | ±2.2% sampling noise at mid-range cells; unaffected by the simulation count | The unchanged-plan cell uses the full-run probability; other cells expose replay precision in the UI (§7), and cell differences are steadier than absolute levels (common random numbers) |
| Feature | This Engine | Best Practice | Gap |
|---|---|---|---|
| Return model | Regime-switching block bootstrap | State-of-art | ✓ Block bootstrap preserves clustering |
| Fat tails | Cornish-Fisher + Student-t | Skew-t or Johnson SU | Minor; bootstrap dominates |
| Correlation | Equity-bond correlation parameter | Full DCC-GARCH | Partial (cash, time-varying not modeled) |
| Inflation | Joint (return, CPI) historical bootstrap; regime-conditioned parametric fallback | VAR(1) with returns | ✓ Empirical joint distribution keeps correlation and persistence without imposing a parametric form |
| Ruin analysis | Full path simulation | Same | ✓ |
| Sequence risk | Quintile analysis of early returns | Kitces/Pfau methodology | ✓ |
| Spending rules | Fixed real, simplified withdrawal-rate guardrails, or percent-of-portfolio | Guardrail / VPW | ✓ Closed; full Guyton-Klinger rules and age-banded VPW remain outside the current model |
| Longevity | Fixed user-chosen horizon | Mortality-weighted | Deliberately not adopted. Showing survival probabilities is the wrong register for a personal planning tool; the fixed horizon is the conservative choice — see TODO.md 2.2 |
| Expected returns | Historical, or anchored to embedded 2025–2026 yields (§4.4.1) | Forward-looking CMAs | ✓ Dated yield-anchored preset; no live quotes or CAPE/valuation adjustment |
| Reproducibility | Optional seeded PRNG, surfaced in the UI and share link | Seeded PRNG | ✓ Closed |
| Convergence reporting | SE on success probability + per-cell heatmap margins | Reported sampling error | ✓ Closed |
- Node.js ≥ 20
- Rust toolchain (install via rustup)
- wasm-pack (
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh)
pnpm install
pnpm run dev # Builds WASM + starts dev server
pnpm run build # Production build (WASM + Vite + publint)
pnpm run build:wasm # Rebuild WASM module only
pnpm run test:engines # Engine + parity tests (Node only, no browser)
pnpm run test # Full suite, including browser tests (needs Playwright)The production simulation is the Rust/WASM engine. src/lib/retirementEngine.ts is kept
deliberately as the reference implementation: it is more readable, runs in plain Node
without a wasm build, and makes the parity suite possible.
src/lib/enginesParity.test.ts runs identical seeded inputs through both and compares the
PRNG streams, the full per-month percentile bands, and every summary, sequence-risk and
ruin-surface value across 17 scenarios. Each scenario uses 400 paths, below Rust's
5,000-path reservoir threshold, so the seeded streams are bit-identical, the engines agree
to about one ULP, and the suite asserts a 1e-9 relative tolerance. Above that threshold,
reservoir-replacement draws advance only Rust's stream; parity at production-sized runs is
an open limitation tracked as TODO 0.25.
Any change to simulation behaviour must land in both engines in the same commit. CI runs
pnpm run test:enginesbefore the dist build, so drift fails the pipeline.Note also what this does not buy you: parity is not correctness. Both engines once shared the same year-boundary bug, and the parity suite would have passed it.
# Full refresh: re-fetches prices, yields and CPI from Stooq/FRED
node scripts/import-retirement-market-data.mjs
# Refresh only the derived rate columns (cpi_index, bond_yield_pct), leaving the
# committed market-data vintage byte-for-byte intact
node scripts/import-retirement-market-data.mjs --merge-rates
# Regenerate static/assets/historical-market-data.json from the raw CSVs
node scripts/preprocess-retirement-market-data.mjsThe preprocessing step writes to static/, which is what SvelteKit serves. Verify data
changes through the running app, not just the generated file — an earlier version wrote
to public/, and the app silently kept using a months-old dataset.
The UI ships in English, German, Spanish, French, Italian, Dutch, Polish, Russian and
Chinese, compiled by Paraglide JS from messages/<locale>.json
into src/lib/paraglide/ (generated, gitignored). Locales are declared in
project.inlang/settings.json.
pnpm run i18n:compile # also run by `prepare`, `check`, and the Vite pluginNotes for anyone touching this:
- Locale resolution is
localStorage → browser language → English. There is nourlstrategy: the app is a static single-route SPA, so a locale path segment would mean prerendering the same page nine times. getLocale()is backed by a rune (i18n.svelte.ts). Paraglide resolves the locale inside everym.*()call, which normally makes messages invisible to Svelte — nothing in the template reads a reactive source. Reading$statethere means Svelte tracks the signal at any call depth, so a switch re-renders every label in place, with no remount: the completed simulation, open disclosures, expanded "more info" text and scroll position all survive. Charts are the exception — they are drawn imperatively insideuntrack, so their effects readcurrentLocale()to force a redraw, which does reset a zoomed axis. Anything reachable from the auto-run effect must read the locale underuntrack(see the worker payload inrunSimulation), or a language switch would re-trigger the simulation.- The strategy list is written twice, in
vite.config.tsand in thei18n:compilescript. Both must match: the CLI's own default (cookie/globalVariable/baseLocale) will otherwise overwrite the generated runtime whenevercheckorprepareruns. - The worker is passed the locale explicitly (
WorkerInputMessage.payload.locale).simulationValidation.tsis reachable fromretirementWorker.ts, and Paraglide'slocalStoragestrategy throws inside a worker, where there is nolocalStorage. - Plotly template tokens are not message placeholders.
%{y:.0f}would be parsed as a variable namedy:.0f, so hover templates use named placeholders ({y}) and the call site passes the Plotly token in. - Length is a layout constraint here. The assumptions sheet is
table-layout: fixedand the data tables are fixed-fraction grids, so column heads and row labels need to stay near their English length;src/lib/styles/i18n.csscarries the wrapping, hyphenation and step-down rules that absorb the rest. Add a language by checking the input panel at its 300px minimum before considering it done. - The timeline chart's modebar is two custom buttons, not Plotly's built-ins. Plotly
localises its own toolbar from community-contributed dictionaries, and of the nine
languages here
pl.jsships an empty dictionary whilenl.jsis missing four of the labels we would need — registering the matching Plotly locale would leave some users with an English toolbar.displaylogo: falseremoves the logo for the same reason. Box and lasso select go with them; drag-to-zoom, double-click-to-reset and Reset axes are drag and event behaviour and still work. - The share link carries its language (
lin the#s=payload), applied insrc/routes/+layout.tsbefore the first render viaapplyLocale(..., { persist: false }), notsetLocale. A link opens in the language its author saw it in without repointing the recipient's own stored preference. This is the one benefit of URL-path locales that applies to a single-route SPA whose deployed HTML is an empty shell; the rest — no flash of the base language, no hydration mismatch, indexability — is either already true or would need real prerendered content first. - Seeded row labels follow the language; edited ones do not. The starting labels
("Salary", "Living expenses", …) are ordinary editable text once seeded, so a restored
scenario used to come back in the language it was created in — routine, since the
language switch round-trips through the share payload.
defaultRowLabels.tsrecognises the seeded strings in every shipped language and re-renders those; anything else is the user's words and survives verbatim.
- Pfau, W. (2018). How Much Can I Spend in Retirement? — comprehensive comparison of variable spending strategies
- Bengen, W. (1994). Determining Withdrawal Rates Using Historical Data — original 4% SWR research
- Kitces, M. & Pfau, W. (2015). Reducing Retirement Risk with a Rising Equity Glide Path — sequence-of-returns risk analysis
- Ang, A. & Bekaert, G. (2002). International Asset Allocation with Regime Shifts — Markov regime-switching in portfolio theory
- Hamilton, J. (1989). A New Approach to the Economic Analysis of Nonstationary Time Series and the Business Cycle — foundational regime-switching model
- Politis, D. & Romano, J. (1994). The Stationary Bootstrap — block bootstrap methodology for dependent data
- Johnson, N. L. (1949). Systems of Frequency Curves — Johnson SU distribution for non-normal financial returns
Licensed under the GNU Affero General Public License v3.0 — see LICENSE.
The AGPL's network clause (§13) is the point: if you run a modified version of this planner as a hosted service, you must offer that service's users the corresponding source. Private use and modification are unrestricted.
Not financial advice. This is an educational planning tool. Its projections are model output, not predictions, and carry no warranty — see the disclaimer in §11 of the license text and the notice shown in the app itself.
Market and price-index data is fetched from public sources (Stooq, FRED) by the scripts
in scripts/ and is subject to those providers' own terms. The derived series committed
under data/retirement/raw/ and static/assets/ are transformations of that data,
included for reproducibility.