Home › Financial infrastructure › Pricing & risk models
algorithm medium

Yield curve bootstrapping

Problem A pricing, risk or actuarial system needs one consistent set of discount factors and forward rates for every maturity, but the market quotes only a few instruments at scattered maturities, each with its own payment dates.

Turn quoted deposits, futures and swaps into a discount curve by solving one new maturity per instrument so that every quote reprices exactly.

O(N·M·k) discount lookups for N pillars, M payments, k Newton steps; N rebuilds for N key-rate deltas
term-structurediscount-factorcalibrationinterpolationsensitivities
Overview

A market quotes a handful of instruments, but a pricer needs the value of one unit paid at any date. Sort the instruments by maturity. Each adds one unknown, the discount factor at its maturity. Everything before it is already solved, so the unknown comes from one equation: closed form for a deposit, a 1-D root find for a swap. The curve reprices every input by construction.

Key facts

Use when

  • Rates desks: price swaps, bonds and options consistently with the quotes.
  • Insurance and pensions: discount liabilities on a curve built from swap quotes.
  • Credit: same recursion on CDS spreads, solving survival probabilities.
  • Commodities: a forward curve from a strip of futures with nested delivery periods.
  • Project finance: discount cash flows at the date-matched df, not a flat rate.

Avoid when

  • Few, noisy quotes: fit a smooth global form (Nelson-Siegel).

Deep dive

How it works

Parts:

  • Instruments — quotes sorted by maturity, each with a schedule and a pricing equation.
  • Curve — pillars (t, ln df), an interpolation rule between and an extrapolation rule beyond.
  • Solver — finds the one new pillar per instrument.
  • Risk layer — key-rate deltas by bumping quotes or by a Jacobian.
  1. Start with df(0) = 1; sort instruments by strictly increasing maturity.
  2. Deposit over τ at simple rate r: df(τ) = 1/(1+rτ).
  3. Par swap, annual fixed leg at rate s: s·Σᵢ df(i) = 1 − df(n). Dates up to the last pillar read the curve; later dates interpolate toward the unknown x = ln df(n).
  4. Solve g(x) = s·Σ df(i) − (1 − eˣ) = 0 by Newton, started at the previous pillar's zero rate. ∂ln df(i)/∂x is the interpolation weight w, so g′ is exact.
  5. Append the pillar; repeat.
  6. Query: interpolate ln df; forwards come from differences.
quotes ──sort──► dep(0.25) ► dep(0.5) ► swap(1) ► swap(2) ► swap(5)
                    │           │          │          │         │
curve pillars:   (0.25,·)   (0.5,·)     (1,·)      (2,·)     (5,·)   one new each; swap(5) reads
                                                                      df(2), interpolates df(3), df(4)

Interpolation decides what lies between pillars. Linear in ln df gives piecewise-flat forwards that jump at pillars. A cubic spline on zeros is smoother but non-local and can give negative or oscillating forwards. Monotone convex (Hagan & West 2006) is designed to keep forwards positive and local.

Multi-curve: discount on an overnight (OIS) curve, project each floating tenor (3M, 6M) on its own; the curves are solved jointly.

Code

Deposits and annual-fixed par swaps with year fraction 1.0 (no calendars; see day-count conventions), log-linear df, Newton per swap pillar.

#[derive(Clone, Copy)]
pub enum Inst {
    Deposit { t: f64, rate: f64 },  // simple rate: df = 1/(1+rate*t)
    Swap { years: u32, rate: f64 }, // annual fixed leg, year fraction 1: rate*sum(df_i) = 1 - df_n
}

/// Pillars (t, ln df), pillar 0 = (0, 0). ln df is linear between pillars (flat forwards);
/// past the last pillar the last forward continues.
#[derive(Clone)]
pub struct Curve { pub t: Vec<f64>, pub l: Vec<f64> }

impl Curve {
    pub fn new() -> Self { Curve { t: vec![0.0], l: vec![0.0] } }

    pub fn ln_df(&self, x: f64) -> f64 {
        let i = self.t.partition_point(|&p| p < x).clamp(1, self.t.len() - 1);
        let (t0, t1) = (self.t[i - 1], self.t[i]);
        self.l[i - 1] + (self.l[i] - self.l[i - 1]) * (x - t0) / (t1 - t0)
    }
    pub fn df(&self, x: f64) -> f64 { self.ln_df(x).exp() }

