Python for Actuarial Students · Course 1
Lesson 4 · Part B

Lab: Bonds & Mortgages as Objects

Two financial instrument classes you’ll reuse for the rest of the course: a coupon bond and a mortgage loan.

Actuarial Lab · 90 minWhat you’ll build

CouponBond

  • Coupon cash flows and their times
  • Price from a yield
  • Yield-to-maturity from a price (bisection)
  • Macaulay and modified duration

MortgageLoan

  • Level monthly payment
  • Full 360-row amortization schedule
  • len(loan) → number of payments
  • Total interest; the principal/interest crossover

Put both classes in src/foundations/instruments.py. Explore and check them in notebooks/lesson04_lab.ipynb:

from foundations.instruments import CouponBond, MortgageLoan

Notebook edits to a module aren’t picked up automatically. Restart the kernel after changing instruments.py — or run %load_ext autoreload and %autoreload 2 once at the top of the notebook.

Companion notebook: lesson-04b-instruments-lab.ipynb — worked examples and self-checking exercises.

Part 1

The CouponBond Class

Face 1,000 · 6% coupon paid semi-annually · 10 years.

CouponBond · Step 1Constructor, properties, cash flows

class CouponBond:
    """A level-coupon bond redeemed at face value."""

    def __init__(self, face: float, coupon_rate: float,
                 years: float, freq: int = 2):
        # TODO: validate the inputs
        self.face = face
        self.coupon_rate = coupon_rate
        self.years = years
        self.freq = freq

    @property
    def n_periods(self) -> int:
        return round(self.years * self.freq)

    @property
    def coupon(self) -> float:
        return self.face * self.coupon_rate / self.freq
def cash_flows(self) -> list[float]:
    cfs = [self.coupon] * self.n_periods
    cfs[-1] += self.face
    return cfs

def times(self) -> list[float]:
    return [k / self.freq
            for k in range(1, self.n_periods + 1)]
>>> b = CouponBond(1000, 0.06, 10)
>>> b.n_periods, b.coupon
(20, 30.0)
>>> b.cash_flows()[:2], b.cash_flows()[-1]
([30.0, 30.0], 1030.0)
>>> b.times()[:3]
[0.5, 1.0, 1.5]

Tasks: validate face > 0, coupon_rate >= 0, years > 0, freq in (1, 2, 4, 12); add a __repr__ giving CouponBond(face=1000, coupon_rate=0.06, years=10, freq=2).

CouponBond · Step 2Price from yield

With annual yield y compounded f times a year, the per-period rate is j = y/f:

$$P(y) = \sum_{k=1}^{n} CF_k \,(1 + j)^{-k}$$

def price(self, ytm: float) -> float:
    j = ytm / self.freq
    return sum(cf * (1 + j) ** -k
               for k, cf in enumerate(self.cash_flows(), 1))
YieldPrice
5%1,077.9458premium
6%1,000.0000par
7%928.9380discount

Price equals face exactly when yield equals the coupon rate. That’s your first sanity check — and a free test case for Lesson 8.

CouponBond · Step 3Yield-to-maturity by bisection

No formula gives y from P. But price falls as yield rises, so we can trap the answer between two bounds and halve the gap repeatedly:

def ytm(self, price: float, lo: float = -0.99,
        hi: float = 1.0, tol: float = 1e-10,
        max_iter: int = 200) -> float:
    for _ in range(max_iter):
        mid = (lo + hi) / 2
        if self.price(mid) > price:
            lo = mid        # price too high → yield too low
        else:
            hi = mid
        if hi - lo < tol:
            return (lo + hi) / 2
    raise RuntimeError("yield did not converge")
>>> y = b.ytm(950.0)
>>> round(y, 6)
0.066939
>>> round(b.price(y), 6)       # round trip
950.0

Each pass halves the interval, so 1.99 → 10−10 takes about 35 passes. It’s Lesson 2’s while-with-a-guard pattern, as a for loop with a maximum.

Task: raise ValueError if the target price is outside [price(hi), price(lo)] — no yield in range can produce it.

CouponBond · Step 4Duration

Macaulay duration: the PV-weighted average time to each cash flow (in years):

