MasterOfPuppetsDev
  • Home
  • Services
  • How I Work
  • The Workshop
  • Contacts
  • Sign in
  • Register

Insurance Monte Carlo Simulator — From Choking Python Script to a Full Rust Ecosystem

Tags: Rust · WebAssembly · GPU Compute · egui · Axum · Yew · Actuarial Science · Monte Carlo · Solvency II


Summary

A friend of mine is writing his actuarial science master's thesis around a Monte Carlo simulation for insurance loss modeling. His original Python implementation was mathematically sound — solid frequency-severity model, proper statistical thinking — but it couldn't scale: a few hundred simulations and the process would crawl to a halt. He asked me to help. I rewrote the entire codebase in Rust, optimized it for parallel and GPU execution, and added features he needed for his thesis. The result went from hundreds of simulations to tens of millions in a few seconds on a laptop workstation. Along the way the project grew from a single script into a full ecosystem: a core library, a CLI tool, a native desktop GUI, and a web application with authentication, all sharing the same simulation engine.

To be clear about the division of work: the mathematical model — the frequency-severity formulation, copula theory, reinsurance structures, Solvency II conventions — comes entirely from my friend's actuarial expertise. My contribution was the engineering: turning that math into fast, correct, ergonomic Rust code, and then building all the interfaces around it.


Background and Context

Actuarial modeling relies heavily on simulation to estimate the probability distribution of insurance losses. The standard approach is the frequency-severity model: the number of claims in a period follows a Poisson distribution with parameter λ, and each claim's size follows a LogNormal distribution parameterized by μ and σ. The aggregate loss for a simulation is simply the sum of all individual claim severities drawn for that period. Running this hundreds of thousands of times builds up a full loss distribution, from which risk measures like Value at Risk (VaR) and Expected Shortfall can be read directly.

The Python script my friend had written was correct, but single-threaded and interpreted. Even at a few hundred simulations the bottleneck was obvious. For a thesis that would eventually need millions of samples across multiple Lines of Business (LOBs) under correlated scenarios, this was a dead end.


Project Architecture: The Rust Workspace

The project is organized as a Cargo workspace with clearly separated crates, each with a focused responsibility:

montecarlo_gpu/
├── insurance_sim_core/     # Core library — all simulation logic
├── insurance_sim_cli/      # CLI binary (clap)
├── insurance_sim_gui/      # Native desktop GUI (egui/eframe), also WASM-compilable
├── insurance_sim_wasm/     # WASM build target for web embedding
└── webapp/
    ├── risk_sim_backend/   # Axum REST backend with auth and PostgreSQL
    ├── risk_sim_frontend/  # Yew frontend (compiled to WASM)
    └── risk_sim_shared/    # Shared types between backend and frontend

This separation was intentional from the start. The core library has no UI dependencies and compiles cleanly to both native and WASM targets. The GUI crate conditionally enables its native features via feature flags. The webapp is entirely independent — it embeds the GUI WASM module as an asset and initializes it on a <canvas> element inside a Yew component.


The Core Library: insurance_sim_core

This is where all the actual simulation work lives. It exposes two main simulation backends — CPU and GPU — behind a common SimulationBackend trait, and provides the data structures, copula implementations, and statistical utilities used by every interface.

The Frequency-Severity Model

Each simulation draws a Poisson-distributed claim count N ~ Poisson(λ), then sums N independent LogNormal variates X_i ~ LogNormal(μ, σ). The aggregate loss per simulation is:

L = Σᵢ₌₁ᴺ Xᵢ,   N ~ Poisson(λ),   Xᵢ ~ LogNormal(μ, σ)

This is the classic collective risk model from actuarial theory. The Python implementation computed this sequentially; the Rust version computes it for millions of simulations simultaneously.

Multi-LOB Support and Copula Correlation

The biggest functional addition beyond the original script was multi-LOB simulation: the ability to model several Lines of Business simultaneously, with statistical correlation between them. This required introducing copula theory.

Copulas model the dependency structure between random variables independently of their marginal distributions. The implementation supports three copula types:

Gaussian Copula — generates correlated standard normals via a Cholesky decomposition of the correlation matrix, then applies the normal CDF to obtain correlated uniform variates. These are then passed through the inverse Poisson CDF to produce correlated claim counts. This copula has no tail dependence, making it appropriate for typical market conditions.

