SigmaField
Mathematical Foundations for Quant Finance
0 of 256 lessons
      1. 1.6.1 Linear interpolationConcept · 25 min
      2. 1.6.2 Cubic splinesConcept · 30 min
      3. 1.6.3 Practice set: interpolationPractice set · 20 min
      4. 1.6.4 Case study: building a zero curveCase study · 30 min

Level 1, Precalculus 1.6 Interpolation Lesson 4 of 4

Case studyLesson 1.6.4 in Level 1, Precalculus. About 30 minutes.

Case study: building a zero curve

Discount factors and zero rates

A discount factor DtD_t is what $1 paid at time tt, in years, is worth today. The matching zero rate ztz_t is the yearly rate, compounded once a year, that gives it: Dt=(1+zt)−tD_t = (1 + z_t)^{-t}. 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 nn-year par yield cnc_n is the coupon rate at which an nn-year bond is priced exactly at its face value. In this case study they are:

maturity (years)12345
par yield3.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 cnc_n at years 1 to nn, and the face value at year nn.

1=cn∑t=1nDt+Dn1 = c_n \sum_{t=1}^{n} D_t + D_n

Every DtD_t before year nn is already known from the shorter bonds. So only DnD_n is unknown, and the equation can be rearranged for it, as in lesson 0.3.2:

Rule: Bootstrapping a discount factor

Dn=1−cn∑t=1n−1Dt1+cnD_n = \frac{1 - c_n \sum_{t=1}^{n-1} D_t}{1 + c_n}

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: zn=Dn−1/n−1z_n = D_n^{-1/n} - 1.

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.

⌘/Ctrl + Enter runs the cell

The order matters

The rule needs every factor before year nn, so the quotes must be used from the shortest maturity up. The loop assumes that the kk-th quote is the kk-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 D3D_3. 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.

⌘/Ctrl + Enter runs the cell

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 z2.5≈3.5616%z_{2.5} \approx 3.5616\%, so $1,000 due then is worth 1000×1.035616−2.5≈1000 \times 1.035616^{-2.5} \approx $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 inzero rate at 2.5value of $1,000
zero rates ztz_t3.5616%$916.23
discount factors DtD_t3.583%$915.75
rate times time, −ln⁡Dt-\ln D_t3.5924%$915.55
zero rates, cubic spline3.5739%$915.95

Rate times time. With continuous compounding (case study 1.4.6), Dt=e−rttD_t = e^{-r_t t}, where rtr_t is the continuous zero rate. So −ln⁡Dt=rt t-\ln D_t = r_t\,t, 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 ln⁡Dt\ln D_t; the minus sign does not change the straight line.

⌘/Ctrl + Enter runs the cell

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 years7-year zero ratevalue of $1,000
hold the 5-year rate (np.interp)4.029%$758.44
continue the last line4.2369%$747.91
continue the spline3.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.

⌘/Ctrl + Enter runs the cell

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 D5D_5 is built from D4D_4. 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, −11-11 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 ff from year n−1n - 1 to year nn is the growth the curve implies for that year alone: 1+f=Dn−1Dn1 + f = \frac{D_{n-1}}{D_n}.

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.

⌘/Ctrl + Enter runs the cell
Check yourself
The zero curve is interpolated linearly. A typo in the 4-year quote changes which of these rates?

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 D1=0.25D_1 = 0.25 and then D3≈−0.0109D_3 \approx -0.0109. 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 ln⁡(1+z)\ln(1 + z) (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.

⌘/Ctrl + Enter runs the cell
Check yourself
Why bootstrap at all, instead of discounting every payment at the bond's own yield?
Exercise

Bootstrap a zero curve

Write two functions.

  1. bootstrap(par_yields) takes par yields for maturities 1, 2, 3, … years and returns a NumPy array of discount factors D1,D2,…D_1, D_2, \ldots, using the rule above. If a factor comes out zero or negative, raise ValueError: the yields were probably typed in percent.
  2. zero_rate_at(t, par_yields) returns the zero rate at time t in years, possibly not a whole number. Bootstrap, convert to zero rates, and interpolate linearly with np.interp. If t is before the first maturity or after the last, raise ValueError: 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 DtD_t is today's value of $1 at time tt; the zero rate is zt=Dt−1/t−1z_t = D_t^{-1/t} - 1.
  • A par bond priced at 1 gives Dn=1−cn∑t<nDt1+cnD_n = \frac{1 - c_n \sum_{t < n} D_t}{1 + c_n}. 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.

Free further reading

Optional. This lesson covers what you need; these add more examples and practice. All are free to read.