$$D_{\text{mac}} = \frac{1}{P}\sum_{k=1}^{n} t_k \, CF_k\,(1+j)^{-k}$$

Modified duration: the price’s sensitivity to yield:

$$D_{\text{mod}} = \frac{D_{\text{mac}}}{1 + y/f} \approx -\frac{1}{P}\frac{dP}{dy}$$

At y = 5%Value
Macaulay duration7.7618 years
Modified duration7.5725
Numerical −P′/P (Δy = 1bp)7.5725 ✓

Checks to write

  1. A zero-coupon bond’s Macaulay duration equals its term: CouponBond(1000, 0.0, 5, freq=1) → 5.0
  2. Modified duration matches a central finite difference of price()
  3. Predict the price at 6% from duration alone: 1,077.95 × (1 − 7.5725 × 0.01) = 996.32. Actual: 1,000.00. The gap is convexity.
Part 2

The MortgageLoan Class

300,000 · 6.5% nominal annual, monthly · 30 years.

MortgageLoan · Step 1Payment from the annuity formula

class MortgageLoan:
    """A level-payment, monthly amortizing loan."""

    def __init__(self, principal, annual_rate, years):
        self.principal = principal
        self.annual_rate = annual_rate   # nominal, monthly
        self.years = years

    @property
    def n_payments(self) -> int:
        return self.years * 12

    @property
    def monthly_rate(self) -> float:
        return self.annual_rate / 12

    @property
    def payment(self) -> float:
        j, n = self.monthly_rate, self.n_payments
        return self.principal * j / (1 - (1 + j) ** -n)

    def __len__(self) -> int:
        return self.n_payments

Payment = principal ÷ $a_{\overline{n}|j}$ — Lesson 2’s polynomial, now in closed form.

>>> m = MortgageLoan(300_000, 0.065, 30)
>>> len(m)
360
>>> round(m.payment, 2)
1896.2

Better still: compute the payment with your Lesson 3 annuity_immediate(). Import it from foundations.interest — one formula, one place.

Task: handle a 0% loan (payment = principal / n).

MortgageLoan · Step 2The amortization schedule

Return a list of dicts — one row per month:

def schedule(self) -> list[dict[str, float]]:
    rows = []
    balance = self.principal
    for month in range(1, self.n_payments + 1):
        interest = balance * self.monthly_rate
        principal = self.payment - interest
        if month == self.n_payments:   # clear rounding dust
            principal = balance
        balance -= principal
        rows.append({"month": month,
                     "payment": principal + interest,
                     "interest": interest,
                     "principal": principal,
                     "balance": balance})
    return rows
MonthPaymentInterestPrincipalBalance
11,896.201,625.00271.20299,728.80
21,896.201,623.53272.67299,456.12
601,896.201,523.20373.01280,832.93
3601,896.2010.221,885.990.00

Tasks

  1. Print rows 1, 2, 60, and 360 with aligned f-string columns
  2. total_interest() method → 382,633.47
  3. Find the first month where principal exceeds interest (comprehension + next()) → 233
  4. Check: sum of principal paid equals the original principal

Stretch goalsIf you finish early

Bond

  • __eq__: two bonds are equal if all four terms match
  • convexity(ytm), and a better price prediction using duration + convexity
  • A __str__ like “10y 6.00% semi-annual bond, face 1,000”

Mortgage

  • extra_payment parameter: how many months does 200/month extra save, and how much interest?
  • balance_after(k) using the prospective formula (PV of remaining payments) — check it matches the schedule
  • A 15-year vs. 30-year comparison table

Commit instruments.py and the lab notebook when you’re done. These classes come back in Lesson 5 (vectorized) and Lesson 8 (tested).

RecapLesson 4B checklist

  • CouponBond prices at par when yield = coupon rate
  • My YTM solver round-trips price → yield → price
  • Duration matches a finite-difference check
  • I can explain the gap between duration’s prediction and the real price
  • MortgageLoan payment is 1,896.20
  • The schedule ends at a balance of exactly 0.00
  • len(loan) works
  • Both classes live in src/foundations/instruments.py, committed to Git
Up next
Lesson 5

Vectorized Numerical Computing with NumPy

Replace loops with whole-array arithmetic: price thousands of cash flows, value cohorts, and simulate portfolios in a single line.