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.
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))
| Yield | Price | |
|---|---|---|
| 5% | 1,077.9458 | premium |
| 6% | 1,000.0000 | par |
| 7% | 928.9380 | discount |
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 duration | 7.7618 years |
| Modified duration | 7.5725 |
| Numerical −P′/P (Δy = 1bp) | 7.5725 ✓ |
Checks to write
- A zero-coupon bond’s Macaulay duration equals its term:
CouponBond(1000, 0.0, 5, freq=1)→ 5.0 - Modified duration matches a central finite difference of
price() - 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.
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
| Month | Payment | Interest | Principal | Balance |
|---|---|---|---|---|
| 1 | 1,896.20 | 1,625.00 | 271.20 | 299,728.80 |
| 2 | 1,896.20 | 1,623.53 | 272.67 | 299,456.12 |
| 60 | 1,896.20 | 1,523.20 | 373.01 | 280,832.93 |
| 360 | 1,896.20 | 10.22 | 1,885.99 | 0.00 |
Tasks
- Print rows 1, 2, 60, and 360 with aligned f-string columns
total_interest()method → 382,633.47- Find the first month where principal exceeds interest (comprehension +
next()) → 233 - Check: sum of principal paid equals the original principal
Stretch goalsIf you finish early
Bond
__eq__: two bonds are equal if all four terms matchconvexity(ytm), and a better price prediction using duration + convexity- A
__str__like “10y 6.00% semi-annual bond, face 1,000”
Mortgage
extra_paymentparameter: 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
CouponBondprices 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
MortgageLoanpayment 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
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.