Course 1 · Lesson 8 · Part A Modules, Docstrings & Tests
Python for Actuarial Students · Course 1
Lesson 8 · Part A

Modules, Docstrings & Tests

Turn seven lessons of notebook code into a small library that explains itself and proves its own calculations.

Lesson 8 · OverviewCapstone lesson, in two parts

Part A — this deck

  • Structuring code across modules in src/foundations/
  • Imports, and the __name__ == "__main__" guard
  • Docstrings that help() can show
  • Unit tests with pytest

≈ 60 min concepts + 45 min refactoring workshop

Part B — the capstone

  • A Monte Carlo engine for a portfolio’s 1-year total loss
  • Value at Risk and Tail Value at Risk
  • Validation, scenarios, and the written deliverable

Project work, at your own pace

Companion notebook: lesson-08a-modules-testing.ipynb — worked examples and self-checking exercises.

Part 1

Structuring Code in Modules

One topic per file; notebooks import, they don’t define.

StructureThe foundations package

foundations/
├── pyproject.toml
├── uv.lock
├── notebooks/            explore & report
├── src/foundations/
│   ├── __init__.py       marks a package
│   ├── interest.py       Lesson 3
│   ├── instruments.py    Lesson 4
│   ├── simulation.py     Lessons 6–7
│   └── risk.py           new: VaR, TVaR
└── tests/
    ├── test_interest.py
    ├── test_instruments.py
    └── test_risk.py
  • A module is one .py file; a package is a folder of them with an __init__.py
  • Group by topic, so a reader can guess where a function lives
  • The src/ layout means you always test the installed package — the same code your notebooks import (you set this up with uv init --package in Lesson 1)
  • Tests mirror modules: risk.py ↔ test_risk.py

Notebooks become thin: import, call, display, explain. The logic lives once, in tested modules.

ImportsGetting code from one module into another

# In a notebook, test, or another package's code:
from foundations.interest import annuity_due, discount_factor
from foundations import risk          # then risk.value_at_risk(...)

# Inside the package, between its own modules:
# src/foundations/instruments.py
from .interest import annuity_immediate   # relative import

# Standard order (Ruff's "I" rules sort this for you)
import math                        # 1. standard library

import numpy as np                 # 2. third-party
from scipy import stats

from foundations.risk import value_at_risk   # 3. your own
  • Import the specific names you use — readers see where they come from
  • Never from module import *: names appear from nowhere
  • A leading dot means “from this package”

Circular imports: if interest.py imports instruments.py and vice versa, Python fails at start-up. Low-level modules (interest) must not import high-level ones (instruments).

ScriptsThe __main__ guard

# src/foundations/interest.py

def annuity_immediate(n: int, i: float) -> float:
    ...


if __name__ == "__main__":
    # Runs only when executed directly:
    #   uv run python -m foundations.interest
    # NOT when imported.
    for n in (5, 10, 20):
        print(n, round(annuity_immediate(n, 0.05), 4))
  • Every module has a __name__: the module’s name when imported, "__main__" when run directly
  • Code at the top level of a module runs on every import — keep it to definitions
  • Put demo or command-line code under the guard

A stray print(...) or a 10-second simulation at module level runs every time a notebook or test imports the module.

Part 2

Docstrings

Documentation that lives with the code, and can’t get lost.

DocstringsA standard format: Google style

def discount_factor(i: float, t: float) -> float:
    """Present value of 1 due at time t at effective annual rate i.

    Args:
        i: Effective interest rate per period. Must be greater than -1.
        t: Time in periods. May be fractional or negative.

    Returns:
        The discount factor v**t = (1 + i)**-t.

    Raises:
        ValueError: If i <= -1.

    Examples:
        >>> round(discount_factor(0.05, 10), 6)
        0.613913
    """
    if i <= -1:
        raise ValueError(f"interest rate must be > -1, got {i}")
    return (1 + i) ** -t
  • First line: one sentence, what it does
  • Args, Returns, Raises: units, valid ranges, conventions
  • Examples show real usage
  • Classes get one too — describe what an instance represents

help(discount_factor) in a notebook, or hovering in VS Code, shows all of this. Write the docstring you would want to read.

The example is runnable: uv run python -m doctest -v src/foundations/interest.py

DocstringsWhat actuarial docstrings must say

Conventions

Effective or nominal? Annual or per period? Payments in advance or arrears?

Units

Years or months? Rates as 0.05 or 5? Amounts in which currency?

Definitions

Which VaR convention? Which quantile method? Is TVaR “≥ VaR” or “> VaR”?

Most actuarial model errors aren’t coding mistakes — they’re convention mismatches between the person who wrote a function and the person who called it. The docstring is where you prevent them.

Part 3

Unit Testing with pytest

Code that checks your code — every time, in seconds.

pytestYour first tests

# tests/test_interest.py
import pytest

from foundations.interest import (annuity_due,
                                  annuity_immediate,
                                  discount_factor)


def test_discount_factor_known_value():
    assert discount_factor(0.05, 10) == pytest.approx(0.6139132535)


