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.
- Start with
df(0) = 1; sort instruments by strictly increasing maturity. - Deposit over
τat simple rater:df(τ) = 1/(1+rτ). - 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 unknownx = ln df(n). - Solve
g(x) = s·Σ df(i) − (1 − eˣ) = 0by Newton, started at the previous pillar's zero rate.∂ln df(i)/∂xis the interpolation weightw, sog′is exact. - Append the pillar; repeat.
- 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
expand 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
dfand 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
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
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| QuantLib | C++ (Python bindings) | lballabio/QuantLib | pip install QuantLib | — | — | — | — | PiecewiseYieldCurve, e.g. PiecewiseLogLinearDiscount; deposit, FRA, futures, swap, OIS helpers |
| rateslib | Python | attack68/rateslib | pip install rateslib | — | — | — | — | curves, multi-curve solver, automatic-differentiation sensitivities |
| FinancePy | Python | domokane/FinancePy | pip install financepy | — | — | — | — | curve and derivatives pricing library |
| convex-curves | Rust | sujitn/convex | cargo 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