Student-t Copula — extends the Gaussian copula by incorporating a chi-squared-distributed scaling factor. Lower degrees of freedom produce heavier joint tails, making correlated extreme events more likely. This is more appropriate for scenarios where losses across LOBs tend to spike together during adverse periods. As the degrees of freedom approach infinity, it converges to the Gaussian copula.

Independent — a degenerate case with identity correlation, useful as a baseline.

The Cholesky decomposition of the correlation matrix (LL^T = Σ) is computed in the library itself. The matrix is validated for symmetry and positive semi-definiteness before any simulation runs, with informative error messages returned via anyhow if the input is invalid.

CPU Backend: Rayon Parallelism

The CPU backend uses Rayon for data-parallelism. The simulation is embarrassingly parallel at the outer level: each simulation is independent, so the work is divided across all available CPU cores with no synchronization overhead between simulations. On a modern 8-core laptop, this alone accounts for roughly an 8× speedup over a sequential implementation, before any other optimizations.

GPU Backend: WGPU Compute Shaders

For very large simulation counts, the CPU backend eventually saturates the available cores. The GPU backend uses WGPU to dispatch compute shaders, running on Vulkan, DirectX 12, or Metal depending on the platform. On macOS, Metal is available out of the box.

The GPU shader implements:

  • PCG hash-based RNG — a stateless, high-quality pseudorandom number generator indexed by thread ID, avoiding the need to store or synchronize RNG state across threads
  • Box-Muller transform — converts pairs of uniform variates into normal variates for the copula
  • Cholesky-based correlation — the decomposed matrix is passed as a uniform buffer and applied in the shader
  • Support for up to 16 LOBs per dispatch

The GPU initialization overhead means the CPU backend wins at smaller simulation counts (below roughly 5–10 million). The crossover point depends on the hardware; on the laptop used for development, the GPU pulls ahead at around 10M simulations for a 3-LOB scenario.

Performance reference on a laptop workstation:

ScenarioCPU (~8 cores)GPU
100K sims, 3 LOBs~30 ms~50 ms
1M sims, 3 LOBs~300 ms~500 ms
10M sims, 3 LOBs~3 s~2 s

To put this in perspective: the original Python script would have needed minutes to complete what the Rust CPU backend does in 300 milliseconds.

Reinsurance and Pricing (v0.3.0 — Solvency II)

Version 0.3.0 introduced actuarial reinsurance structures, motivated by Solvency II requirements. Both are applied per-LOB on top of the gross loss distribution:

Quota Share — the reinsurer covers a fixed percentage α of all losses. The insurer retains (1 − α) × L. A reinsurance commission c_re is paid back to the insurer. This reduces volatility proportionally but at the cost of ceding premium.

Excess of Loss (XL) — the reinsurer covers losses in a defined layer [retention, retention + limit]. The recoverable per simulation is min(upper, max(0, L − lower)). Two limit types are supported: a fixed monetary amount, or a statistical limit defined as mean + k × σ of the gross loss. The premium basis can be either a fixed safety loading or a statistical loading proportional to the Coefficient of Variation of the recoverable.

These structures produce both gross and net loss distributions, enabling the kind of gross-to-net analysis that is central to Solvency II internal models.

Solvency II LOB Presets

The library ships with preset parameters for all 12 standard Solvency II Non-Life Lines of Business, from Medical Expense and Motor Vehicle Liability to Marine, Aviation, and Cyber. These serve as sensible starting points for calibration exercises.

Excel Import

The library can read simulation parameters directly from an Excel file with a multi-index structure (LOB × Parameter × Time Step for the parameters sheet, and an N×N correlation matrix on a separate sheet). This is implemented using the calamine crate, which reads .xlsx files without requiring Excel or any native Excel libraries.

Data Layout

Results are stored as a flat array with the layout [sim][time][lob], accessed via the index formula:

index = sim × (time_steps × num_lobs) + time × num_lobs + lob

This layout was chosen to optimize cache locality for the most common access pattern: iterating over all LOBs for a fixed simulation and time step. The GPU shader uses the same layout, ensuring zero-copy transfer from shader output to the results structure.


The CLI: insurance_sim_cli

The command-line interface is built with clap and covers three main subcommands:

Default (single-LOB) — runs a single-LOB frequency-severity simulation with configurable λ, μ, σ, simulation count, and backend. Results are printed to stdout with mean, standard deviation, min, max, and runtime. Optional CSV export.

