Turn seven lessons of notebook code into a small library that explains itself and proves its own calculations.
src/foundations/__name__ == "__main__" guardhelp() can showpytest≈ 60 min concepts + 45 min refactoring workshop
Project work, at your own pace
Companion notebook: lesson-08a-modules-testing.ipynb — worked examples and self-checking exercises.
One topic per file; notebooks import, they don’t define.
foundations packagefoundations/
├── 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
.py file; a package is a folder of them with an __init__.pysrc/ 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)risk.py ↔ test_risk.pyNotebooks become thin: import, call, display, explain. The logic lives once, in tested modules.
# 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
from module import *: names appear from nowhereCircular 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).
__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))
__name__: the module’s name when imported, "__main__" when run directlyA stray print(...) or a 10-second simulation at module level runs every time a notebook or test imports the module.
Documentation that lives with the code, and can’t get lost.
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
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
Effective or nominal? Annual or per period? Payments in advance or arrears?
Years or months? Rates as 0.05 or 5? Amounts in which currency?
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.
pytestCode that checks your code — every time, in seconds.
# 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))
test_*.py in tests/; functions named test_*asserts something — plain Pythonpytest.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 ============
________________________ 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.
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 3match= checks the message tooparametrize runs one test per case; each passes or fails on its ownUseful commands:
uv run pytest -q | quiet summary |
uv run pytest -x | stop at first failure |
uv run pytest -k annuity | only matching tests |
| Kind of test | Example from this course |
|---|---|
| Known values — textbook or hand-calculated | a10 at 5% = 7.721735 |
| Identities — two routes to one answer | ä = (1 + i) · a; d = 1 − v |
| Limiting cases | i = 0 annuity = n; zero-coupon duration = term |
| Invariants — always true | bond at par when yield = coupon; mortgage balance ends at 0; TVaR ≥ VaR |
| Round trips | price → YTM → price; nominal → effective → nominal |
| Bad inputs | i ≤ −1 raises ValueError |
| Simulations — seeded, with tolerance | simulated mean within 4 standard errors of the theoretical mean |
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
This catches the bugs that matter: a deductible applied twice, a limit ignored, claims assigned to the wrong year.
interest.py, instruments.py, simulation.py are in src/foundations/uv run pytest and uv run ruff check . both cleanWrite 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.
src/foundations/__main__assert, approx, raises, and parametrizeuv run pytest is greenSimulate a portfolio’s 1-year total loss 50,000 times — and report its Value at Risk and Tail Value at Risk.