Hello. This is a little project I've been working on. It is the culmination of about 1 year into the subject of Retirement allocation testing.
It all started with the paper "Beyond the Status Quo: A Critical Assessment of Lifecycle Investment Advice", which is a paper that challenges the idea of reducing your equity allocation when nearing retirement. It advocates for a 100% equity globally diversified portfolio through working years, up to, and including retirement.
I was exposed to this paper by the following YouTube video by Ben Felix: "The Most Controversial Paper in Finance", whose videos I love watching.
This got me started down a retirement backtesting rabbit hole, looking at block bootstrap methods and t distributions and the such. I have long had a passion for quantitative finance and investing, and this is yet another step in my journey.
What started as a single python script quickly grew in proportion, and I felt like I was spending more time refreshing myself on my python skills versus learning about lifetime allocation. Therefore, I started using generative AI to write the code for me, and I have not written a single line of code for this project. However, I am still in control of how it works in the backend and what techniques were used and why...
This repo contains AI generated code from models such as Deepseek-V4-Flash and Gemini-3.7-Flash. I am putting it on the internet in the case that this might be of interest to anyone out there. Note that I have not looked upon all the lines of this code, and so there might be some sneaky bugs I haven't found yet.
What follows is the AI generated readme file explaining what this tool is and how to use it:
A client-side, GPU-accelerated lifetime asset-allocation and retirement simulation for a Quebec resident, built to run 100% in the browser via WebGPU and to deploy as a single static file on GitHub Pages.
index.html is the final standalone application. It embeds the
calibrated heavy-tailed market model, five WGSL compute passes plus quantile
and terminal-estate (bequest) reductions, and a Wealthsimple-style
institutional light UI in one self-contained file — no backend, no local file
dependencies (fonts come from the Google Fonts CDN).
.
├── config.py # Single source of truth: typed dataclasses for every parameter
├── calibration.py # Multivariate skew-t calibration, model buffers, CPU reference sampler
├── engine.py # Python wgpu compute harness (exact WGSL execution parity)
├── build_html.py # Standalone HTML builder (template + shaders + runtime -> index.html)
├── index.html # Generated: the deployable GitHub Pages application
├── template_readonly.html # Institutional light-mode UI foundation (read-only, ingested at build time)
├── download.py # Price history download & backfill pipeline (writes downloaded_prices.csv)
├── downloaded_prices.csv # Calibrated monthly real price history (VEQT / VGRO / VBAL)
├── canadian_cpi.csv # CPI input for the download pipeline
├── dexcaus.csv # USD/CAD FX input for the download pipeline
├── README.md
├── LICENSE
├── .gitignore
├── shaders/
│ ├── common.wgsl # Shared buffer schemas, memory layout, PRNG and tax helpers
│ ├── returns.wgsl # RQMC Sobol (default) / Threefry (legacy) return generator
│ ├── accumulation.wgsl # 5 house strategies x 7 paths, stochastic buy, FHSA/HBP
│ ├── solver.wgsl # 24-step parallel bisection for sustainable spending w*
│ ├── drawdown_ui.wgsl # Composite Ulcer Index tracking across lifecycle phases
│ ├── quantiles.wgsl # GPU-native 2-pass histogram reduction (201 quantiles/allocation)
│ └── bequest.wgsl # Terminal-estate quantile ladders (reset/walk/final, batched like the solver)
└── tests/
├── test_calibration.py # Calibration unit tests
├── test_parity.py # Python CPU vs. WebGPU numerical parity tests
└── test_e2e_selenium.py# Automated headless-browser verification (5,040 strategies)
# 1. Build the standalone application (reads downloaded_prices.csv)
py -3.14 build_html.py
# 2. Open it: just double-click index.html (or host it anywhere,
# including GitHub Pages — it is a single self-contained file)
# 3. Run the test suites
py -3.14 tests/test_calibration.py
py -3.14 tests/test_parity.py
py -3.14 tests/test_e2e_selenium.py # needs Chrome/Edge + seleniumOptional Python runtime for the engine and parity tests: py -3.14 -m pip install wgpu.
The page simulates 5,040 strategies — every combination of
- 5 housing plans (renter, or buyer funded through a CASH / VBAL / VGRO / VEQT house fund),
- 7 accumulation paths (VEQT, VEQT1.5, VEQT2, VGRO, VBAL, DECLINING glidepath, RISING glidepath),
- 12 bridge phase options and 12 post-pension phase options (each
pure fund, its
+CASHwedge variant, and the two glidepaths).
Each strategy runs the full Quebec fiscal lifecycle on the GPU: stochastic
layoffs, employer DC matching, TFSA/RRSP/FHSA/HBP room mechanics, stochastic
home purchase at the down-payment target, mortgage amortizing to zero at
retirement, RRSP meltdown, RRIF minimums, OAS clawback and enhanced QPP
deferral. For every simulated path the engine solves the maximum sustainable
annual net spending w* by 24-step parallel bisection, then reduces the
201-point monthly spending quantile ladder (P0…P100) on the GPU.
The dashboard ranks strategies by Certainty Equivalent (CRRA utility),
with instant post-processing: moving the Risk Aversion γ, Drawdown Aversion
λ, Bequest Intensity θ or Bequest Curvature k sliders and pressing
Update Table & Re-Rank re-ranks all 5,040 strategies in pure JavaScript
from the cached quantile and estate ladders, applying the exact formula
CE_adj = CE(γ, θ, k) × exp(−λ × Composite UI)
where CE(γ, 0, 0) is the unadjusted CRRA certainty equivalent and θ > 0
activates the bequest-adjusted base CE (see below). No GPU re-simulation is
needed for any slider, filter, search or sort.
Monthly simple returns for VEQT / VGRO / VBAL are computed from the price
history (downloaded_prices.csv). The engine calibrates a multivariate
skew-t distribution ST(ξ, ω, δ, Σ, ν):
| parameter | meaning | calibration |
|---|---|---|
ξ |
location (mode) vector | ξ = μ − ω δ b_ν, with forward-looking CMA means μ |
ω |
scale vector | ω_i = √(Σ_ii / (ν/(ν−2) − δ_i² b_ν²)) |
δ |
skewness vector | moment estimator δ_i = cap·tanh(skew_i/3), clamped by δᵀΣ⁻¹δ ≤ 0.99 |
Σ |
correlation | Σ_ij = ((ν−2)/ν)·(cov_ij/(ω_i ω_j) + b_ν² δ_i δ_j), unit diagonal |
ν |
degrees of freedom | fixed at 5 (heavy tails) |
The settings window's "Use expected returns above — off uses historical"
toggle switches the mean vector μ between the forward-looking CMAs listed in
the CMAs tab and the historical sample means of the price history.
with b_ν = √(ν/π)·Γ((ν−1)/2)/Γ(ν/2) evaluated exactly for integer ν
(no Lanczos approximation). The shader samples ST on-chip through a
digital-shift scrambled Sobol stream (the default; lower single-seed error
at low simulation counts, identical converged CEQ) backed by a 14-bit
Joe-Kuo direction table embedded in the page — or, under
?sampler=threefry, a Threefry-2x32-20 counter-based PRNG (the legacy
stream): a |N(0,1)| skew component, a χ²_ν/ν scale, three independent
normals, and a Cholesky rotation of the residual correlation Σ − δδᵀ. The
parity test proves the NumPy reference and the WGSL sampler agree to float32
precision for every sampler variant.
The two leveraged funds are deterministic transforms of VEQT:
r₁.₅ = max(−0.95, 1.5·r − 0.5·r_borrow − fee₁.₅),
r₂.₀ = max(−0.95, 2.0·r − r_borrow − fee₂.₀), all clipped at −95%.
This checkout (new/) replaces the single skew-t draw with a two-state
Markov switching version of the same heavy-tailed model. A univariate
two-state Gaussian HMM is fit by maximum likelihood (Hamilton filter) to the
VEQT monthly returns, giving a low-vol "bull" state and a high-vol "bear"
state plus the 2×2 persistence matrix [[p00, 1−p00], [1−p11, p11]]. VGRO and
VBAL follow VEQT through their pooled Beta on VEQT (state-invariant residual
vol), so a regime switch tilts every fund at once. Each state carries its own
skew-t parameter set (xi, omega, Cholesky; skewness δ shared), and the two
state means are shifted by a common constant so the regime-weighted
(expected) return equals the same forward-looking CMA target as the base
model — the change alters risk structure and regime persistence, not drift.
On a given simulated path the active state follows the Markov chain (month 0 drawn from the stationary prior, later months persisting with probability p00/p11) and each month is sampled from that state's skew-t. The sampler mirrors the base in three places (NumPy reference, WGSL, browser JS); the parity suite proves CPU↔GPU agreement to float32 precision for every sampler variant (Threefry, Sobol shift, Owen, direct-χ²). The model buffer carries two 18-word skew-t sets plus the two transition probabilities (38 words in place of 18), so the bequest/estate offsets shift accordingly.
The regime fit (HMM + per-state skew-t sets) is calibrated at build time
and embedded in the page. The settings toggle ("Use expected returns above —
off uses historical") and the CMA inputs still retarget the drift live in
the browser: a common constant is added to both state means so the
regime-weighted mean equals either the forward-looking CMA target
(log1p(CMA)/12 + cov/2) or the historical sample mean — exactly the shift
calibration.calibrate_two_state_markov applies. Regime persistence and
per-state volatility are unaffected.
Retirement spending follows a life-stage "smile" schedule (flat → declining → care phase), and the certainty equivalent is computed with mortality-aware, discounted intertemporal weights (CPM2014 survival curve, 50% mortality reduction, 1% annual discount):
CE(γ) = [ (1/199) Σ_i q_i^(1−γ) ]^(1/(1−γ)) · κ(smile, γ)
κ(smile, γ) = [ Σ_t w_t · smile_t^(1−γ) ]^(1/(1−γ))
where q_i are the 199 interior points of the 201-point monthly-spending
quantile ladder (P0 and P100, the sample min/max, are excluded as extreme
order-statistic noise), w_t are the mortality-adjusted discounted
intertemporal weights, and smile_t is the monthly smile multiplier. The
spending distribution is month-stationary (the smile factor separates), so
the intertemporal weights enter only through κ.
Drawdowns are measured on cumulative return indices (never on net liquid
wealth, so planned decumulation cannot masquerade as a drawdown), with
UI_phase = √(mean(D_t²)) and
UI_comp = 0.60·UI_bridge + 0.25·UI_post + 0.15·UI_accum
(re-normalized to (0, 0.65, 0.35) when the bridge phase is empty).
The Risk & Estate Preferences panel adds a De Nardi-style bequest motive on
top of the solved w*. A dedicated GPU pass re-walks every simulated life at
fractions f of its solved w* (0.5 … 1.0) and records the tax-adjusted
estate at 95: TFSA liquidated 100% tax-free, non-registered gains
taxed at the capital-gains inclusion rate (50%), RRSP/RRIF fully taxed at
the top marginal bracket (deemed disposition at death), and home equity
principal-residence-exempt (value fixed at the target property value; the
mortgage amortizes to zero by retirement). The engine rolls any FHSA balance
into the RRSP by age 40 / retirement, so no separate FHSA account exists at
death. The solver is untouched — the bequest ladders are preference-
independent, so moving the Bequest Intensity θ and Bequest Curvature k
sliders re-ranks the table instantly from the cached ladders:
CE_beq = max_f [ mean_i ( (f·q_i·κ)^(1−γ) + θ·(b_{f,i} + k)^(1−γ) ) ]^(1/(1−γ))
The estate is valued in monthly-spending-equivalent units (lump ÷ retirement
months), and the θ slider is normalized so 0.5 always means parity, at any
γ: the actual intensity is θ = 2·θ_parity·slider with
θ_parity = (b_ref / (H·c_ref))^(γ−1) — the level at which the bequest term
equals the consumption term for a reference life (b_ref = $500k estate vs.
c_ref = $5,000/mo spending). The spending fraction itself is refined
continuously between the GPU grid fractions by linear interpolation of the
estate ladders (the terminal estate is near-affine in the fraction), so the
implied choice reports smooth values (e.g. "spends 93% of max sustainable").
0 = no motive, 0.5 = parity, 1 = twice parity.
k is clamped to a $10k floor whenever θ > 0 — a zero estate has infinite CRRA
disutility otherwise (precisely the role of De Nardi's k). θ = k = 0 reduces
to the unadjusted CE exactly. The shipped defaults (γ = 4, θ = 0.5, k =
$200k) approximate the De Nardi–French–Jones (2010) calibration (γ ≈ 3.84,
k ≈ $273k in 2006 dollars, θ ≈ 2360 in their annual/thousands-of-dollars
units — 0.5 on the parity-normalized slider plays the same role).
- One source of truth. Every fiscal rule, CMA, real-estate number and
calibration control lives in the typed dataclasses of
config.py; the Python engine, the calibration payload and the browser runtime all derive from the same defaults. - Structured buffer contracts. Every WGSL storage-buffer region is
declared and documented in
shaders/common.wgsl(andshaders/quantiles.wgsl); there are no undocumented magic offsets. The 144-byte params buffer is byte-identical between the Python runner and the browser'smakeParams. - No parameter duplication. The old engine hardcoded defaults across four files and raw WGSL offsets; this tree defines each number once.
- Exact parity.
engine.pydrives the same WGSL pass files the browser runs, from the same calibration code path, so the Python harness is a trustworthy numerical reference for the deployed page.
config.py exposes strongly-typed dataclasses (LifecycleConfig,
CareerConfig, HousingConfig, TaxFiscalConfig, CMAConfig,
ModelCalibrationConfig, SmileConfig, SimulationConfig) plus
human-friendly formatting/parsing helpers (20.0%, $88,000,
4.30% / yr) that the settings window uses to present and read the same
values the engine stores as raw decimals (0.2, 88000, 0.043).
If the app reports that WebGPU is unavailable or a run does nothing:
- Open the browser console (F12) — every step of the pipeline logs
ENGINE-prefixed lines (adapter selection, buffer sizes, batch progress, quantile reduction) and every failure logs full context plus a diagnostics snapshot. - Run
dumpDiagnostics()in the console to print a complete snapshot: browser version, adapter list, engine state and device-loss reason. - GPU device loss mid-run (crash/
TDRon Windows) surfaces immediately in the console and in the status bar; a 120-second watchdog flags hung runs. - For headless/CI use, launch Chrome with
--enable-unsafe-webgpu --use-angle=d3d11.
Tip: for very large runs (e.g. 30k paths), load with
?allocations=1000to cap GPU memory to a subset of strategies.
MIT — use it, study it, fork it.