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.
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
.pyfile; 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 withuv init --packagein 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.
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.
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_*.pyintests/; functions namedtest_* - A test is a function that
asserts something — plain Python pytest.approxcompares 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.raisespasses only if the expected error is raised — testing your defensive code from Lesson 3match=checks the message tooparametrizeruns one test per case; each passes or fails on its own
Useful commands:
uv run pytest -q | quiet summary |
uv run pytest -x | stop at first failure |
uv run pytest -k annuity | only matching tests |
StrategyWhat to test in actuarial code
| 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 |
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
- Check
interest.py,instruments.py,simulation.pyare insrc/foundations/ - Move any function still defined in a notebook into the right module; import it back
- Give every public function and class a Google-style docstring
- Write at least 12 tests — two of each kind in the strategy table
uv run pytestanduv run ruff check .both clean- 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, andparametrize - 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 pytestis green
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.