Python for Actuarial Students · Course 1
Lesson 8 · Part B

Capstone: A Financial Simulation Engine

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.

The briefWhat you’re building

A motor insurer with 2,000 policies needs to know how bad next year could be. Build an engine that:

  1. Generates the portfolio: ages, mileage, claim frequencies (Lesson 7)
  2. Simulates 50,000 possible years of claims, with a deductible and a limit
  3. Reports the mean, standard deviation, VaR, and TVaR of the annual total loss
  4. Validates itself against theory, and is covered by pytest tests
  5. Explains its results in a short notebook report

Uses

Functions & errors (Wk 3) · NumPy (Wk 5) · RNGs (Wk 6) · scipy.stats (Wk 7) · modules & tests (Wk 8A)

Why it matters

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.

Part 1

Risk Measures

Summarizing the bad end of a distribution in one number.

DefinitionsVaR and TVaR

Value at Risk

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)

Tail Value at Risk (CTE)

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.

Part 2

The Engine

A compound Poisson model, fully vectorized.

ArchitectureHow the pieces fit

make_portfoliosimulation.py
ages, mileage, freq
→
simulate_annual_lossessimulation.py
50,000 annual totals
→
value_at_risk
tail_value_at_risk
risk.py
→
Reportnotebooks/capstone.ipynb
tables, charts, prose

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

The key ideaPooling claims across the portfolio

  • Driver k has Nk ~ Poisson(λk) claims, independently
  • A sum of independent Poissons is Poisson: the portfolio’s claim count is N ~ Poisson(Σλk)
  • Severities don’t depend on the driver, so each year’s total is a compound Poisson sum

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)

Running itThe base run

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 loss1,245,042
Std. deviation160,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% − mean447,788

make_portfolio is Lesson 7’s generator code as a function (same draws, same order). Runs in well under a second.

Part 3

Validation & Scenarios

Earn the right to believe your numbers.

ValidationFour checks your report must show

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

ScenariosWhat drives the tail?

Same portfolio, same simulation seed (1), 50,000 years each — only one assumption changed per row:

ScenarioMeanVaR 99.5%TVaR 99.5%VaR − mean
Base: lognormal σ = 1.2, limit 50,0001,245,0421,692,8311,759,408447,789
Heavier severity tail: σ = 1.61,659,3202,269,8742,356,590610,554
Higher limit: 250,0001,346,5061,978,8682,083,453632,362
No limit1,351,8352,070,8012,228,134718,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.

Capstone deliverableWhat to hand in

1 · The library — src/foundations/

  • simulation.py: make_portfolio, simulate_annual_losses
  • risk.py: value_at_risk, tail_value_at_risk
  • Docstrings on every public function, with conventions and units

2 · The tests — tests/

  • Risk measures on a known sample (1…100)
  • TVaR ≥ VaR; invalid level raises
  • Simulated mean within 4 s.e. of theory (exponential severity)
  • Deductible and limit applied correctly on hand-built claims

3 · The report — notebooks/capstone.ipynb

  • Assumptions table, with the seeds used
  • Histogram of annual losses with VaR and TVaR marked
  • The four validation checks
  • The scenario table, plus one scenario of your own
  • A half-page conclusion a non-programmer could follow
  • Restart & Run All, then export to HTML (Course 0)

Definition of done: uv sync, uv run pytest green, notebook runs top to bottom — all from a fresh clone.

AssessmentHow it will be marked

CriterionWeightWhat “excellent” looks like
Correctness30%Reproduces the base results; validation checks pass
Code quality20%Clear modules, pure functions, no loops over claims, Ruff-clean
Testing20%Meaningful tests of each kind; all green; fast
Documentation10%Docstrings state conventions; README explains how to run
Report & judgment20%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?

Course 1 · RecapEight lessons, one library

1 · Fundamentals

Types, floats, f-strings, decisions

2 · Collections

Lists, dicts, loops, series

3 · Functions

Pure, typed, defensive

4 · Classes

Bonds and mortgages as objects

5 · NumPy

Vectorized pricing and mortality

6 · Discrete sim.

Seeds, counts, stop-loss

7 · Continuous

Severities and heavy tails

8 · Capstone

Tested Monte Carlo engine

You can now turn actuarial mathematics into correct, tested, reproducible code.

Python for Actuarial Students
Course 1 complete · Next: Course 2

Actuarial Data Wrangling, Relational Schemas & Reporting

From simulated data to real, messy data: pandas, multi-table joins, exposure calculations, and automated Excel reports.