One rate per maturity
So far one yield discounted every payment of a bond. In reality money lent for 6 months, 2 years and 10 years earns different rates, and the pattern across maturities is the yield curve. A zero rate is the annual rate for a single payment at a given date, with no coupons in between. Usually the curve rises with maturity (investors want more for waiting longer), but it can be flat or inverted, when short rates exceed long ones, which markets read as a warning about growth.
import numpy as np
maturities = np.array([0.5, 1, 2, 5, 10, 30]) # years where the market quotes a rate
rates = np.array([0.040, 0.042, 0.045, 0.048, 0.050, 0.052]) # an upward-sloping curve
# The rate at 3 years is not quoted: interpolate linearly between 2 years (4.5%) and 5 years (4.8%)
print(float(np.interp(3, maturities, rates))) # 0.046
print(np.interp([0.25, 7, 40], maturities, rates).tolist()) # [0.04, 0.0488, 0.052] flat outside the quoted rangeDiscount factors and the forward rate
A zero rate r for maturity t gives the discount factor (1 + r) ** -t: what one unit paid at t is worth today. The curve also hides expectations. Lending for 2 years at 4.5% should equal lending for 1 year at 4.2% and then lending again for a year at an agreed rate f: (1 + 4.5%)² = (1 + 4.2%)(1 + f), so f = 4.80%. That forward rate is the break-even rate for the second year, locked in today. In general f = ((1 + r2) ** t2 / (1 + r1) ** t1) ** (1 / (t2 - t1)) - 1.
def discount_factor(rate, t):
return (1 + rate) ** -t
print(round(float(discount_factor(0.045, 2)), 5)) # 0.91573 one unit paid in 2 years
r1, t1, r2, t2 = 0.042, 1, 0.045, 2
forward = ((1 + r2) ** t2 / (1 + r1) ** t1) ** (1 / (t2 - t1)) - 1
print(round(forward, 5)) # 0.04801 the rate for year 2, implied by the curve
print(round((1 + r1) ** t1 * (1 + forward) ** (t2 - t1), 5), round((1 + r2) ** t2, 5)) # 1.09 1.09 both routes agreePricing on the whole curve
Pricing with a single yield treats a 1-year and a 10-year payment alike. The curve approach is more faithful: for each payment read the zero rate of its own date (interpolating if needed), turn it into a discount factor and sum flow * factor. Two bonds with the same cash flows must have the same price whatever their quoted yields, and this is how banks mark bonds to market. For a 3-year 5% bond on our curve the price is 1011.25, versus 1010.98 with a flat 4.6%: close here because the curve is gentle, but different bonds react differently when the curve twists.
times = np.array([1.0, 2.0, 3.0])
flows = np.array([50.0, 50.0, 1050.0]) # a 3-year 5% bond with annual coupons
zero = np.interp(times, maturities, rates)
print(zero.tolist()) # [0.042, 0.045, 0.046]
print((discount_factor(zero, times)).round(5).tolist()) # [0.95969, 0.91573, 0.87379]
print(round(float((flows * discount_factor(zero, times)).sum()), 4)) # 1011.2462 on the curve
print(round(float((flows / 1.046 ** times).sum()), 4)) # 1010.9752 with one flat 4.6% instead# The forward rates between the quoted maturities, in one array expression
t = np.array([1.0, 2.0, 5.0])
r = np.array([0.042, 0.045, 0.048])
forwards = ((1 + r[1:]) ** t[1:] / (1 + r[:-1]) ** t[:-1]) ** (1 / np.diff(t)) - 1
print(forwards.round(5).tolist()) # [0.04801, 0.05] year 2 and years 3 to 5: an upward-sloping curve implies rising forwardsnp.interp keeps the rate flat, which is a convention, not a fact. State the method whenever you publish a curve.- A yield curve is the zero rate per maturity; read missing maturities by interpolation (
np.interp, flat outside the range). - Discount factor
(1 + r) ** -t; forward rate((1 + r2) ** t2 / (1 + r1) ** t1) ** (1 / (t2 - t1)) - 1. - Price = the sum of
flow * discount factorwith each flow at the zero rate of its own date; state the interpolation method.
Next up: option payoffs, what a call or a put pays at expiry, the starting point of option pricing.
Lab · what's graded
- Read a rate between the quoted maturities with
interpolate_rate25 pts - Turn a rate into a discount factor with
discount_factor25 pts - Read the future rate hidden in the curve with
forward_rate25 pts - Price a payment stream on the whole curve with
price_on_curve25 pts