Term Structure of Interest Rates › Practical Curve Construction
Quantitative FinanceAll chapters 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
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)# 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)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:
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)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.

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:
Where is differentiable, , so a positive zero-curve slope puts its instantaneous forward above the zero rate at . 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.