    /// Quote implied by the curve.
    pub fn implied(&self, q: &Inst) -> f64 {
        match *q {
            Inst::Deposit { t, .. } => (1.0 / self.df(t) - 1.0) / t,
            Inst::Swap { years, .. } => (1.0 - self.df(years as f64)) / (1..=years).map(|i| self.df(i as f64)).sum::<f64>(),
        }
    }

    /// Solve pillars `from..` of `ins` (sorted); earlier pillars are kept.
    pub fn bootstrap_from(&mut self, ins: &[Inst], from: usize) {
        self.t.truncate(from + 1);
        self.l.truncate(from + 1);
        for q in &ins[from..] {
            let (tp, lp) = (*self.t.last().unwrap(), *self.l.last().unwrap());
            match *q {
                Inst::Deposit { t, rate } => {
                    self.t.push(t);
                    self.l.push(-(1.0 + rate * t).ln()); // closed form
                }
                Inst::Swap { years, rate } => {
                    let n = years as f64;
                    assert!(n > tp);
                    self.t.push(n);
                    self.l.push(if tp > 0.0 { lp / tp * n } else { -0.03 * n }); // start: previous zero rate
                    let k = self.t.len() - 1;
                    let j = (tp.floor() as u32).min(years); // payments up to here ignore the unknown
                    let fixed: f64 = (1..=j).map(|i| self.df(i as f64)).sum();
                    for _ in 0..50 {
                        let (mut f, mut d) = (fixed * rate, 0.0);
                        for i in j + 1..=years {
                            let (df, w) = (self.df(i as f64), (i as f64 - tp) / (n - tp));
                            f += rate * df;
                            d += rate * df * w; // d ln df_i / d ln df_n = w
                        }
                        let dn = self.l[k].exp();
                        let (f, d) = (f - (1.0 - dn), d + dn);
                        if f.abs() < 1e-15 { break; }
                        self.l[k] -= f / d; // Newton on x = ln df_n
                    }
                }
            }
        }
    }
}

fn main() {
    let sw = |s: &[(u32, f64)]| s.iter().map(|&(y, r)| Inst::Swap { years: y, rate: r }).collect::<Vec<_>>();
    let mut ins = vec![Inst::Deposit { t: 0.25, rate: 0.030 }, Inst::Deposit { t: 0.5, rate: 0.031 }];
    ins.extend(sw(&[(1, 0.032), (2, 0.034), (3, 0.035), (5, 0.038), (10, 0.041)]));
    let mut c = Curve::new();
    c.bootstrap_from(&ins, 0);
    for q in &ins {
        let (Inst::Deposit { rate, .. } | Inst::Swap { rate, .. }) = *q;
        assert!((c.implied(q) - rate).abs() < 1e-12); // every quote reprices
    }
    assert!(c.l.windows(2).all(|w| w[1] < w[0])); // df strictly decreasing
    let fwd = |a: f64, b: f64| (c.ln_df(a) - c.ln_df(b)) / (b - a);
    assert!((fwd(3.1, 3.6) - fwd(4.2, 4.9)).abs() < 1e-10); // flat inside 3y..5y
    assert!((fwd(3.5, 4.5) - fwd(5.5, 6.5)).abs() > 1e-4); // but not across pillars
    let mut f = Curve::new(); // flat 4 % par swaps: flat 4 % annually compounded zeros
    f.bootstrap_from(&sw(&[(1, 0.04), (2, 0.04), (3, 0.04), (5, 0.04), (10, 0.04)]), 0);
    for y in [1.0, 2.0, 4.0, 7.5, 10.0] { assert!((f.df(y).powf(-1.0 / y) - 1.04).abs() < 1e-12); }
    let mut up = ins.clone(); // bump the 2y quote, re-solve only later pillars
    if let Inst::Swap { rate, .. } = &mut up[3] { *rate += 1e-4; }
    let (mut part, mut full) = (c.clone(), Curve::new());
    part.bootstrap_from(&up, 3);
    full.bootstrap_from(&up, 0);
    assert!(part.l.iter().zip(&full.l).all(|(a, b)| (a - b).abs() < 1e-14));
}

Compiled (rustc 1.99.1), asserts pass: inputs reprice within 1e-12 (3.5e-16 at 38 pillars); flat 4 % swaps give a flat zero curve; forwards are constant inside 3y-5y; a suffix rebuild equals a full one.