multi-lob — runs a multi-LOB simulation with full copula configuration, time step support, reinsurance, and pricing parameters. The full parameter set is extensive: number of LOBs, time steps, copula type, degrees of freedom, correlation coefficient, reinsurance type and parameters, expense/safety loadings, backend, and CSV export path.

excel — imports LOB parameters and the correlation matrix from an Excel file and runs the simulation.

sample-excel — prints the expected Excel file structure to stdout, useful for bootstrapping a new configuration.

A typical invocation for a multi-LOB run with Student-t copula and Excess of Loss reinsurance:

insurance_sim_cli multi-lob \
  --sims 50000 --lobs 4 \
  --correlation 0.2 --copula student-t --df 8 \
  --reins-type excess-of-loss --retention 100 --limit 200

The GUI: insurance_sim_gui (egui/eframe)

The Python script was CLI-only. Adding a graphical interface was one of my friend's first requests — it makes parameter exploration vastly more ergonomic than editing command-line flags. The GUI is built with egui via the eframe framework, which compiles to both a native desktop application and a WebAssembly module.

The interface is organized as a three-panel layout:

Left panel — Configuration. Contains the simulation mode toggle (Single-LOB / Multi-LOB), backend selector (CPU / GPU), parameter sliders for the active mode, copula type dropdown, visualization options (histogram bins, log-scale toggle), the Run button with a spinner during execution, and export buttons (CSV, PNG).

Right panel — Lines of Business (Multi-LOB mode). A scrollable list of LOB cards, each collapsible. Each card exposes λ, μ, σ sliders, pricing parameters (expense load, safety load), and a full reinsurance configurator (None / Quota Share / Excess of Loss with per-type parameter inputs). A predefined LOB dropdown with 12 common insurance lines (Motor Third Party, Fire Commercial, Marine Hull, Aviation, Cyber, Workers Comp, etc.) allows quick composition of realistic portfolios.

Central panel — Results. Displays a statistics grid (mean, std dev, min/max, runtime) and an interactive histogram of the loss distribution. In Multi-LOB mode, three view modes are available: Total Loss (all LOBs and time steps aggregated), Per-LOB, and Per-Time-Step. A gross/net toggle shows the effect of reinsurance on the distribution. The histogram supports log-scale Y for visualizing heavy-tailed distributions.

Simulation runs on a background thread (native) or via wasm_bindgen_futures::spawn_local (WASM), with results sent back to the UI through an mpsc channel, keeping the interface responsive during long runs.

The same MyApp struct and rendering code compile cleanly to both targets. Native builds use std::thread and rfd for native file dialogs; the WASM build uses the browser's Blob API to trigger downloads.


The Web Application: Axum + Yew

Beyond the desktop GUI, the project includes a full web application. The architecture is a Rust full-stack: an Axum REST backend and a Yew frontend compiled to WebAssembly, with a shared types crate that both sides depend on.

Backend: risk_sim_backend (Axum)

The backend is an async Axum server built on Tokio. Key features:

Authentication — users can register and log in with email/password. Passwords are hashed with Argon2 (winner of the Password Hashing Competition). Sessions are managed with signed JWTs using the jsonwebtoken crate. OAuth2 support is implemented for SSO integration via a proprietary identity provider (MOP ID), using the oauth2 crate with its typestate-safe client API.

Database — PostgreSQL via SQLx. User records, audit logs, and session data are persisted here.

Audit logging — an AuditContext is threaded through sensitive operations (registration, login, password change) and writes structured audit records to the database.

Rate limiting — the tower_governor crate enforces per-IP request rate limits. A SmartIp extractor respects X-Forwarded-For headers when the request comes through a known trusted proxy, falling back to the direct remote address otherwise. This handles deployment behind a reverse proxy (e.g. nginx) without blindly trusting the forwarded header.

Security headers — tower-http's SetResponseHeaderLayer adds standard security headers to every response.

Static file serving — the compiled Yew frontend (HTML, CSS, JS, WASM assets) and the simulator WASM module are served from a dist/ directory via ServeDir.

Frontend: risk_sim_frontend (Yew)

The frontend is a Yew single-page application with client-side routing via yew-router. Pages include a home/landing page, login and registration forms, a settings page, an admin panel, and the simulator page.