def test_annuity_due_identity():
    assert annuity_due(10, 0.05) == pytest.approx(
        1.05 * annuity_immediate(10, 0.05))
  • Files named test_*.py in tests/; functions named test_*
  • A test is a function that asserts something — plain Python
  • pytest.approx compares floats with a relative tolerance (default 10−6) — never == on floats (Lesson 1)
$ uv run pytest
collected 10 items

tests/test_interest.py .......          [ 70%]
tests/test_risk.py ...                  [100%]

============ 10 passed in 0.06s ============

pytestWhen a test fails

________________________ test_tvar_is_mean_of_tail ________________________

    def test_tvar_is_mean_of_tail():
        losses = np.arange(1, 101, dtype=float)
>       assert tail_value_at_risk(losses, 0.95) == pytest.approx(97.0)
E       assert 98.0 == 97.0 ± 9.7e-05
E         comparison failed
E         Obtained: 98.0
E         Expected: 97.0 ± 9.7e-05

tests/test_risk.py:14: AssertionError
========================= short test summary info =========================
FAILED tests/test_risk.py::test_tvar_is_mean_of_tail - assert 98.0 == 97.0 ± ...
1 failed, 9 passed in 0.09s

pytest shows the failing line, the value it got, and the value it expected. Now decide: is the code wrong, or the test?

Here the test was wrong: losses ≥ VaR (95.05) are 96…100, whose mean is 98. Writing the test forced us to pin down the TVaR definition.

pytestErrors and many cases at once

def test_rejects_impossible_rate():
    with pytest.raises(ValueError, match="must be > -1"):
        discount_factor(-1.5, 3)


@pytest.mark.parametrize("n", [0, 1, 10, 30])
def test_zero_interest_annuity_is_n(n):
    assert annuity_immediate(n, 0.0) == n


@pytest.mark.parametrize("coupon, ytm", [(0.04, 0.04),
                                         (0.06, 0.06),
                                         (0.08, 0.08)])
def test_bond_prices_at_par(coupon, ytm):
    bond = CouponBond(1000, coupon, 10)
    assert bond.price(ytm) == pytest.approx(1000)
  • pytest.raises passes only if the expected error is raised — testing your defensive code from Lesson 3
  • match= checks the message too
  • parametrize runs one test per case; each passes or fails on its own

Useful commands:

uv run pytest -qquiet summary
uv run pytest -xstop at first failure
uv run pytest -k annuityonly matching tests

StrategyWhat to test in actuarial code

Kind of testExample from this course
Known values — textbook or hand-calculateda10 at 5% = 7.721735
Identities — two routes to one answerä = (1 + i) · a; d = 1 − v
Limiting casesi = 0 annuity = n; zero-coupon duration = term
Invariants — always truebond at par when yield = coupon; mortgage balance ends at 0; TVaR ≥ VaR
Round tripsprice → YTM → price; nominal → effective → nominal
Bad inputsi ≤ −1 raises ValueError
Simulations — seeded, with tolerancesimulated mean within 4 standard errors of the theoretical mean

StrategyTesting random code

def test_simulated_mean_matches_theory():
    rng = np.random.default_rng(123)       # fixed seed
    freq = np.full(500, 0.1)               # simple portfolio
    sev = stats.expon(scale=1000)          # easy theory

    losses = simulate_annual_losses(freq, 20_000, rng, sev,
                                    deductible=0, limit=np.inf)

    theory = freq.sum() * sev.mean()       # E[N] · E[X] = 50,000
    se = losses.std() / np.sqrt(losses.size)
    assert abs(losses.mean() - theory) < 4 * se
  • Seed the generator so the test is deterministic — it passes or fails the same way every run
  • Choose inputs whose answer you know in closed form
  • Compare with a tolerance in standard errors, not a fixed number of decimals
  • Keep it small: tests should run in seconds

This catches the bugs that matter: a deductible applied twice, a limit ignored, claims assigned to the wrong year.

Workshop · 45 minRefactor, document, test

  1. Check interest.py, instruments.py, simulation.py are in src/foundations/
  2. Move any function still defined in a notebook into the right module; import it back
  3. Give every public function and class a Google-style docstring
  4. Write at least 12 tests — two of each kind in the strategy table
  5. uv run pytest and uv run ruff check . both clean
  6. Commit: “Package foundations library with docstrings and tests”

Write tests for code you trust first (Lesson 3’s formulas) — they protect it while you move things. Then rerun the Lesson 3–7 notebooks: same numbers means the refactor worked.

RecapLesson 8A checklist

  • My code lives in topic modules under src/foundations/
  • Notebooks import functions instead of defining them
  • Module-level code is only definitions; demos sit under __main__
  • Every public function has a docstring with conventions and units
  • I can write tests with assert, approx, raises, and parametrize
  • I can read a pytest failure and decide what’s wrong
  • I test random code with a seed and a standard-error tolerance
  • uv run pytest is green
Up next
Lesson 8 · Part B

Capstone: A Financial Simulation Engine

Simulate a portfolio’s 1-year total loss 50,000 times — and report its Value at Risk and Tail Value at Risk.