Term Structure of Interest Rates › Practical Curve Construction

Michael BrenndoerferNovember 7, 20258 min read
Quantitative FinanceAll chapters
Part 2 of 15Equity and Fixed Income Markets
Chapter 12Term Structure of Interest Rates › Practical Curve Construction
Reading section 5 of 8 · Practical Curve Construction · 8 minSection 5 of 8 · 8 min

Book contents

Section 5 of 8 · 8 min read

Term Structure of Interest Rates › Practical Curve ConstructionOne section · Sections in this chapter5 / 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

Building a production-quality yield curve involves several practical considerations beyond basic bootstrapping.

Instrument Selection

The choice of instruments for curve construction depends on the curve's intended use.

  • Treasury curves use government bond prices as a nominal reference in their currency; sovereign credit and liquidity characteristics still matter.
  • Swap curves depend on their currency, reference index and collateral conventions. Historical LIBOR curves differ from modern overnight-index curves. All LIBOR settings have ceased, with the final synthetic USD settings ending on 30 September 2024; SOFR is a USD replacement, not a universal replacement in every currency.
  • Corporate yields reflect credit risk together with liquidity, risk premia and other features. A spread over a reference curve is not pure default compensation.

Short-end instruments must match the intended curve: Treasury bills, unsecured commercial paper and overnight-index swaps have different risk and contractual characteristics. They are not interchangeable risk-free inputs. Longer maturities may use suitable bonds or swaps, with consistent reference and collateral conventions.

Building a Multi-Instrument Curve

In[14]:
Code
from scipy.optimize import brentq


def build_full_curve(instruments):
    """
    Build a zero curve from multiple instrument types.

    Parameters:
    -----------
    instruments : list of dicts
        Each dict contains: type, maturity, rate or price, and other info.
        Coupon bonds use a positive integer frequency (annual by default),
        a decimal annual coupon rate, and a regular schedule with no stubs.
        The coupon-bond solve needs a sign-changing rate bracket [-0.10, 1.00].
        Use quotes with consistent currency, credit and compounding conventions.

    Returns:
    --------
    maturities, zero_rates : arrays
    """
    # Sort by maturity
    instruments = sorted(instruments, key=lambda x: x["maturity"])

    maturities = []
    zero_rates = []

    for inst in instruments:
        t = inst["maturity"]

        if inst["type"] == "deposit":
            # Simple rate to continuous rate conversion
            simple_rate = inst["rate"]
            cont_rate = np.log(1 + simple_rate * t) / t
            maturities.append(t)
            zero_rates.append(cont_rate)

        elif inst["type"] == "zero_bond":
            price = inst["price"]
            face = inst.get("face", 100)
            cont_rate = -np.log(price / face) / t
            maturities.append(t)
            zero_rates.append(cont_rate)

        elif inst["type"] == "coupon_bond":
            price = inst["price"]
            face = inst.get("face", 100)
            frequency = inst.get("frequency", 1)
            numeric_types = (int, float, np.integer, np.floating)
            if (
                isinstance(frequency, (bool, np.bool_))
                or not isinstance(frequency, numeric_types)
                or not np.isfinite(frequency)
                or frequency <= 0
                or frequency != int(frequency)
            ):
                raise ValueError("Coupon frequency must be a positive integer.")
            frequency = int(frequency)
            if not np.isfinite(t) or t <= 0:
                raise ValueError("Coupon maturity must be finite and positive.")
            payment_count = int(round(t * frequency))
            if payment_count < 1 or not np.isclose(
                t * frequency, payment_count, rtol=0.0, atol=1e-10
            ):
                raise ValueError(
                    "Only regular coupon schedules without stubs are supported."
                )
            coupon = inst["coupon"] * face / frequency
            # Integer payment indices avoid floating steps including maturity.
            # The final coupon is added exactly once in final_pv below.
            coupon_times = np.arange(1, payment_count, dtype=float) / frequency

            known_maturities = np.asarray(maturities)
            known_rates = np.asarray(zero_rates)

            def price_residual(candidate_rate):
                # Include the candidate maturity so missing coupon-date rates,
                # such as the 4-year rate for a 5-year bond, are interpolated.
                curve_maturities = np.append(known_maturities, t)
                curve_rates = np.append(known_rates, candidate_rate)
                coupon_rates = np.interp(coupon_times, curve_maturities, curve_rates)
                pv_coupons = np.sum(coupon * np.exp(-coupon_rates * coupon_times))
                final_pv = (face + coupon) * np.exp(-candidate_rate * t)
                return pv_coupons + final_pv - price

            lower, upper = -0.10, 1.00
            lower_residual = price_residual(lower)
            upper_residual = price_residual(upper)
            if (
                not np.isfinite(lower_residual)
                or not np.isfinite(upper_residual)
                or (
                    lower_residual != 0
                    and upper_residual != 0
                    and np.signbit(lower_residual) == np.signbit(upper_residual)
                )
            ):
                raise ValueError(
                    f"Coupon bond at maturity {t} needs a finite sign-changing "
                    "rate bracket within [-0.10, 1.00]; review its quotes or model domain."
                )
            cont_rate = brentq(price_residual, lower, upper)
            maturities.append(t)
            zero_rates.append(cont_rate)

    return np.array(maturities), np.array(zero_rates)