The simulator page is the centerpiece. It is protected by an authentication guard — unauthenticated users are redirected to login. On mount, it dynamically loads the insurance_sim_wasm WASM module (the egui-based simulator compiled to WebAssembly) and initializes it on a <canvas> element. This approach lets the two WASM modules (Yew UI and egui simulator) coexist in the same browser context, with the Yew shell handling routing, auth, and chrome, while the egui module provides the full simulation interface.

The frontend uses ammonia for HTML sanitization to prevent XSS in any user-generated content rendered to the DOM.


Testing

The core library ships with 39 unit and integration tests organized across four modules:

types (13 tests) — correlation matrix construction and validation (identity, uniform, asymmetric rejection, out-of-bounds values), Cholesky decomposition correctness (verified by checking L × L^T = Σ), multi-LOB result indexing, aggregation across simulations/LOBs/times, and statistical calculations.

copula (11 tests) — Gaussian and Student-t copula sampling, CDF symmetry, output bounds (samples must be in (0, 1)), and roundtrip consistency.

backend/cpu (12 tests) — full simulation pipeline from config to result, Poisson CDF correctness, statistical invariants (non-negative losses, mean scaling with λ, variance scaling with σ).

backend/gpu (1 test) — basic GPU simulation sanity check.

excel_import (2 tests) — expected sample structure and parsing correctness.


Build and Distribution

The release directory provides portable pre-built binaries for all four major targets: Windows (x86_64 MSVC), Linux (x86_64 GNU), macOS Intel, and macOS Apple Silicon (aarch64). Both the CLI and GUI are distributed. No installation is required; the binaries are self-contained.

The GPU backend requires a driver supporting Vulkan (Windows/Linux) or Metal (macOS). On Linux, the mesa-vulkan-drivers package is sufficient for most integrated and discrete GPUs. macOS has Metal support out of the box since 10.13.

Building from source requires only a stable Rust toolchain installed via rustup. The GPU features are included by default; cargo build --release produces both CLI and GUI binaries.


Reflections

This project is a good example of what I actually enjoy about systems programming: taking something that works but doesn't scale, understanding the bottleneck, and rebuilding it so that the bottleneck effectively disappears. The Python script wasn't wrong — the math in it is the same math running in the Rust version — but it was leaving most of the machine idle.

The performance numbers matter in context. A researcher running a sensitivity analysis might want to vary 10 parameters over 20 values each: that's 200 configurations. At 1 million simulations per configuration, you're looking at 200 million total simulations. On the Python script that's not a feasible experiment. On the Rust CPU backend it's roughly 10 minutes. On the GPU backend, closer to 5. That's the difference between a computation that shapes what questions you can ask and one that just answers them.

The decision to build the GUI in egui and make it compile to both native and WASM was also worthwhile, even though the WASM target is more complex (async task spawning, browser download APIs, canvas initialization). Having a single codebase for both deployment targets avoids all the drift that comes from maintaining two separate UIs, and the WASM integration into the Yew shell is clean enough to be reusable in other projects.

The webapp layer — Axum, Yew, JWT, Argon2, rate limiting, audit logging — goes beyond what's strictly needed for a thesis tool, but it's also the part I'd reuse most directly in any future project that needs a Rust web application with authentication.


Key Technical Choices at a Glance

ConcernChoiceReason
LanguageRustPerformance, memory safety, cross-compilation
CPU parallelismRayonZero-boilerplate data-parallelism
GPU computeWGPUCross-platform (Vulkan/DX12/Metal/WebGPU)
GPU RNGPCG hashStateless, no sync needed between threads
GUI frameworkegui / eframeCompiles native and WASM from same source
Web frontendYewFull Rust, type-safe, WASM
Web backendAxumAsync, ergonomic, integrates well with SQLx
AuthJWT + Argon2Industry-standard password hashing, stateless sessions
OAuth2oauth2 crate (typestate)Compile-time enforcement of protocol state
Rate limitingtower_governorTower middleware, composable
Excel I/OcalamineNo native Excel dependency
Error handlinganyhowErgonomic propagation in application code

Project version at time of writing: 0.3.0 (Solvency II Update, December 2025)
Full source available on request.

© 2026 MasterOfPuppetsDev · Privacy Policy

Cookie Policy

We use cookies to improve your experience on our site. Technical cookies are necessary for the site to function, while others help us improve our services. Read the full privacy policy