The Yield Curve, End to End#
Every interest-rate calculation starts from a discount curve, but markets
quote par rates, not discount factors. This tutorial follows one curve
from quotes to derivatives across two domains: bootstrapping and
smoothing it in financialkit.fixed_income, pricing a swap on it,
fitting a Hull-White short-rate model to it in
financialkit.interest_rate_models, checking that model by Monte
Carlo through financialkit.integrators, and finally pricing a cap
two ways.
1. Bootstrapping par rates into zero rates#
Par rates are the coupons at which bonds of each maturity would trade at par. Bootstrapping solves for discount factors one maturity at a time, each using the ones already found:
import numpy as np
import financialkit as fk
maturities = np.array([0.5, 1, 2, 3, 5, 7, 10, 20, 30.0])
par_rates = np.array([3.10, 3.20, 3.35, 3.50, 3.70, 3.85, 4.00, 4.25, 4.30]) / 100
curve = fk.fixed_income.bootstrap_par_curve(maturities, par_rates)
t = np.array([1.0, 5.0, 10.0, 30.0])
print("zero rates (%):", np.round(100 * curve.zero_rate(t), 3))
# zero rates (%): [3.175 3.683 4.006 4.356]
print("discount factors:", np.round(curve.discount(t), 5))
# discount factors: [0.96874 0.83179 0.66994 0.2707 ]
print(f"repriced 10-year par yield: {100 * curve.par_yield(10.0):.4f}%")
# repriced 10-year par yield: 4.0000%
The curve reprices its own inputs exactly. Zero rates follow the package-wide convention of continuous compounding, so the 1-year zero rate of 3.175% is just the 3.20% semiannual par rate restated. Further out the two part ways: on an upward-sloping curve the 10-year zero rate (4.006%) sits above the continuously compounded 10-year par rate (3.961%), because a par bond’s early coupons are discounted at the lower short rates.
2. Smoothing with Nelson-Siegel#
A bootstrapped curve passes through every quote, noise included. Nelson and Siegel’s four-parameter curve (level, slope, curvature and a decay time) is what central banks publish instead:
fit = fk.fixed_income.fit_nelson_siegel(maturities, curve.zero_rate(maturities))
b0, b1, b2, tau = fit.params
print(f"level {100 * b0:.2f}%, slope {100 * b1:.2f}%, curvature {100 * b2:.2f}%, tau {tau:.2f} years")
# level 4.43%, slope -1.44%, curvature 1.20%, tau 6.93 years
print(f"RMSE against the bootstrapped curve: {1e4 * fit.rmse:.1f} bp")
# RMSE against the bootstrapped curve: 1.1 bp
Four numbers summarise the whole term structure to about a basis point.
3. A swap on the curve#
The par swap rate is the fixed rate that gives a swap zero value. Paying fixed below it is worth money:
print(f"5-year par swap rate: {100 * fk.fixed_income.swap_rate(curve, 5.0):.4f}%")
# 5-year par swap rate: 3.7000%
swap = fk.fixed_income.interest_rate_swap_value(curve, 10_000_000, 0.035, 5.0)
print(f"pay 3.50% fixed on 10 million for 5 years: worth {swap.value:,.0f}")
# pay 3.50% fixed on 10 million for 5 years: worth 90,923
With semiannual payments the 5-year swap rate equals the 5-year par yield, 3.70%: a swap is a par bond against a floating-rate note.
4. A short-rate model fitted to the curve#
Pricing options on rates needs a model of how the curve moves. Hull and White made Vasicek’s mean-reverting short rate fit today’s curve exactly, by letting its long-run level drift with time:
hw = fk.interest_rate_models.HullWhiteModel(curve, a=0.1, sigma=0.01)
print("model bond prices:", np.round(hw.zero_coupon_bond(t), 5))
# model bond prices: [0.96874 0.83179 0.66994 0.2707 ]
print("max mismatch with the curve:", f"{np.abs(hw.zero_coupon_bond(t) - curve.discount(t)).max():.1e}")
# max mismatch with the curve: 0.0e+00
The closed-form bond prices match the curve by construction. A stronger
check simulates the short rate and averages discount factors along each
path (the Feynman-Kac representation). The paths come from the shared
SDE schemes in financialkit.integrators:
mc = fk.interest_rate_models.monte_carlo_bond_prices(hw, [1.0, 2.0, 5.0, 10.0], n_steps=200, n_paths=20_000, rng=2)
print("Monte Carlo:", np.round(mc.prices, 5))
# Monte Carlo: [0.96873 0.93564 0.83159 0.66898]
print("(MC - exact) / SE:", np.round(mc.z_scores, 2))
# (MC - exact) / SE: [-0.37 -0.11 -0.64 -1.57]
Every z-score is within two standard errors, so the simulation agrees with the closed form.
5. A cap, two ways#
A 5-year cap at 4% pays whenever 3-month rates fix above 4%. The market
convention prices each caplet with Black-76, the single implementation
in financialkit.options that the rates domain calls, using a
lognormal forward with a quoted volatility. Hull-White prices the same
cap from its own normal dynamics:
black = fk.interest_rate_models.cap_floor_price(curve, 0.04, 5.0, vol=0.20)
model = fk.interest_rate_models.model_cap_floor_price(hw, 0.04, 5.0)
print(f"Black-76, 20% vol: {1e4 * black.price:.1f} bp of notional")
# Black-76, 20% vol: 159.8 bp of notional
print(f"Hull-White, a = 0.1, sigma = 1%: {1e4 * model.price:.1f} bp of notional")
# Hull-White, a = 0.1, sigma = 1%: 179.8 bp of notional
The two disagree because a 20% lognormal vol and a 1% normal vol are
different assumptions. Calibrating a model means choosing a and
sigma so that its cap prices match the market’s Black quotes, after
which it can price instruments with no market quote, such as Bermudan
swaptions.