In[15]:
Code
# Example: Build a curve from mixed instruments
instruments = [
    {"type": "deposit", "maturity": 0.25, "rate": 0.035},  # 3-month deposit
    {"type": "deposit", "maturity": 0.5, "rate": 0.037},  # 6-month deposit
    {"type": "zero_bond", "maturity": 1, "price": 96.15},  # 1-year T-bill
    {"type": "coupon_bond", "maturity": 2, "coupon": 0.05, "price": 101.50},
    {"type": "coupon_bond", "maturity": 3, "coupon": 0.06, "price": 103.00},
    {"type": "coupon_bond", "maturity": 5, "coupon": 0.055, "price": 100.50},
]

full_maturities, full_rates = build_full_curve(instruments)
Out[16]:
Console
Complete Zero Curve
------------------------------
Maturity    Zero Rate
------------------------------
  0.25 yr     3.48%
  0.50 yr     3.67%
  1.00 yr     3.93%
  2.00 yr     4.12%
  3.00 yr     4.83%
  5.00 yr     5.31%

The curve shows rates increasing from 3.48% at 3 months to 5.31% at 5 years. The short-end rates (deposits at 0.25 and 0.5 years) are lower than the bond-derived rates, which reflects the upward slope in these inputs. The increase from the 3-year rate (4.83%) to the 5-year rate (5.31%) gives higher maturity-specific discount rates. Interpreting it as expected future rate increases requires assumptions or additional evidence that separates expectations from term premia, such as a term-structure model or surveys; the slope alone does not establish that forecast. Because no 4-year instrument is supplied, the bootstrap linearly interpolates the 4-year zero rate between the solved 3- and 5-year nodes when valuing the 5-year bond's year-4 coupon.

Computing Forward Rates from the Curve

Once you have a zero curve, you can derive forward rates for any period:

In[17]:
Code
def compute_forward_rates(maturities, zero_rates):
    """
    Compute forward rates between consecutive maturities.
    """
    forward_rates = np.zeros(len(maturities))
    forward_rates[0] = zero_rates[0]  # First forward = first spot

    for i in range(1, len(maturities)):
        t1 = maturities[i - 1]
        t2 = maturities[i]
        z1 = zero_rates[i - 1]
        z2 = zero_rates[i]
        forward_rates[i] = (z2 * t2 - z1 * t1) / (t2 - t1)

    return forward_rates


forward_rates = compute_forward_rates(full_maturities, full_rates)
Out[18]:
Console
Zero Rates vs Forward Rates
---------------------------------------------
Period          Zero Rate   Forward Rate
---------------------------------------------
0 to 0.25yr        3.48%        3.48%
0.25 to 0.50yr     3.67%        3.85%
0.50 to 1.00yr     3.93%        4.19%
1.00 to 2.00yr     4.12%        4.32%
2.00 to 3.00yr     4.83%        6.24%
3.00 to 5.00yr     5.31%        6.03%

The forward rates range from 3.48% for the first quarter to 6.24% for the period from year 2 to year 3. The forward rate from year 3 to year 5 is 6.03%, which exceeds the 5-year zero rate of 5.31%. This divergence occurs because forward rates must compensate for the lower rates earlier in the curve. The higher marginal rates at the long end pull up the average to reach the 5-year zero rate.

Out[19]:
Visualization
Bar chart comparing zero rates and forward rates across different maturities from 3 months to 5 years.
Side-by-side comparison of zero rates and forward rates across six maturities from 3 months to 5 years. Zero rates (blue bars) increase steadily from 3.48% to 5.31%. Forward rates (coral bars) range from 3.48% for the initial period to 6.24% for the period from year 2 to year 3. The initial forward rate equals the initial zero rate; subsequent forward rates exceed their corresponding zero rates, reflecting the relationship between marginal rates and cumulative averages along an upward-sloping curve.

The later interval forwards in the table exceed their endpoint zero rates because the zero nodes increase. For a differentiable continuous zero curve, the related instantaneous statement follows from the averaging relationship:

z(T)=1T∫0Tf(t) dtz(T) = \frac{1}{T} \int_0^T f(t) \, dt

Where zz is differentiable, f(T)=z(T)+Tz′(T)f(T)=z(T)+Tz'(T), so a positive zero-curve slope puts its instantaneous forward above the zero rate at T>0T>0. The next figure instead interpolates the finite-period forward values and zero nodes separately for display. Its dashed line is not the instantaneous forward derived from the blue interpolation; the shaded gap is only a comparison of those two displayed series.

Out[20]:
Visualization
Line chart with a blue solid zero rate curve rising from 3.48% to 5.31%, a coral dashed forward rate curve peaking at 6.24% near year 3, and a shaded region filling the spread between the curves.
Zero rates and finite-period forward rates interpolated separately for display from 0.25 to 5 years. The blue zero curve rises from 3.48% to 5.31%. The coral period-forward display starts at the same value, peaks at 6.24% near year 3 and ends at 6.03% at year 5. The shaded region compares these independent interpolations; it is not the instantaneous marginal-forward spread implied by the zero curve.