Term Structure of Interest Rates › Bootstrapping the Zero Curve

Michael BrenndoerferNovember 7, 202512 min read
Quantitative FinanceAll chapters
Part 2 of 15Equity and Fixed Income Markets
Chapter 12Term Structure of Interest Rates › Bootstrapping the Zero Curve
Reading section 3 of 8 · Bootstrapping the Zero Curve · 12 minSection 3 of 8 · 12 min

Book contents

Section 3 of 8 · 12 min read

Term Structure of Interest Rates › Bootstrapping the Zero CurveOne section · Sections in this chapter3 / 8 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Direct single-payment prices do not cover every maturity needed for a curve. Long-dated zeros do exist, including Treasury STRIPS, but coupon-bearing instruments often supply additional pricing information. Bootstrapping is one way to extract maturity-specific discount factors from those prices.

A coupon bond's yield to maturity is the single internal rate of return that discounts its payment schedule to its price under a stated convention. It summarizes several cash-flow dates through a nonlinear pricing equation; it is not a simple average of their zero rates. We can work backward from coupon bond prices sequentially, or use a fitted curve model.

The Bootstrapping Algorithm

Bootstrapping

Bootstrapping extracts zero-coupon rates or discount factors from instrument prices in maturity order. Each step uses previously determined nodes and a stated interpolation rule for any missing payment dates, then solves the new maturity's pricing equation.

With suitable instruments and a local interpolation rule, each step can leave one new scalar unknown. Earlier coupon-date discounts must already be known or supplied by that rule. When interpolation depends on the candidate maturity rate, solve the resulting scalar equation rather than treating those intermediate discounts as fixed.

The algorithm works as follows:

  1. Start with an instrument that isolates one discount factor, such as a zero-coupon bond. The shortest coupon bond alone may not identify discounts for all its payment dates.
  2. For each successive maturity, discount earlier cash flows using solved nodes and the chosen interpolation rule.
  3. Solve for the new rate that reproduces the instrument's price, checking that the implied discount factor is positive.
  4. Repeat for the remaining instruments, with consistent currency, credit and quoting conventions.

Consider a 2-year annual coupon bond with face value $100, annual coupon payment cc dollars, and price PP. Here cc is a cash amount: a 5% annual coupon on $100 face gives c=5c=5. If we know the 1-year zero rate z(1)z(1), we can solve for z(2)z(2):

P=c⋅e−z(1)⋅1+(100+c)⋅e−z(2)⋅2P = c \cdot e^{-z(1) \cdot 1} + (100 + c) \cdot e^{-z(2) \cdot 2}

This equation says that the bond's price equals the present value of all its cash flows. The first coupon payment at year 1 is discounted at the known 1-year rate. The final payment (coupon plus principal) at year 2 is discounted at the unknown 2-year rate.

Rearranging step by step to isolate the unknown rate:

P=c⋅e−z(1)⋅1+(100+c)⋅e−z(2)⋅2(bond pricing equation)P−c⋅e−z(1)=(100+c)⋅e−z(2)⋅2(isolate unknown term)e−z(2)⋅2=P−c⋅e−z(1)100+c(solve for discount factor)−z(2)⋅2=ln⁡(P−c⋅e−z(1)100+c)(take natural log)z(2)=−12ln⁡(P−c⋅e−z(1)100+c)(solve for z(2))\begin{aligned} P &= c \cdot e^{-z(1) \cdot 1} + (100 + c) \cdot e^{-z(2) \cdot 2} && \text{(bond pricing equation)} \\ P - c \cdot e^{-z(1)} &= (100 + c) \cdot e^{-z(2) \cdot 2} && \text{(isolate unknown term)} \\ e^{-z(2) \cdot 2} &= \frac{P - c \cdot e^{-z(1)}}{100 + c} && \text{(solve for discount factor)} \\ -z(2) \cdot 2 &= \ln\left(\frac{P - c \cdot e^{-z(1)}}{100 + c}\right) && \text{(take natural log)} \\ z(2) &= -\frac{1}{2} \ln\left(\frac{P - c \cdot e^{-z(1)}}{100 + c}\right) && \text{(solve for } z(2) \text{)} \end{aligned}

where:

  • PP: market price of the 2-year bond
  • cc: annual coupon payment
  • z(1)z(1): previously bootstrapped 1-year zero rate
  • z(2)z(2): unknown 2-year zero rate we're solving for

The numerator is the residual price after subtracting the first coupon's present value. It must be positive for a finite real zero rate when the final cash flow is positive. Dividing by that final payment gives its discount factor; the logarithm then recovers the rate. If the residual is nonpositive, check the data, assumptions and instrument suitability rather than taking its logarithm.

Worked Example: Bootstrapping from Bond Prices

