Functional Programming & Core Math Applications
Write each formula once, name it, test it, and reuse it everywhere: discount factors, interest conversions, and annuities.
Lesson 3 · OverviewBy the end of this lesson you can…
- Define functions with positional, keyword, and default arguments
- Explain local vs. global scope — and why pure functions are safer
- Document intent with type hints
- Handle errors with
try/exceptand reject bad input withraise ValueError - Map and filter data with list and dictionary comprehensions
- Build a small, trustworthy library of interest-theory functions
Companion notebook: lesson-03-functions.ipynb — worked examples and self-checking exercises.
Defining Functions
Name a calculation, give it inputs, get back an output.
FunctionsAnatomy of a function
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 + colon- The indented body runs each time you call it
returnsends a value back (noreturn→None)- The first string is the docstring:
help(discount_factor)shows it
Written once, tested once, used a thousand times. If the formula is wrong, you fix it in one place.
ArgumentsPositional, keyword, and default
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...
- Positional: matched by order
- Keyword: matched by name — clearer, order-free
- Defaults make parameters optional; they come after required ones
For anything with more than two numeric arguments, call with keywords. pv(1000, 0.03, 10) invites swapping rate and term.
GotchaNever use a mutable default
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).
ScopeLocal vs. global
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)
- Names assigned inside a function are local — they vanish when it returns
- Reading a global works; relying on it is fragile
- A pure function depends only on its arguments and changes nothing outside itself
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.
Type hintsSay what goes in and what comes out
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: typefor parameters,-> typefor the result- Built-in generics:
list[float],dict[str, float],tuple[int, ...] float | Nonemeans “a float, or nothing”- The
typingmodule adds more (Callable,Sequence, …)
Python does not enforce hints at run time. They are documentation that your editor (Pylance) and tools can check.
Errors & Defensive Programming
Fail loudly and early — never silently wrong.
ExceptionsReading a traceback
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'
- Read the last line first: the exception type and message
- Read upwards: the chain of calls that led there
- Find the first line that’s your code
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.
ExceptionsRaise errors for bad inputs
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
raisestops the function and signals the callerValueError: right type, unacceptable valueTypeError: wrong type entirely- Put the offending value in the message
Unlike assert, raise can’t be switched off. Use it for anything that validates real inputs.
ExceptionsHandling errors with try / except
def 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
- Code in
tryruns; if a named exception occurs, the matchingexceptruns instead - Optional
else:runs if nothing failed;finally:always runs - Catch only what you expect and can handle
Never write a bare except: or except Exception: pass. It hides typos and real bugs along with the error you meant to catch.
Comprehensions
Build new collections by mapping and filtering, in one line.
ComprehensionsMap and filter, declaratively
# 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 ]
- Equivalent to a
forloop that appends — but reads as what you want, not how - Slightly faster than the loop
sum(... for ...)works without the brackets (a generator)
If a comprehension needs nested loops and conditions, write the plain loop. Clarity wins.
Actuarial Lab · 75 minAn interest-theory toolkit
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 |
Lab · RequirementsMake them trustworthy
- Every function: type hints and a one-line docstring
- Reject impossible inputs with
ValueError: i ≤ −1, m ≤ 0, n < 0 - Handle i = 0 in the annuities (the formula divides by zero)
- Import into the notebook:
from foundations.interest import annuity_due - Check identities with
math.isclose:- $\ddot{a}_{\overline{n}|} = (1+i)\,a_{\overline{n}|}$
- nominal → effective → nominal round-trips
- $d = 1 - v$ and $\delta = \ln(1+i)$
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 δ.
RecapLesson 3 checklist
- I can write functions with defaults and call them with keywords
- I never use a mutable default
- I pass values in rather than reading globals
- I add type hints and docstrings
- I can read a traceback from the bottom up
- I raise
ValueErrorfor bad inputs and catch only specific exceptions - I use comprehensions to map and filter
- My
foundations/interest.pypasses its identity checks
Object-Oriented Programming for Financial Instruments
Bundle data and behavior into classes — and build a coupon bond and a mortgage that know how to value themselves.