Bundle data with the calculations that belong to it — so a policy, a bond, or a loan can answer questions about itself.
__init__ and self@property__repr__, __str__, __len__, __add__≈ 75 min, concepts & worked examples
CouponBond: cash flows, price, yield-to-maturity, durationMortgageLoan: a full month-by-month amortization schedule≈ 90 min, hands-on
Companion notebook: lesson-04a-classes-objects.ipynb — worked examples and self-checking exercises.
So far, a bond is scattered across loose variables and functions:
face, coupon_rate, years, freq = 1000, 0.06, 10, 2
price(face, coupon_rate, years, freq, ytm=0.05)
duration(face, coupon_rate, years, freq, ytm=0.05)
cash_flows(face, coupon_rate, years, freq)
Every call repeats the same four arguments — and one swapped argument is a silent error.
With a class, the bond carries its own data:
bond = CouponBond(face=1000, coupon_rate=0.06,
years=10, freq=2)
bond.price(0.05)
bond.macaulay_duration(0.05)
bond.cash_flows()
A class is a blueprint; each object built from it is an instance with its own data. You already use them: every list and dict is an object with methods.
Attributes, self, and the constructor.
class Policy:
"""A single life insurance policy."""
def __init__(self, policy_id: str, age: int,
sum_assured: float, annual_premium: float):
if sum_assured <= 0:
raise ValueError(f"sum_assured must be positive, got {sum_assured}")
self.policy_id = policy_id
self.age = age
self.sum_assured = sum_assured
self.annual_premium = annual_premium
def premium_per_mille(self) -> float:
return self.annual_premium / self.sum_assured * 1000
def birthday(self) -> None:
self.age += 1
class name in CapWords__init__ is the constructor: runs once when an object is createdself is the object being built or usedself.age = age stores an attribute on that objectselfValidate in __init__: an object that can’t be created invalid never needs checking again.
>>> p = Policy("P001", 34, 250_000, 310.50)
>>> p.age
34
>>> p.premium_per_mille()
1.242
>>> p.birthday()
>>> p.age
35
>>> q = Policy("P002", 45, 500_000, 980.00)
>>> q.age # its own data
45
>>> p
<__main__.Policy object at 0x7f9e86b91400>
Policy(...) — creates an instance and runs __init__p.premium_per_mille() is shorthand for Policy.premium_per_mille(p): that’s where self comes frombirthday)That last line is useless for debugging. We’ll fix it with __repr__.
Protect an object’s data; derive values instead of storing them.
class Account:
def __init__(self, balance: float):
self._balance = balance # "internal" by convention
@property
def balance(self) -> float: # read-only view
return self._balance
def deposit(self, amount: float) -> None:
if amount <= 0:
raise ValueError("deposit must be positive")
self._balance += amount
>>> a = Account(100)
>>> a.deposit(50)
>>> a.balance
150
>>> a.balance = 0
AttributeError: property 'balance' of 'Account' object has no setter
_balance) says “internal — don’t touch from outside”@property exposes a value that looks like an attribute but is computed by a methodAn account balance, a reserve, a policy status: values with business rules about how they may change. Encapsulation puts those rules in one place.
class CouponBond:
def __init__(self, face, coupon_rate, years, freq=2):
self.face = face
self.coupon_rate = coupon_rate
self.years = years
self.freq = freq
@property
def coupon(self) -> float:
return self.face * self.coupon_rate / self.freq
@property
def n_periods(self) -> int:
return round(self.years * self.freq)
>>> b = CouponBond(1000, 0.06, 10)
>>> b.coupon, b.n_periods
(30.0, 20)
Why not store self.coupon = face * coupon_rate / freq in __init__?
A stored copy goes stale: change b.face later and b.coupon is silently wrong.
A property is recomputed each time it’s read, so it can’t disagree with the attributes it’s derived from.
No parentheses: b.coupon, not b.coupon().
Special “dunder” methods make your objects behave like built-ins.
__repr__ and __str__class CashFlows:
def __init__(self, amounts: list[float]):
self.amounts = list(amounts) # defensive copy
def __repr__(self) -> str:
return f"CashFlows({self.amounts!r})"
def __str__(self) -> str:
return (f"{len(self)} cash flows totalling "
f"{sum(self.amounts):,.2f}")
>>> bond = CashFlows([60, 60, 1060])
>>> bond # uses __repr__
CashFlows([60, 60, 1060])
>>> print(bond) # uses __str__
3 cash flows totalling 1,180.00
| Audience | Used by | |
|---|---|---|
__repr__ | developers | REPL, notebooks, debuggers, lists of objects |
__str__ | end users | print(), str(), f-strings |
Always write a __repr__. Aim for text that could recreate the object if pasted back into Python.
If there’s no __str__, Python falls back to __repr__.
__len__ and __add__class CashFlows:
...
def __len__(self) -> int:
return len(self.amounts)
def __add__(self, other: "CashFlows") -> "CashFlows":
n = max(len(self), len(other))
a = self.amounts + [0.0] * (n - len(self))
b = other.amounts + [0.0] * (n - len(other))
return CashFlows([x + y for x, y in zip(a, b)])
def pv(self, i: float) -> float:
return sum(cf * (1 + i) ** -t for t, cf in enumerate(self.amounts, 1))
>>> net = CashFlows([60, 60, 1060]) + CashFlows([-500, -500])
>>> net
CashFlows([-440, -440, 1060.0])
>>> len(net), round(net.pv(0.05), 2)
(3, 97.53)
len(x) calls x.__len__()a + b calls a.__add__(b)__add__ returns a new object; neither input changes__eq__ | == |
__lt__ | <, and so sorted() |
__mul__ | * |
__iter__ | for x in obj |
Asset flows + liability flows = net flows. Operators that mean what actuaries mean make code read like the math.
annuity_due(n, i)Good classes use good functions. CouponBond.price() can call the discount_factor() you wrote in Lesson 3.
__init__ and attributesself is and where it comes from_underscore names and methods to protect state@property for derived values__repr____len__ and __add__Build a CouponBond that prices itself and finds its own yield, and a MortgageLoan that writes its own amortization schedule.