Complexity and constants

Each swap pillar costs O(M·k) lookups of O(log N) each, so O(N·M·k·log N) overall; M grows with maturity. State is two f64 per pillar. A rebuild is a dependent chain and does not parallelise; N bumped rebuilds do.

Efficiency

  • Cost: Apple M4 Pro, one core, release build, measured: 38 pillars (4 deposits, swaps at 1-30, 35, 40, 45, 50 years) rebuild in 6.9-7.3 µs, about 0.2 µs per pillar; bound is exp and lookups. All 38 key-rate bumps (+1 bp, full rebuild): 270-295 µs including cloning.
  • Levers:
    • Hoist payments before the last pillar out of the Newton loop — rebuild 20.2 → 7.0 µs, 2.9x (measured) — swaps with many payments.
    • Rebuild only pillars after the bumped quote — all 38 bumps 270-295 → 169-181 µs, about 1.6x (measured) — key-rate loops; bumping the last quote costs one pillar.
    • Cache schedules and year fractions per instrument — no date arithmetic per rebuild (not measured) — calendar-based schedules, not in this code.
    • Jacobian or adjoint AD — one backward sweep gives all deltas of a PV, not N rebuilds (not measured) — many pillars, many portfolios.
  • Naive → standard → state of the art:
    • Unhoisted sequential solve, bump and rebuild · 20 µs per rebuild (measured)
    • Hoisted solve, bump and rebuild · 7 µs per rebuild, about 280 µs for 38 deltas (measured)
    • Analytic Jacobian or AD for deltas · one rebuild plus one sweep (not measured)

Implementation notes

  • Assert strictly increasing maturities; two quotes at one maturity over-determine a pillar.
  • Test the residual (1e-15 here), not x; cap iterations.

Pitfalls

  • Day counts, calendars and payment lags change the quotes' meaning; a swap priced with the wrong convention does not reprice.
  • Interest-rate futures are not forwards: apply a convexity adjustment before using them.
  • Inconsistent quotes give rising df and negative forwards.
  • Piecewise-flat forwards make forward-starting prices and hedges jump with pillar placement.
  • If a root find fails, fail loudly; every later pillar depends on it.

Variants and alternatives

Nelson-Siegel / Svensson4 or 6 parameters fitted by least squares to many quotes; smooth, but not exact on any quote.
Hazard-rate bootstrapthe same recursion with survival probabilities and CDS par spreads.

In production

  • Rates libraries (QuantLib, rateslib) build curves from deposit, FRA, futures and swap quotes, with OIS discounting and per-tenor projection curves.
  • Insurers and pension funds discount liabilities on such curves.
  • Credit desks bootstrap hazard rates from CDS quotes; energy desks forward curves from futures strips.

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
QuantLibC++ (Python bindings)lballabio/QuantLibpip install QuantLib————PiecewiseYieldCurve, e.g. PiecewiseLogLinearDiscount; deposit, FRA, futures, swap, OIS helpers
rateslibPythonattack68/rateslibpip install rateslib————curves, multi-curve solver, automatic-differentiation sensitivities
FinancePyPythondomokane/FinancePypip install financepy————curve and derivatives pricing library
convex-curvesRustsujitn/convexcargo add convex-curves————yield, credit and multi-curve term structures

Sources

  • Hagan, West, Interpolation Methods for Curve Construction, Applied Mathematical Finance 13(2), 2006, 89-129. paper
  • Nelson, Siegel, Parsimonious Modeling of Yield Curves, Journal of Business 60(4), 1987, 473-489. paper
  • Svensson, Estimating and Interpreting Forward Interest Rates: Sweden 1992-1994, NBER Working Paper 4871, 1994. paper
  • QuantLib, PiecewiseYieldCurve reference. docs

Builds on

day count conventions · plannedACT/360, ACT/365, 30/360, ACT/ACT accrual fractions for interest and coupons
business day calendars · plannedHoliday calendars, business-day adjustment rules (following, modified following) and settlement cycles
spline interpolation · plannedLinear, bilinear/trilinear and cubic spline interpolation on grids and tables
Newton root findingSolving nonlinear equations or systems (implicit time steps, inverse kinematics, implied volatility) fast when derivatives are available, while avoiding overshoot and divergence.

Used by

Systems (4):

Alternatives and related