Let's bootstrap a zero curve from the following bonds, all with $100 face value and annual coupon payments:

Bond data for bootstrapping example.
MaturityCoupon RatePrice
1 year0%$96.15
2 years5%$101.50
3 years6%$103.00

Step 1: 1-year zero rate

The 1-year bond is a zero-coupon bond, so we can extract its rate directly using the fundamental zero-rate formula:

z(1)=−11ln⁡(96.15100)(zero rate formula)=−ln⁡(0.9615)(simplify)≈0.0392607≈3.93%(rounded result)\begin{aligned} z(1) &= -\frac{1}{1} \ln\left(\frac{96.15}{100}\right) && \text{(zero rate formula)} \\ &= -\ln(0.9615) && \text{(simplify)} \\ &\approx 0.0392607 \approx 3.93\% && \text{(rounded result)} \end{aligned}

Step 2: 2-year zero rate

The 2-year bond pays $5 at year 1 and $105 at year 2. We know the 1-year rate, so we can discount the first coupon and solve for the 2-year rate:

101.50=5D(1)+105⋅e−z(2)⋅2101.50 = 5D(1) + 105 \cdot e^{-z(2) \cdot 2} 101.50=5⋅0.9615+105⋅e−z(2)⋅2101.50 = 5 \cdot 0.9615 + 105 \cdot e^{-z(2) \cdot 2} 101.50=4.8075+105⋅e−z(2)⋅2101.50 = 4.8075 + 105 \cdot e^{-z(2) \cdot 2} D(2)=e−z(2)⋅2=96.6925105≈0.9208809524D(2)=e^{-z(2) \cdot 2} = \frac{96.6925}{105} \approx 0.9208809524 z(2)=−12ln⁡(96.6925105)≈0.0412123≈4.12%z(2) = -\frac{1}{2} \ln\left(\frac{96.6925}{105}\right) \approx 0.0412123 \approx 4.12\%

Step 3: 3-year zero rate

The 3-year bond pays $6 at years 1 and 2, and $106 at year 3. Using the rates already determined, we can discount the intermediate coupons and solve for the remaining unknown:

103.00=6D(1)+6D(2)+106⋅e−z(3)⋅3103.00 = 6D(1) + 6D(2) + 106 \cdot e^{-z(3) \cdot 3} 103.00=6⋅0.9615+6⋅96.6925105+106⋅e−z(3)⋅3103.00 = 6 \cdot 0.9615 + 6 \cdot \frac{96.6925}{105} + 106 \cdot e^{-z(3) \cdot 3} 6D(1)+6D(2)≈5.769+5.5252857143=11.29428571436D(1)+6D(2) \approx 5.769 + 5.5252857143 = 11.2942857143 D(3)=e−z(3)⋅3=103−6D(1)−6D(2)106≈91.7057142857106≈0.8651482480D(3)=e^{-z(3) \cdot 3} = \frac{103-6D(1)-6D(2)}{106} \approx \frac{91.7057142857}{106} \approx 0.8651482480 z(3)=−13ln⁡D(3)≈0.0482848≈4.83%z(3) = -\frac{1}{3} \ln D(3) \approx 0.0482848 \approx 4.83\%

We retain unrounded discount factors throughout the calculation and round only the displayed results. The bootstrapped zero curve is:

Bootstrapped zero curve from bond prices.
MaturityZero Rate
1 year3.93%
2 years4.12%
3 years4.83%
Out[6]:
Visualization
Bar chart showing a single blue $100 cash flow at year 1, with a dashed coral line at the bond price of $96.15.
Step 1: Extract 1-year zero rate (3.93%) directly from a zero-coupon bond priced at $96.15 using the fundamental zero rate formula.
Bar chart with a teal $5 coupon bar at year 1 and a blue $105 bar at year 2, with a dashed coral line at price $101.50.
Step 2: Discount the first coupon of the 2-year bond using z(1), then solve for the 2-year zero rate (4.12%) from the remaining present value.
Bar chart with teal $6 coupon bars at years 1 and 2 and a blue $106 bar at year 3, with a dashed coral line at price $103.00.
Step 3: Discount intermediate coupons of the 3-year bond using z(1) and z(2), then solve for the 3-year zero rate (4.83%) from the final payment.

Python Implementation of Bootstrapping

In[7]:
Code
import numpy as np


