Discount factors and zero rates
A discount factor is what $1 paid at time , in years, is worth today. The matching zero rate is the yearly rate, compounded once a year, that gives it: . Unlike a bond's single yield (case study 1.3.5), there is one zero rate for each date, and together they make the zero curve.
Markets quote par yields instead. The -year par yield is the coupon rate at which an -year bond is priced exactly at its face value. In this case study they are:
| maturity (years) | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| par yield | 3.0% | 3.4% | 3.7% | 3.9% | 4.0% |
Bootstrapping, one year at a time
A par bond with face value 1 is priced at 1. Its price is a sum of discounted payments (case study 1.5.5): coupons at years 1 to , and the face value at year .
Every before year is already known from the shorter bonds. So only is unknown, and the equation can be rearranged for it, as in lesson 0.3.2:
Rule: Bootstrapping a discount factor
Start with the 1-year bond, where the sum is empty (lesson 0.2.2), and work up one maturity at a time. Then convert each factor to a zero rate: .
Try it
The loop builds the discount factors in order. The zero rates come out close to the par yields, but not equal: about 3.4068% at 2 years, 3.7163% at 3, and 4.0290% at 5.
The last line checks the method: each of the five par bonds, priced with these factors, is worth exactly 1.
The order matters
The rule needs every factor before year , so the quotes must be used from the shortest maturity up. The loop assumes that the -th quote is the -year bond. Two things on a real screen break that.
- Quotes out of order. Feed in the 2-year yield first and the loop treats it as the 1-year one. The zero curve then starts at 3.4% and drops to 2.994% at 2 years, from correct quotes. Sort by maturity first.
- A missing maturity. The 4-year bond pays a coupon at year 3, so it needs . Dropping a missing 3-year quote makes the loop treat the 4-year yield as the 3-year one, and the 3-year zero rate comes out 3.9252%.
Fill a gap before bootstrapping. Interpolating the par yields linearly (lesson 1.6.1) gives 3.65% at 3 years, and a 3-year zero rate of 3.6642%. That is 5.2 basis points below the 3.7163% from a real quote; a basis point is a hundredth of a percentage point, 0.0001.
Try it
zero_rates runs the same loop and converts to zero rates. The first line swaps the first two quotes; the second drops the 3-year one.
The last line fills the 3-year par yield by interpolation, then bootstraps. Only the 3-year zero rate moves noticeably.
Interpolating the curve
Now the 2.5-year rate. Interpolate the zero rates between 2 and 3 years (lesson 1.6.1), then turn the rate into a discount factor. Linear interpolation gives , so $1,000 due then is worth $916.23 today.
The zero curve, with a knot at each quoted maturity and straight lines between. Past the last quote, three simple rules give three different 7-year rates.
Interpolating in what quantity?
Lesson 1.6.1 showed that the answer depends on the quantity you draw the straight line in. A curve offers several, and all of them are in use.
| straight line in | zero rate at 2.5 | value of $1,000 |
|---|---|---|
| zero rates | 3.5616% | $916.23 |
| discount factors | 3.583% | $915.75 |
| rate times time, | 3.5924% | $915.55 |
| zero rates, cubic spline | 3.5739% | $915.95 |
Rate times time. With continuous compounding (case study 1.4.6), , where is the continuous zero rate. So , the rate times the time.
It plays the part of the distance in lesson 1.6.1's cyclist. A straight line in it assumes one steady rate across each gap: here 4.2465% a year, continuous, between years 2 and 3.
The four answers span 68 cents on $1,000, about $68,000 on $100 million. None is the right one. A desk picks one, writes it down and uses it everywhere, so that two systems value the same cash flow alike.
Try it
The cell bootstraps again, then interpolates each quantity at 2.5 years. Each line prints the zero rate in percent and the value of $1,000.
Rate times time is interpolated as ; the minus sign does not change the straight line.
Beyond the last quote
A cash flow due in 7 years lies past the last quote, so every method now extrapolates (lesson 1.6.1). They disagree:
| rule past 5 years | 7-year zero rate | value of $1,000 |
|---|---|---|
hold the 5-year rate (np.interp) | 4.029% | $758.44 |
| continue the last line | 4.2369% | $747.91 |
| continue the spline | 3.9055% | $764.77 |
The spread is $16.86 on $1,000, against 68 cents at 2.5 years. The spline's answer also depends on its end condition (lesson 1.6.2): a natural spline gives 4.3726% at 7 years. Further out, SciPy's default spline keeps falling, to 2.8279% at 10 years.
A desk either finds a longer quote, such as a 10-year bond, or holds the last rate flat and flags the value as extrapolated. Holding flat is the usual default, and it is what np.interp does.
Try it
The three rules at 7 years, each with the zero rate in percent and the value of $1,000.
The last line is the natural spline at 7 years and the default spline at 10.
A bad quote makes a kink
Suppose the 4-year par yield is typed as 3.6% instead of 3.9%. Bootstrapping still runs, and every par bond still prices at exactly 1 with the factors it produces. The check in the first cell passes, yet the curve is wrong.
The 4-year zero rate drops from 3.925% to 3.6063%. The 5-year one moves too, from 4.029% to 4.0393%, because is built from . In bootstrapping, an error travels to every longer maturity.
The slopes of the chain between the years were 40.7, 30.9, 20.9 and 10.4 basis points per year, ever gentler. With the typo they are 40.7, 30.9, and 43.3. The curve dips and climbs again, a kink at 4 years.
One mistyped quote, at 4 years. On the zero curve it makes a kink; the forward rates turn it into a jump of 2.5 points.
Forward rates magnify the kink. The one-year forward rate from year to year is the growth the curve implies for that year alone: .
From year 1 to year 5, good quotes give forward rates of 3.82%, 4.34%, 4.55% and 4.45%. With the typo the last two are 3.28% and 5.79%: a jump of 2.5 points from one year to the next.
Linear interpolation keeps the damage local; a spline spreads it. The linear 2.5-year rate does not change at all, because it uses only years 2 and 3. The spline's rate moves from 3.5739% to 3.6139% (lesson 1.6.2), and the $1,000 at 2.5 years loses $0.88 to a typo two years away.
Try it
For the good quotes, then the typo, curve returns the factors and zero rates. Each block prints the zero rates in percent, the slopes in basis points per year, and the forward rates in percent.
Its last line is the 2.5-year rate, linear then spline, and the spline's value of $1,000 there.
What a desk checks
The method is a few lines, but its inputs come from screens and spreadsheets. These checks catch the cases above.
- Sort, and fill gaps first. Use the quotes in order of maturity, and interpolate any missing par yield before bootstrapping.
- Units. Par yields typed in percent, 3.0 for 3%, give and then . A discount factor must lie between 0 and 1, so reject any that does not.
- Compounding. These zero rates compound once a year. The continuous rate is (case study 1.4.6): 3.3501% at 2 years against 3.4068%, a gap of about 5.7 basis points. Never mix the two.
- Look at the forward rates. A sudden jump between neighbouring years points to a bad quote.
- Flag extrapolation. Mark every value past the last quote, and say which rule produced it.
Try it
The first line bootstraps the five yields typed in percent. From year 3 on, the factors are negative.
The second converts the 2-year zero rate to a continuous rate, in percent.
Bootstrap a zero curve
Write two functions.
bootstrap(par_yields)takes par yields for maturities 1, 2, 3, … years and returns a NumPy array of discount factors , using the rule above. If a factor comes out zero or negative, raiseValueError: the yields were probably typed in percent.zero_rate_at(t, par_yields)returns the zero rate at timetin years, possibly not a whole number. Bootstrap, convert to zero rates, and interpolate linearly withnp.interp. Iftis before the first maturity or after the last, raiseValueError: refuse to extrapolate.
The tests check that each par bond is priced at exactly 1 by your factors, and try the bad inputs from the desk checks.
See the tests
import numpy as np
PAR = [0.030, 0.034, 0.037, 0.039, 0.040]
def test_one_year():
"""The 1-year factor is 1 / 1.03"""
expect_close(bootstrap(PAR)[0], 1 / 1.03, what="D_1")
def test_every_par_bond_prices_at_one():
"""Each n-year par bond is worth exactly 1 with your factors"""
D = np.asarray(bootstrap(PAR))
for n, c in enumerate(PAR, start=1):
expect_close(c * D[:n].sum() + D[n - 1], 1.0, what=f"{n}-year par bond")
def test_flat_curve():
"""If every par yield is 5%, every zero rate is 5%"""
D = np.asarray(bootstrap([0.05] * 4))
expect_close(D, 1.05 ** -np.arange(1, 5), what="factors for a flat 5% curve")
def test_zero_rate_on_the_grid():
"""At a whole year, zero_rate_at matches the bootstrapped zero rate: 3 years"""
D3 = np.asarray(bootstrap(PAR))[2]
expect_close(zero_rate_at(3, PAR), D3 ** (-1 / 3) - 1, what="zero_rate_at(3)")
def test_zero_rate_between():
"""At 2.5 years the zero rate is about 3.5616%"""
expect_close(zero_rate_at(2.5, PAR), 0.035615540935610146, what="zero_rate_at(2.5)", rel=1e-6)
def test_percent_inputs_rejected():
"""Par yields typed in percent (3.0 for 3%) raise ValueError"""
try:
bootstrap([3.0, 3.4, 3.7, 3.9, 4.0])
except ValueError:
return
raise AssertionError("expected ValueError: with yields in percent, D_3 comes out negative")
def test_refuses_to_extrapolate():
"""Before 1 year or after the last maturity, zero_rate_at raises ValueError"""
for t in (0.5, 6):
try:
zero_rate_at(t, PAR)
except ValueError:
continue
raise AssertionError(f"expected ValueError at t = {t}: the quotes run from 1 to 5 years")
Summary card
- A discount factor is today's value of $1 at time ; the zero rate is .
- A par bond priced at 1 gives . Bootstrap from the shortest maturity up, with the quotes sorted and every year filled.
- Interpolate between maturities, then convert back to discount factors. Zero rates, discount factors, rate times time and splines give slightly different answers; fix one convention.
- Past the last quote every rule is a guess, and the guesses spread widely. Hold flat and flag it, or find a longer quote.
- A bad quote makes a kink in the zero curve and a jump in the forward rates. Linear interpolation keeps the damage local; a spline spreads it.