Write each formula once, name it, test it, and reuse it everywhere: discount factors, interest conversions, and annuities.
try/except and reject bad input with raise ValueErrorCompanion notebook: lesson-03-functions.ipynb — worked examples and self-checking exercises.
Name a calculation, give it inputs, get back an output.
def discount_factor(i, t):
"""Present value of 1 due at time t, at effective rate i."""
return (1 + i) ** -t
discount_factor(0.05, 10) # 0.6139132535407591
discount_factor(0.05, 0.5) # works for fractional t too
def + a name + parameters in parentheses + colonreturn sends a value back (no return → None)help(discount_factor) shows itWritten once, tested once, used a thousand times. If the formula is wrong, you fix it in one place.
def pv(amount, rate=0.05, years=1):
return amount * (1 + rate) ** -years
pv(1000) # 952.38... defaults used
pv(1000, 0.03) # 970.87... positional
pv(1000, years=10) # 613.91... skip rate
pv(amount=1000, rate=0.03, years=10) # 744.09...
For anything with more than two numeric arguments, call with keywords. pv(1000, 0.03, 10) invites swapping rate and term.
def add_claim(amount, claims=[]): # ❌
claims.append(amount)
return claims
add_claim(1) # [1]
add_claim(2) # [1, 2] — surprise!
def add_claim(amount, claims=None): # ✅
if claims is None:
claims = []
claims.append(amount)
return claims
Default values are created once, when the function is defined — not on every call. A default list is shared by every call that uses it.
Use None as the default and create the list inside. Ruff flags the mistake for you (rule B006).
rate = 0.05 # global
def pv_bad(amount, years):
return amount * (1 + rate) ** -years # reads a global
def pv_good(amount, rate, years):
return amount * (1 + rate) ** -years # everything passed in
x = 10
def f():
x = 5 # a NEW local x
return x
f(), x # (5, 10)
In a notebook, pv_bad silently picks up whatever rate a cell set last. Pure functions give the same answer every time — which is what an auditor wants.
def annuity_immediate(n: int, i: float) -> float:
"""Present value of 1 paid at the end of each of n periods."""
if i == 0:
return float(n)
return (1 - (1 + i) ** -n) / i
def pv_cash_flows(cash_flows: list[float],
i: float) -> float: ...
def lookup(table: dict[int, float], age: int) -> float | None: ...
name: type for parameters, -> type for the resultlist[float], dict[str, float], tuple[int, ...]float | None means “a float, or nothing”typing module adds more (Callable, Sequence, …)Python does not enforce hints at run time. They are documentation that your editor (Pylance) and tools can check.
Fail loudly and early — never silently wrong.
Traceback (most recent call last):
File "~/actuarial_work/foundations/scratch.py", line 7, in <module>
total = pv_cash_flows(flows, "0.05")
File "~/actuarial_work/foundations/scratch.py", line 3, in pv_cash_flows
return sum(cf * (1 + i) ** -t for t, cf in enumerate(cash_flows, 1))
~~^~~
TypeError: unsupported operand type(s) for +: 'int' and 'str'
Here the rate arrived as the text "0.05", so 1 + i failed. The fix belongs where the value was read in, not deep in the formula.
def discount_factor(i: float, t: float) -> float:
if i <= -1:
raise ValueError(f"interest rate must be > -1, got {i}")
return (1 + i) ** -t
>>> discount_factor(-1.5, 3)
ValueError: interest rate must be > -1, got -1.5
raise stops the function and signals the callerValueError: right type, unacceptable valueTypeError: wrong type entirelyUnlike assert, raise can’t be switched off. Use it for anything that validates real inputs.
try / exceptdef to_float(text: str) -> float | None:
"""Parse a number from a data file; None if impossible."""
try:
return float(text.replace(",", ""))
except ValueError:
return None
to_float("1,200") # 1200.0
to_float("N/A") # None
try runs; if a named exception occurs, the matching except runs insteadelse: runs if nothing failed; finally: always runsNever write a bare except: or except Exception: pass. It hides typos and real bugs along with the error you meant to catch.
Build new collections by mapping and filtering, in one line.
# Map: a transformed copy
factors = [round(discount_factor(0.05, t), 4) for t in range(1, 6)]
# [0.9524, 0.907, 0.8638, 0.8227, 0.7835]
claims = [1200, 350, 8800, 40, 15000]
# Filter
large = [c for c in claims if c > 1000]
# [1200, 8800, 15000]
# Map with a cap
capped = [min(c, 10_000) for c in claims]
# [1200, 350, 8800, 40, 10000]
# Dict comprehension
table = {t: round(discount_factor(0.05, t), 4) for t in (1, 5, 10)}
# {1: 0.9524, 5: 0.7835, 10: 0.6139}
[ expression for item in iterable if condition ]
for loop that appends — but reads as what you want, not howsum(... for ...) works without the brackets (a generator)If a comprehension needs nested loops and conditions, write the plain loop. Clarity wins.
Write pure, typed, documented functions in src/foundations/interest.py, then import and test them from notebooks/lesson03_lab.ipynb.
| Function | Formula | Check at i = 5% |
|---|---|---|
discount_factor(i, t) | $v^t = (1+i)^{-t}$ | t = 10 → 0.613913 |
effective_from_nominal(i_m, m) | $(1 + i^{(m)}/m)^m - 1$ | 6% monthly → 0.0616778 |
nominal_from_effective(i, m) | $m\,[(1+i)^{1/m} - 1]$ | m = 12 → 0.0488895 |
discount_rate(i) | $d = i/(1+i)$ | 0.0476190 |
force_of_interest(i) | $\delta = \ln(1+i)$ | 0.0487902 |
annuity_immediate(n, i) | $a_{\overline{n}|} = (1 - v^n)/i$ | n = 10 → 7.721735 |
annuity_due(n, i) | $\ddot{a}_{\overline{n}|} = (1 - v^n)/d$ | n = 10 → 8.107822 |
ValueError: i ≤ −1, m ≤ 0, n < 0from foundations.interest import annuity_duemath.isclose:
import math
from foundations.interest import (effective_from_nominal,
nominal_from_effective)
i12 = nominal_from_effective(0.05, 12)
back = effective_from_nominal(i12, 12)
back # 0.050000000000001
back == 0.05 # False
math.isclose(back, 0.05) # True
That last digit is floating point at work (Lesson 1). Identities are checked with a tolerance — exactly how Lesson 8’s tests will do it.
Stretch: a {m: i_m} dict comprehension of nominal rates for m in (1, 2, 4, 12, 365), plus the limit δ.
ValueError for bad inputs and catch only specific exceptionsfoundations/interest.py passes its identity checksBundle data and behavior into classes — and build a coupon bond and a mortgage that know how to value themselves.