def bootstrap_zero_curve(maturities, coupon_rates, prices, face_value=100):
    """
    Bootstrap zero rates from coupon bond prices.

    Parameters:
    -----------
    maturities : array-like
        Bond maturities in years (assumed to be 1, 2, 3, ...)
    coupon_rates : array-like
        Annual coupon rates as decimals
    prices : array-like
        Bond prices
    face_value : float
        Face value of bonds

    Returns:
    --------
    zero_rates : ndarray
        Continuously compounded zero rates
    discount_factors : ndarray
        Discount factors for each maturity

    Raises:
    -------
    ValueError
        If a final payment, residual price after known cash flows, or implied
        discount factor is nonfinite or nonpositive.
    """
    n = len(maturities)
    zero_rates = np.zeros(n)
    discount_factors = np.zeros(n)

    for i in range(n):
        maturity = maturities[i]
        coupon = coupon_rates[i] * face_value
        price = prices[i]

        # Calculate PV of known cash flows
        pv_known = 0
        for j in range(i):
            pv_known += coupon * discount_factors[j]

        # Final cash flow
        final_cf = face_value + coupon
        if not np.isfinite(final_cf) or final_cf <= 0:
            raise ValueError("Final cash flow must be finite and positive.")

        # Solve for discount factor
        residual = price - pv_known
        if not np.isfinite(residual) or residual <= 0:
            raise ValueError(
                "Residual price must be finite and positive; "
                "check quotes and valuation assumptions."
            )
        df = residual / final_cf
        if not np.isfinite(df) or df <= 0:
            raise ValueError(
                "Implied discount factor must be finite and positive; "
                "check quotes and valuation assumptions."
            )
        discount_factors[i] = df

        # Convert to zero rate
        zero_rates[i] = -np.log(df) / maturity

    return zero_rates, discount_factors
In[8]:
Code
# Bond data from our example
maturities = np.array([1, 2, 3])
coupon_rates = np.array([0.00, 0.05, 0.06])
prices = np.array([96.15, 101.50, 103.00])

# Bootstrap the zero curve
zero_rates, discount_factors = bootstrap_zero_curve(maturities, coupon_rates, prices)
Out[9]:
Console
Bootstrapped Zero Curve
----------------------------------------
Maturity  Discount Factor  Zero Rate
----------------------------------------
  1 yr       0.9615         3.93%
  2 yr       0.9209         4.12%
  3 yr       0.8651         4.83%

The bootstrapped rates (3.93%, 4.12%, 4.83%) match our manual calculations exactly. The discount factors (0.9615, 0.9209, 0.8651) show how $1 received in the future is worth progressively less as the maturity increases. A dollar in 3 years is worth only about 87 cents today.

Handling Intermediate Maturities

Real-world bootstrapping must handle bonds that don't mature at convenient annual intervals. When you need a discount factor for a maturity that falls between bootstrapped points, you interpolate. Common interpolation methods include:

  • Linear interpolation on zero rates: Simple but can produce non-smooth forward rates
  • Log-linear interpolation on discount factors: Preserves positive factors between positive endpoints; the implied interval forward rate is positive, zero, or negative according to whether the factors decline, stay constant, or increase
  • Cubic spline interpolation: Produces smooth curves but may oscillate
In[10]:
Code
from scipy.interpolate import CubicSpline


def interpolate_zero_curve(
    known_maturities, known_rates, target_maturities, method="cubic"
):
    """
    Interpolate within the known maturity range.

    Outside it, linear interpolation holds endpoint rates constant,
    while the cubic branch extrapolates its end polynomial.
    """
    if method == "linear":
        return np.interp(target_maturities, known_maturities, known_rates)
    elif method == "cubic":
        cs = CubicSpline(known_maturities, known_rates)
        return cs(target_maturities)
    else:
        raise ValueError(f"Unknown method: {method}")


# Create a fine grid of maturities
fine_maturities = np.linspace(0.5, 3.5, 100)

# Add an illustrative zero-year anchor equal to the one-year rate.
# This is a boundary assumption, not another observed instrument.
extended_maturities = np.concatenate([[0], maturities])
extended_rates = np.concatenate(
    [[zero_rates[0]], zero_rates]
)  # Equal endpoint anchors do not force a flat cubic between them.

# Interpolate
interpolated_rates = interpolate_zero_curve(
    extended_maturities, extended_rates, fine_maturities, method="cubic"
)
Out[11]:
Visualization
Line chart showing zero rates increasing from about 4% at 1 year to nearly 5% at 3 years.
Zero curve evaluated from 0.5 to 3.5 years. Coral dots mark the bootstrapped rates (3.93% at 1 year, 4.12% at 2 years and 4.83% at 3 years). The cubic spline uses an assumed zero-year anchor equal to the one-year rate, interpolates within its 0-to-3-year knot range and extrapolates beyond 3 years. It has a shallow dip near 0.7 years before rising; cubic interpolation need not preserve monotonicity.

Within its supported range, interpolation supplies discounts for intermediate payment dates. Outside that range, choose and justify an extrapolation policy. Pricing derivatives with uncertain payments can also require volatility, credit or other model inputs beyond this curve.