How long is a bond, really?
Maturity says when the last payment arrives, but a bond that pays big coupons returns much of your money earlier. Macaulay duration is the average time to receive the cash flows, weighting each payment time by its share of the price: sum(t * PV(t)) / price. A zero-coupon bond has a duration equal to its maturity; coupons pull it down. Our 10-year 5% bond at 6% has a duration of 7.9 years. Duration is the bond's real length in time.
import numpy as np
def bond_cashflows(face, coupon_rate, years, freq):
n = int(round(years * freq))
times = np.arange(1, n + 1) / freq
flows = np.full(n, face * coupon_rate / freq)
flows[-1] += face
return times, flows
times, flows = bond_cashflows(1000, 0.05, 10, 2)
pv = flows / (1 + 0.06 / 2) ** (2 * times) # present value of each payment
price = pv.sum()
macaulay = (times * pv).sum() / price # the average time, weighted by present value
print(round(price, 4), round(macaulay, 4)) # 925.6126 7.895 shorter than the 10-year maturityFrom duration to price risk
Dividing by 1 + ytm / freq gives the modified duration, which has a direct meaning: it is the percentage fall in price for a one-unit (100-point) rise in yield, so a modified duration of 7.67 means about -7.67% for +1 percentage point, to a first approximation: dP / P ≈ -D_mod * dy. This is the number risk desks quote as the bond's interest-rate sensitivity. It also tells you why the long bond of the previous topic fell so much more than the short one: its duration is higher.
modified = macaulay / (1 + 0.06 / 2)
print(round(modified, 4)) # 7.6654 about -7.67% of price for +1 percentage point of yield
dy = 0.01
print(round(price * (1 - modified * dy), 2)) # 854.66 the first-order estimate after a rise to 7%
# the exact price at 7% is 857.88: duration alone underestimates itConvexity: the curvature
The price-yield relationship is a curve, not a line, and duration is only its tangent. The tangent underestimates the price for moves in either direction, because the curve bends upwards: a bond gains more when yields fall than it loses when they rise by the same amount. Convexity measures that bend, from the second derivative: sum(flow * t * (t + 1/freq) / (1 + y/freq) ** (freq * t + 2)) / price. Adding the term 0.5 * convexity * dy² to the duration estimate gives a much better price forecast, and it matters more as the move grows.
convexity = (flows * times * (times + 1 / 2) / (1 + 0.06 / 2) ** (2 * times + 2)).sum() / price
print(round(convexity, 3)) # 71.785
for dy in (0.01, -0.01, 0.03):
exact = bond_price(1000, 0.05, 0.06 + dy, 10)
duration_only = price * (1 - modified * dy)
with_convexity = price * (1 - modified * dy + 0.5 * convexity * dy ** 2)
print(dy, round(exact, 2), round(duration_only, 2), round(with_convexity, 2))
# 0.01 857.88 854.66 857.99
# -0.01 1000.0 996.56 999.88
# 0.03 739.84 712.77 742.67 for a big move convexity matters most# Duration by maturity for a 5% bond at a 6% yield: it grows with maturity, but slower than one for one
for years in (1, 5, 10, 30):
t_, f_ = bond_cashflows(1000, 0.05, years, 2)
p_ = f_ / (1 + 0.06 / 2) ** (2 * t_)
print(years, round((t_ * p_).sum() / p_.sum(), 3))
# 1 0.988
# 5 4.472
# 10 7.895
# 30 14.77bond_price does.- Macaulay duration =
sum(t * PV) / price, the average time to the money; a zero-coupon bond's duration is its maturity. - Modified duration = Macaulay /
(1 + ytm / freq): the relative price fall for +1 of yield,dP / P ≈ -D_mod * dy. - Price estimate =
price * (1 - D_mod * dy + 0.5 * convexity * dy²); the convexity term corrects the tangent, mostly for big moves.
Next up: yield curves and interpolation, because different maturities have different rates.
Lab · what's graded
- Find the average time to your money with
macaulay_duration25 pts - Turn it into a price sensitivity with
modified_duration25 pts - Capture the curvature with
convexity25 pts - Forecast the new price without repricing with
price_estimate25 pts