A tested Monte Carlo library that models an insurance portfolio’s 1-year total loss and reports its Value at Risk and Tail Value at Risk.
A motor insurer with 2,000 policies needs to know how bad next year could be. Build an engine that:
pytest testsFunctions & errors (Wk 3) · NumPy (Wk 5) · RNGs (Wk 6) · scipy.stats (Wk 7) · modules & tests (Wk 8A)
This is the skeleton of every internal capital model: simulate the loss distribution, then read risk measures off its tail.
Companion notebook: lesson-08b-capstone.ipynb — worked examples and self-checking exercises.
Summarizing the bad end of a distribution in one number.
The loss exceeded with probability only 1 − α:
$$\operatorname{VaR}_\alpha(S) = F_S^{-1}(\alpha)$$
“In 199 years out of 200, the loss is no more than this.” (α = 99.5%, the Solvency II standard)
The average loss given we’re in the tail:
$$\operatorname{TVaR}_\alpha(S) = E\big[S \mid S \ge \operatorname{VaR}_\alpha(S)\big]$$
# src/foundations/risk.py
import numpy as np
def value_at_risk(losses: np.ndarray,
level: float = 0.995) -> float:
"""Empirical VaR: the `level` quantile of losses."""
if not 0 < level < 1:
raise ValueError(f"level must be in (0, 1), got {level}")
return float(np.quantile(losses, level))
def tail_value_at_risk(losses: np.ndarray,
level: float = 0.995) -> float:
"""Mean loss in the worst (1 - level) of outcomes."""
var = value_at_risk(losses, level)
return float(losses[losses >= var].mean())
VaR says nothing about how bad the tail is; TVaR does. TVaR ≥ VaR, always — a free test.
A compound Poisson model, fully vectorized.
simulation.pysimulation.pyrisk.pynotebooks/capstone.ipynbPass the generator and the severity distribution into functions as arguments — never create them inside. Then tests can swap in a simple exponential, and scenarios can swap in a heavier tail.
Use two generators: one seeded for the portfolio, one for the simulation. Changing the simulation seed then doesn’t change who is insured.
So: draw a count per year, draw all severities at once, then add them up by year.
counts = np.array([2, 0, 3]) # claims in years 0, 1, 2
paid = np.array([10., 20, 30, 40, 50]) # all claims, in order
year = np.repeat(np.arange(3), counts) # [0 0 2 2 2]
np.bincount(year, weights=paid, minlength=3)
# [ 30. 0. 120.]
def simulate_annual_losses(freq: np.ndarray,
n_years: int,
rng: np.random.Generator,
severity,
deductible: float = 500.0,
limit: float = 50_000.0
) -> np.ndarray:
"""Total paid losses in each of n_years years.
Claim counts are Poisson with rate freq.sum();
each claim is severity - deductible, floored at
0 and capped at limit.
"""
counts = rng.poisson(freq.sum(), size=n_years)
ground_up = severity.rvs(size=counts.sum(),
random_state=rng)
paid = np.minimum(np.maximum(ground_up - deductible,
0.0), limit)
year = np.repeat(np.arange(n_years), counts)
return np.bincount(year, weights=paid,
minlength=n_years)
import numpy as np
from scipy import stats
from foundations.risk import tail_value_at_risk, value_at_risk
from foundations.simulation import (make_portfolio,
simulate_annual_losses)
portfolio = make_portfolio(2_000, np.random.default_rng(2026))
freq = portfolio["freq"] # E[N] = 174.97
severity = stats.lognorm(s=1.2, scale=4_000)
losses = simulate_annual_losses(freq, 50_000,
np.random.default_rng(1),
severity)
value_at_risk(losses, 0.995)
tail_value_at_risk(losses, 0.995)
| Base results (50,000 years) | Value |
|---|---|
| Mean annual loss | 1,245,042 |
| Std. deviation | 160,303 |
| VaR 99% | 1,644,696 |
| TVaR 99% | 1,712,634 |
| VaR 99.5% | 1,692,831 |
| TVaR 99.5% | 1,759,408 |
| Capital: VaR 99.5% − mean | 447,788 |
make_portfolio is Lesson 7’s generator code as a function (same draws, same order). Runs in well under a second.
Earn the right to believe your numbers.
| Check | Result |
|---|---|
| 1. Mean vs. theory E[N] × E[paid per claim] via severity.expect(...) | 1,244,758 vs. 1,245,042 0.4 standard errors (s.e. 717) ✓ |
| 2. Seed stability VaR 99.5% for seeds 1–10 | 1,679,342 – 1,695,464 sd ≈ 4,200 |
| 3. Convergence VaR at 5k, 10k, 20k, 50k years | your plot |
| 4. Normal approximation mean + 2.576 × sd | 1,657,956 — understates VaR by 35,000 |
Check 2 tells you how many digits of your VaR are real: with 50,000 simulations, about the first three. Report it as ≈ 1.69 million, not 1,692,830.73.
Check 4: the total-loss distribution is right-skewed (skewness 0.22), so the normal approximation undershoots the tail. That’s why insurers simulate rather than assume normality.
Same portfolio, same simulation seed (1), 50,000 years each — only one assumption changed per row:
| Scenario | Mean | VaR 99.5% | TVaR 99.5% | VaR − mean |
|---|---|---|---|---|
| Base: lognormal σ = 1.2, limit 50,000 | 1,245,042 | 1,692,831 | 1,759,408 | 447,789 |
| Heavier severity tail: σ = 1.6 | 1,659,320 | 2,269,874 | 2,356,590 | 610,554 |
| Higher limit: 250,000 | 1,346,506 | 1,978,868 | 2,083,453 | 632,362 |
| No limit | 1,351,835 | 2,070,801 | 2,228,134 | 718,966 |
Removing the limit raises the mean by only 9% but the capital (VaR − mean) by 61%. Limits cut the tail far more than the average — which is exactly why policy limits and excess-of-loss reinsurance exist.
src/foundations/simulation.py: make_portfolio, simulate_annual_lossesrisk.py: value_at_risk, tail_value_at_risktests/level raisesnotebooks/capstone.ipynbDefinition of done: uv sync, uv run pytest green, notebook runs top to bottom — all from a fresh clone.
| Criterion | Weight | What “excellent” looks like |
|---|---|---|
| Correctness | 30% | Reproduces the base results; validation checks pass |
| Code quality | 20% | Clear modules, pure functions, no loops over claims, Ruff-clean |
| Testing | 20% | Meaningful tests of each kind; all green; fast |
| Documentation | 10% | Docstrings state conventions; README explains how to run |
| Report & judgment | 20% | Sensible own scenario; honest about simulation error; clear conclusion |
Stretch: an excess-of-loss reinsurance layer (reinsurer pays claims between 25,000 and 50,000) — how much capital does it save, and what would you pay for it?
You can now turn actuarial mathematics into correct, tested, reproducible code.
From simulated data to real, messy data: pandas, multi-table joins, exposure calculations, and automated Excel reports.