Skip to main content

kestrel_chartkit/valuation/
bootstrap.rs

1//! Building a curve from quoted market instruments.
2//!
3//! [`YieldCurve::from_zero_rates`] takes a term structure that someone has already worked out.
4//! This module works it out: given instruments that trade, it finds the zero rates that reprice
5//! them. Two instrument types are supported and named, because an unnamed one would be a silent
6//! approximation:
7//!
8//! * a directly quoted zero rate, and
9//! * a par swap — a fixed rate paid `frequency` times a year that makes the swap worth nothing
10//!   today.
11//!
12//! Everything else — futures, FRAs, tenor basis, cross-currency — is *not* supported and is
13//! rejected rather than fitted with something that looks similar.
14
15#[cfg(feature = "serde")]
16use serde::{Deserialize, Serialize};
17
18use crate::finance::{year_fraction, CouponSchedule, Date, DayCountConvention};
19
20use super::{ValuationContextError, YieldCurve};
21
22/// A quoted instrument a curve is calibrated to.
23#[derive(Debug, Clone, Copy, PartialEq)]
24#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
25pub enum CalibrationInstrument {
26    /// A continuously compounded zero rate quoted directly for `maturity`.
27    ZeroRate { maturity: Date, rate: f64 },
28    /// A par swap: `rate` paid `frequency` times a year until `maturity`, worth zero today.
29    ///
30    /// The par condition used is `rate * annuity = 1 - discount(maturity)`, with the annuity
31    /// summed over the swap's own payment schedule. That is the textbook single-curve
32    /// relationship: it assumes discounting and projection happen on the same curve, which is the
33    /// case this crate models. A market that discounts on a separate collateral curve needs a
34    /// second curve, and pretending otherwise here would misprice by exactly that difference.
35    ParSwap {
36        maturity: Date,
37        rate: f64,
38        frequency: u32,
39    },
40}
41
42impl CalibrationInstrument {
43    pub fn maturity(&self) -> Date {
44        match self {
45            Self::ZeroRate { maturity, .. } | Self::ParSwap { maturity, .. } => *maturity,
46        }
47    }
48}
49
50impl YieldCurve {
51    /// Builds a curve that reprices every given instrument.
52    ///
53    /// Instruments are taken in order of maturity, each fixing one node. A zero rate fixes its
54    /// node directly. A par swap is solved for: the zero rate at its maturity is chosen so that
55    /// the curve — including the interpolation between the already fixed nodes and this one —
56    /// values the swap at par. Solving against the interpolation actually used is what makes the
57    /// result self-consistent; fixing nodes by a closed formula and interpolating afterwards
58    /// would leave the intermediate payment dates mispriced.
59    ///
60    /// Fails when instruments share a maturity, when one matures at or before the reference date,
61    /// or when a swap has no solution in the searched range of `-50%` to `+100%`.
62    ///
63    /// The result is a plain [`YieldCurve`]: once built, nothing distinguishes it from one given
64    /// by hand, and the same flat extrapolation applies beyond the last instrument.
65    pub fn bootstrap(
66        reference: Date,
67        instruments: &[CalibrationInstrument],
68        day_count: DayCountConvention,
69    ) -> Result<Self, ValuationContextError> {
70        if instruments.is_empty() {
71            return Err(ValuationContextError::InvalidCurve(
72                "bootstrapping needs at least one instrument",
73            ));
74        }
75
76        let mut sorted = instruments.to_vec();
77        sorted.sort_by_key(|instrument| instrument.maturity());
78        if sorted
79            .windows(2)
80            .any(|w| w[0].maturity() == w[1].maturity())
81        {
82            return Err(ValuationContextError::InvalidCurve(
83                "two instruments share a maturity",
84            ));
85        }
86        if sorted[0].maturity() <= reference {
87            return Err(ValuationContextError::InvalidCurve(
88                "instruments must mature after the reference date",
89            ));
90        }
91
92        let mut nodes: Vec<(f64, f64)> = Vec::with_capacity(sorted.len());
93        for instrument in &sorted {
94            let time = year_fraction(reference, instrument.maturity(), day_count);
95            match instrument {
96                CalibrationInstrument::ZeroRate { rate, .. } => {
97                    if !rate.is_finite() {
98                        return Err(ValuationContextError::InvalidCurve(
99                            "a quoted zero rate must be finite",
100                        ));
101                    }
102                    nodes.push((time, *rate));
103                }
104                CalibrationInstrument::ParSwap {
105                    maturity,
106                    rate,
107                    frequency,
108                } => {
109                    let rate = solve_par_swap_node(
110                        reference, *maturity, *rate, *frequency, day_count, &nodes,
111                    )?;
112                    nodes.push((time, rate));
113                }
114            }
115        }
116
117        Self::from_zero_rates(reference, nodes, day_count)
118    }
119
120    /// The fixed rate that would make a swap of this maturity and frequency worth nothing on this
121    /// curve — the curve's own view of where that swap trades.
122    ///
123    /// Repricing the instruments a curve was built from is how a bootstrap is checked, so this is
124    /// deliberately public rather than hidden in a test.
125    pub fn par_swap_rate(
126        &self,
127        maturity: Date,
128        frequency: u32,
129    ) -> Result<f64, ValuationContextError> {
130        let (annuity, final_discount) = swap_legs(self, maturity, frequency)?;
131        if annuity <= 0.0 {
132            return Err(ValuationContextError::InvalidCurve(
133                "swap annuity is not positive",
134            ));
135        }
136        Ok((1.0 - final_discount) / annuity)
137    }
138}
139
140/// Annuity (sum of accrual fraction times discount factor) and the discount factor at maturity.
141fn swap_legs(
142    curve: &YieldCurve,
143    maturity: Date,
144    frequency: u32,
145) -> Result<(f64, f64), ValuationContextError> {
146    let schedule = CouponSchedule::covering(curve.reference_date(), maturity, frequency)
147        .map_err(ValuationContextError::Instrument)?;
148
149    let mut annuity = 0.0;
150    for index in 0..schedule.period_count() {
151        let (start, end) = schedule
152            .period(index)
153            .expect("index below period_count is valid");
154        let accrual = year_fraction(start, end, curve.day_count());
155        annuity += accrual * curve.discount_factor(end)?;
156    }
157    Ok((annuity, curve.discount_factor(maturity)?))
158}
159
160/// Finds the zero rate at the swap's maturity that prices it at par.
161///
162/// Bisection rather than a Newton step: the objective is monotone in the rate over the searched
163/// range, so bisection cannot run away, and a curve build that silently converges to the wrong
164/// root would be worse than one that fails.
165fn solve_par_swap_node(
166    reference: Date,
167    maturity: Date,
168    quoted_rate: f64,
169    frequency: u32,
170    day_count: DayCountConvention,
171    fixed_nodes: &[(f64, f64)],
172) -> Result<f64, ValuationContextError> {
173    if !quoted_rate.is_finite() {
174        return Err(ValuationContextError::InvalidCurve(
175            "a quoted swap rate must be finite",
176        ));
177    }
178
179    let time = year_fraction(reference, maturity, day_count);
180    let mismatch = |candidate: f64| -> Result<f64, ValuationContextError> {
181        let mut nodes = fixed_nodes.to_vec();
182        nodes.push((time, candidate));
183        let curve = YieldCurve::from_zero_rates(reference, nodes, day_count)?;
184        let (annuity, final_discount) = swap_legs(&curve, maturity, frequency)?;
185        // Par condition: rate * annuity = 1 - discount(maturity).
186        Ok(quoted_rate * annuity - (1.0 - final_discount))
187    };
188
189    let (mut low, mut high) = (-0.5, 1.0);
190    let (low_value, high_value) = (mismatch(low)?, mismatch(high)?);
191    if low_value.signum() == high_value.signum() {
192        return Err(ValuationContextError::InvalidCurve(
193            "no zero rate between -50% and +100% prices this swap at par",
194        ));
195    }
196
197    // 100 halvings take the 1.5-wide bracket far below double precision; the loop is bounded so a
198    // pathological objective cannot hang the build.
199    for _ in 0..100 {
200        let middle = 0.5 * (low + high);
201        let value = mismatch(middle)?;
202        if value == 0.0 {
203            return Ok(middle);
204        }
205        if value.signum() == low_value.signum() {
206            low = middle;
207        } else {
208            high = middle;
209        }
210    }
211    Ok(0.5 * (low + high))
212}