use crate::curves::DiscountCurve;
use crate::errors::BootstrapError;
use crate::instruments::{CurveSnapshot, Instrument, InstrumentLike};
use crate::interpolation::Interpolation;
use crate::math::MathError;
use crate::math::brent::{BrentConfig, brent_root};
use crate::types::{Date, Daycount};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BootstrapConfig {
pub tolerance: f64,
pub max_iter: u32,
pub bracket: f64,
pub iterative: bool,
pub iter_max: u32,
pub iter_tol: f64,
}
impl Default for BootstrapConfig {
fn default() -> Self {
Self {
tolerance: 1e-12,
max_iter: 100,
bracket: 0.5,
iterative: true,
iter_max: 8,
iter_tol: 1e-14,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Bootstrap {
pub reference_date: Date,
pub daycount: Daycount,
pub config: BootstrapConfig,
}
impl Bootstrap {
#[must_use]
pub fn new(reference_date: Date, daycount: Daycount) -> Self {
Self {
reference_date,
daycount,
config: BootstrapConfig::default(),
}
}
#[must_use]
pub fn with_config(mut self, config: BootstrapConfig) -> Self {
self.config = config;
self
}
pub fn build(
&self,
instruments: &[Instrument],
method: Interpolation,
) -> Result<DiscountCurve, BootstrapError> {
if instruments.is_empty() {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "no instruments supplied",
});
}
for (i, inst) in instruments.iter().enumerate() {
let pillar = inst.pillar();
if pillar.days_between(self.reference_date) >= 0 {
return Err(BootstrapError::InvalidInstrument {
at_index: i,
reason: "instrument pillar must be after reference_date",
});
}
if i > 0 {
let prev_pillar = instruments[i - 1].pillar();
if pillar.days_between(prev_pillar) >= 0 {
return Err(BootstrapError::NonIncreasingAnchor { at_index: i });
}
}
}
let n = instruments.len();
let mut times: Vec<f64> = Vec::with_capacity(n + 1);
let mut discounts: Vec<f64> = Vec::with_capacity(n + 1);
times.push(0.0);
discounts.push(1.0);
for inst in instruments {
let t = self
.daycount
.year_fraction(self.reference_date, inst.pillar())?;
times.push(t);
discounts.push(1.0); }
self.sweep(instruments, method, ×, &mut discounts, true)?;
if self.config.iterative && method_is_nonlocal(method) {
let mut converged = false;
let mut last_change = 0.0_f64;
for _ in 0..self.config.iter_max {
let previous = discounts.clone();
self.sweep(instruments, method, ×, &mut discounts, false)?;
let mut max_change = 0.0_f64;
for (a, b) in discounts.iter().zip(previous.iter()) {
let delta = (a - b).abs();
if delta > max_change {
max_change = delta;
}
}
last_change = max_change;
if max_change < self.config.iter_tol {
converged = true;
break;
}
}
if !converged {
return Err(BootstrapError::LegDidNotConverge {
at_index: usize::MAX,
residual: last_change,
});
}
}
let curve = DiscountCurve::from_times_and_discounts(
self.reference_date,
self.daycount,
×,
&discounts,
method,
)?;
Ok(curve)
}
fn sweep(
&self,
instruments: &[Instrument],
_method: Interpolation,
times: &[f64],
discounts: &mut [f64],
is_initial: bool,
) -> Result<(), BootstrapError> {
for (k, inst) in instruments.iter().enumerate() {
let idx = k + 1; let t_k = times[idx];
let t_prev = times[idx - 1];
let d_prev = discounts[idx - 1];
let d_guess = if is_initial {
let r_prev = if k == 0 {
0.05_f64
} else {
let t_p2 = times[idx - 2];
let d_p2 = discounts[idx - 2];
let dt = t_prev - t_p2;
if dt > 0.0 && d_prev > 0.0 && d_p2 > 0.0 {
(d_p2 / d_prev).ln() / dt
} else {
0.05_f64
}
};
let dt = t_k - t_prev;
d_prev * (-r_prev * dt).exp()
} else {
let warm = discounts[idx];
if warm.is_finite() && warm > 0.0 {
warm
} else {
d_prev
}
};
let ctx = LegContext {
index: k,
instrument: inst,
pillar_idx: idx,
d_guess,
times,
discounts,
};
let solved = self.solve_leg(&ctx)?;
discounts[idx] = solved;
}
Ok(())
}
fn solve_leg(&self, ctx: &LegContext<'_>) -> Result<f64, BootstrapError> {
let mut bracket = self.config.bracket;
#[allow(unused_assignments)]
let mut last_residual: f64 = 0.0;
for attempt in 0..=5_u32 {
let lo = (ctx.d_guess * (-bracket).exp()).max(f64::MIN_POSITIVE);
let hi = ctx.d_guess * bracket.exp();
let residual_fn = |d: f64| -> f64 {
let mut probe = ctx.discounts.to_vec();
probe[ctx.pillar_idx] = d;
let snapshot = CurveSnapshot {
reference_date: self.reference_date,
daycount: self.daycount,
times: ctx.times,
discounts: &probe,
};
instrument_residual(ctx.instrument, self.reference_date, &snapshot)
.unwrap_or(f64::INFINITY)
};
let f_lo = residual_fn(lo);
let f_hi = residual_fn(hi);
last_residual = f_lo;
if !f_lo.is_finite() || !f_hi.is_finite() || f_lo * f_hi > 0.0 {
if attempt == 5 {
return Err(BootstrapError::NoBracket {
at_index: ctx.index,
});
}
bracket *= 2.0;
continue;
}
let brent_cfg = BrentConfig {
xtol: 1e-15,
ftol: self.config.tolerance,
max_iter: self.config.max_iter,
};
match brent_root(residual_fn, lo, hi, brent_cfg) {
Ok(root) => {
let mut probe = ctx.discounts.to_vec();
probe[ctx.pillar_idx] = root;
let snapshot = CurveSnapshot {
reference_date: self.reference_date,
daycount: self.daycount,
times: ctx.times,
discounts: &probe,
};
let final_residual =
instrument_residual(ctx.instrument, self.reference_date, &snapshot)?;
if final_residual.abs() > self.config.tolerance {
return Err(BootstrapError::LegDidNotConverge {
at_index: ctx.index,
residual: final_residual,
});
}
return Ok(root);
}
Err(MathError::BracketNotStraddling) => {
if attempt == 5 {
return Err(BootstrapError::NoBracket {
at_index: ctx.index,
});
}
bracket *= 2.0;
}
Err(_) => {
return Err(BootstrapError::LegDidNotConverge {
at_index: ctx.index,
residual: last_residual,
});
}
}
}
Err(BootstrapError::NoBracket {
at_index: ctx.index,
})
}
}
struct LegContext<'a> {
index: usize,
instrument: &'a Instrument,
pillar_idx: usize,
d_guess: f64,
times: &'a [f64],
discounts: &'a [f64],
}
fn instrument_residual(
inst: &Instrument,
reference_date: Date,
snapshot: &CurveSnapshot<'_>,
) -> Result<f64, BootstrapError> {
match inst {
Instrument::Bond(b) => b.residual(reference_date, snapshot),
Instrument::Deposit(d) => d.residual(reference_date, snapshot),
Instrument::Fra(f) => f.residual(reference_date, snapshot),
Instrument::Future(f) => f.residual(reference_date, snapshot),
Instrument::SwapFixedFloat(s) => s.residual(reference_date, snapshot),
Instrument::OisSwap(s) => s.residual(reference_date, snapshot),
Instrument::BasisSwap(b) => b.residual(reference_date, snapshot),
}
}
fn method_is_nonlocal(method: Interpolation) -> bool {
match method {
Interpolation::CubicSpline(_)
| Interpolation::HermiteBessel
| Interpolation::MonotoneHyman => true,
Interpolation::ConvexMonotone
| Interpolation::Linear
| Interpolation::LogLinear
| Interpolation::LinearInZero
| Interpolation::PiecewiseConstantForward
| Interpolation::MonotoneCubic
| Interpolation::MonotoneSteffen => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::instruments::{Bond, Deposit, Fra, OisSwap, SwapFixedFloat, SwapSchedule};
use crate::interpolation::SplineBoundary;
use crate::types::Frequency;
fn d(y: i32, m: u32, day: u32) -> Date {
Date::from_ymd(y, m, day).unwrap()
}
#[test]
fn bootstrap_config_default_values_match_spec() {
let cfg = BootstrapConfig::default();
assert!((cfg.tolerance - 1e-12).abs() < 1e-18);
assert_eq!(cfg.max_iter, 100);
assert!((cfg.bracket - 0.5).abs() < 1e-15);
assert!(cfg.iterative);
assert_eq!(cfg.iter_max, 8);
assert!((cfg.iter_tol - 1e-14).abs() < 1e-20);
}
#[test]
fn bootstrap_with_config_round_trip() {
let reference = d(2024, 1, 2);
let cfg = BootstrapConfig {
tolerance: 1e-10,
max_iter: 50,
bracket: 0.25,
iterative: false,
iter_max: 4,
iter_tol: 1e-12,
};
let bs = Bootstrap::new(reference, Daycount::Act360).with_config(cfg);
assert_eq!(bs.config, cfg);
}
#[test]
fn build_rejects_empty_instrument_list() {
let reference = d(2024, 1, 2);
let bs = Bootstrap::new(reference, Daycount::Act360);
let err = bs.build(&[], Interpolation::LogLinear).unwrap_err();
assert!(matches!(
err,
BootstrapError::InvalidInstrument {
at_index: 0,
reason: "no instruments supplied",
}
));
}
#[test]
fn build_rejects_pillar_on_or_before_reference_date() {
let reference = d(2024, 1, 2);
let dep = Deposit::new(reference, reference, 0.05, Daycount::Act360).unwrap();
let bs = Bootstrap::new(reference, Daycount::Act360);
let err = bs
.build(&[Instrument::Deposit(dep)], Interpolation::LogLinear)
.unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn build_rejects_non_increasing_pillars() {
let reference = d(2024, 1, 2);
let dep_late = Deposit::new(reference, d(2024, 7, 2), 0.05, Daycount::Act360).unwrap();
let dep_early = Deposit::new(reference, d(2024, 4, 2), 0.05, Daycount::Act360).unwrap();
let bs = Bootstrap::new(reference, Daycount::Act360);
let err = bs
.build(
&[
Instrument::Deposit(dep_late),
Instrument::Deposit(dep_early),
],
Interpolation::LogLinear,
)
.unwrap_err();
assert!(matches!(
err,
BootstrapError::NonIncreasingAnchor { at_index: 1 }
));
}
#[test]
fn deposit_only_bootstrap_flat_5pct_log_linear() {
let reference = d(2024, 1, 2);
let dc = Daycount::Act360;
let rate = 0.05_f64;
let tenors = [
(d(2024, 2, 2)), (d(2024, 3, 2)), (d(2024, 4, 2)), (d(2024, 7, 2)), ];
let instruments: Vec<Instrument> = tenors
.iter()
.map(|&payment| {
Instrument::Deposit(Deposit::new(reference, payment, rate, dc).unwrap())
})
.collect();
let bs = Bootstrap::new(reference, dc);
let curve = bs.build(&instruments, Interpolation::LogLinear).unwrap();
let times: Vec<f64> = curve.times().to_vec();
let discounts: Vec<f64> = curve.discounts().to_vec();
let snapshot = CurveSnapshot {
reference_date: reference,
daycount: dc,
times: ×,
discounts: &discounts,
};
for inst in &instruments {
let residual = instrument_residual(inst, reference, &snapshot).unwrap();
assert!(
residual.abs() < 1e-12,
"deposit residual must be < 1e-12, got {residual}",
);
}
for &payment in &tenors {
let tau = dc.year_fraction(reference, payment).unwrap();
let expected = 1.0 / (1.0 + rate * tau);
let got = curve.discount_at(payment).unwrap();
assert!(
(got - expected).abs() < 1e-12,
"D({payment:?}) -> {got}, expected {expected}",
);
}
}
fn flat_curve_quotes_for_dep(reference: Date, dc: Daycount, r_c: f64, payment: Date) -> f64 {
let tau = dc.year_fraction(reference, payment).unwrap();
let d_pay = (-r_c * tau).exp();
(1.0 / d_pay - 1.0) / tau
}
fn flat_curve_quotes_for_fra(dc: Daycount, r_c: f64, start: Date, end: Date) -> f64 {
let tau = dc.year_fraction(start, end).unwrap();
((r_c * tau).exp() - 1.0) / tau
}
fn flat_par_swap_rate(
reference: Date,
start: Date,
maturity: Date,
freq: Frequency,
fixed_dc: Daycount,
curve_dc: Daycount,
r_c: f64,
) -> f64 {
let schedule = SwapSchedule::from_regular(start, maturity, freq).unwrap();
let mut annuity = 0.0_f64;
for i in 0..schedule.len() {
let p_end = schedule.period_end(i);
let tau_i = fixed_dc
.year_fraction(schedule.period_start(i), p_end)
.unwrap();
let t_pay = curve_dc.year_fraction(reference, p_end).unwrap();
annuity += tau_i * (-r_c * t_pay).exp();
}
let t_start = curve_dc.year_fraction(reference, start).unwrap();
let t_mat = curve_dc.year_fraction(reference, maturity).unwrap();
((-r_c * t_start).exp() - (-r_c * t_mat).exp()) / annuity
}
#[test]
fn mixed_deposits_fras_swap_bootstrap_log_linear() {
let reference = d(2024, 1, 2);
let dc = Daycount::Act360;
let r_c = 0.04_f64;
let dep1_pay = d(2024, 4, 2);
let dep2_pay = d(2024, 7, 2);
let fra1_start = d(2024, 7, 2);
let fra1_end = d(2024, 10, 2);
let fra2_start = d(2024, 10, 2);
let fra2_end = d(2025, 1, 2);
let swap_start = reference;
let swap_maturity = d(2026, 1, 2);
let dep1 = Deposit::new(
reference,
dep1_pay,
flat_curve_quotes_for_dep(reference, dc, r_c, dep1_pay),
dc,
)
.unwrap();
let dep2 = Deposit::new(
reference,
dep2_pay,
flat_curve_quotes_for_dep(reference, dc, r_c, dep2_pay),
dc,
)
.unwrap();
let fra1 = Fra::new(
fra1_start,
fra1_end,
flat_curve_quotes_for_fra(dc, r_c, fra1_start, fra1_end),
dc,
)
.unwrap();
let fra2 = Fra::new(
fra2_start,
fra2_end,
flat_curve_quotes_for_fra(dc, r_c, fra2_start, fra2_end),
dc,
)
.unwrap();
let par = flat_par_swap_rate(
reference,
swap_start,
swap_maturity,
Frequency::SemiAnnual,
Daycount::Act360,
dc,
r_c,
);
let swap = SwapFixedFloat::new(
swap_start,
swap_maturity,
par,
Frequency::SemiAnnual,
Daycount::Act360,
Frequency::Quarterly,
Daycount::Act360,
)
.unwrap();
let instruments = [
Instrument::Deposit(dep1),
Instrument::Deposit(dep2),
Instrument::Fra(fra1),
Instrument::Fra(fra2),
Instrument::SwapFixedFloat(swap),
];
let bs = Bootstrap::new(reference, dc);
let curve = bs.build(&instruments, Interpolation::LogLinear).unwrap();
let times: Vec<f64> = curve.times().to_vec();
let discounts: Vec<f64> = curve.discounts().to_vec();
let snapshot = CurveSnapshot {
reference_date: reference,
daycount: dc,
times: ×,
discounts: &discounts,
};
for inst in &instruments {
let residual = instrument_residual(inst, reference, &snapshot).unwrap();
assert!(
residual.abs() < 1e-10,
"mixed residual must be < 1e-10, got {residual}",
);
}
}
#[test]
fn ois_only_bootstrap_log_linear() {
let reference = d(2024, 1, 2);
let dc = Daycount::Act360;
let r_c = 0.03_f64;
let m_1y = d(2025, 1, 2);
let m_2y = d(2026, 1, 2);
let m_5y = d(2029, 1, 2);
let par_1y = flat_par_swap_rate(reference, reference, m_1y, Frequency::Annual, dc, dc, r_c);
let par_2y = flat_par_swap_rate(reference, reference, m_2y, Frequency::Annual, dc, dc, r_c);
let par_5y = flat_par_swap_rate(reference, reference, m_5y, Frequency::Annual, dc, dc, r_c);
let ois_1y = OisSwap::new(reference, m_1y, par_1y, Frequency::Annual, dc).unwrap();
let ois_2y = OisSwap::new(reference, m_2y, par_2y, Frequency::Annual, dc).unwrap();
let ois_5y = OisSwap::new(reference, m_5y, par_5y, Frequency::Annual, dc).unwrap();
let instruments = [
Instrument::OisSwap(ois_1y),
Instrument::OisSwap(ois_2y),
Instrument::OisSwap(ois_5y),
];
let bs = Bootstrap::new(reference, dc);
let curve = bs.build(&instruments, Interpolation::LogLinear).unwrap();
let times: Vec<f64> = curve.times().to_vec();
let discounts: Vec<f64> = curve.discounts().to_vec();
let snapshot = CurveSnapshot {
reference_date: reference,
daycount: dc,
times: ×,
discounts: &discounts,
};
for inst in &instruments {
let residual = instrument_residual(inst, reference, &snapshot).unwrap();
assert!(
residual.abs() < 1e-10,
"OIS residual must be < 1e-10, got {residual}",
);
}
}
#[test]
fn cubic_spline_bootstrap_converges_via_outer_iteration() {
let reference = d(2024, 1, 2);
let dc = Daycount::Act360;
let r_c = 0.04_f64;
let dep1_pay = d(2024, 4, 2);
let dep2_pay = d(2024, 7, 2);
let fra1_start = d(2024, 7, 2);
let fra1_end = d(2024, 10, 2);
let fra2_start = d(2024, 10, 2);
let fra2_end = d(2025, 1, 2);
let swap_start = reference;
let swap_maturity = d(2026, 1, 2);
let dep1 = Deposit::new(
reference,
dep1_pay,
flat_curve_quotes_for_dep(reference, dc, r_c, dep1_pay),
dc,
)
.unwrap();
let dep2 = Deposit::new(
reference,
dep2_pay,
flat_curve_quotes_for_dep(reference, dc, r_c, dep2_pay),
dc,
)
.unwrap();
let fra1 = Fra::new(
fra1_start,
fra1_end,
flat_curve_quotes_for_fra(dc, r_c, fra1_start, fra1_end),
dc,
)
.unwrap();
let fra2 = Fra::new(
fra2_start,
fra2_end,
flat_curve_quotes_for_fra(dc, r_c, fra2_start, fra2_end),
dc,
)
.unwrap();
let par = flat_par_swap_rate(
reference,
swap_start,
swap_maturity,
Frequency::SemiAnnual,
Daycount::Act360,
dc,
r_c,
);
let swap = SwapFixedFloat::new(
swap_start,
swap_maturity,
par,
Frequency::SemiAnnual,
Daycount::Act360,
Frequency::Quarterly,
Daycount::Act360,
)
.unwrap();
let instruments = [
Instrument::Deposit(dep1),
Instrument::Deposit(dep2),
Instrument::Fra(fra1),
Instrument::Fra(fra2),
Instrument::SwapFixedFloat(swap),
];
let bs = Bootstrap::new(reference, dc);
let curve = bs
.build(
&instruments,
Interpolation::CubicSpline(SplineBoundary::NotAKnot),
)
.unwrap();
let times: Vec<f64> = curve.times().to_vec();
let discounts: Vec<f64> = curve.discounts().to_vec();
let snapshot = CurveSnapshot {
reference_date: reference,
daycount: dc,
times: ×,
discounts: &discounts,
};
for inst in &instruments {
let residual = instrument_residual(inst, reference, &snapshot).unwrap();
assert!(
residual.abs() < 1e-10,
"cubic spline residual must be < 1e-10, got {residual}",
);
}
}
#[test]
fn every_interpolation_method_converges_on_three_deposits() {
let reference = d(2024, 1, 2);
let dc = Daycount::Act360;
let r_c = 0.04_f64;
let payments = [d(2024, 4, 2), d(2024, 7, 2), d(2024, 10, 2)];
let instruments: Vec<Instrument> = payments
.iter()
.map(|&p| {
Instrument::Deposit(
Deposit::new(
reference,
p,
flat_curve_quotes_for_dep(reference, dc, r_c, p),
dc,
)
.unwrap(),
)
})
.collect();
let methods = [
Interpolation::Linear,
Interpolation::LogLinear,
Interpolation::LinearInZero,
Interpolation::PiecewiseConstantForward,
Interpolation::CubicSpline(SplineBoundary::NotAKnot),
Interpolation::ConvexMonotone,
Interpolation::HermiteBessel,
Interpolation::MonotoneCubic,
Interpolation::MonotoneHyman,
Interpolation::MonotoneSteffen,
];
let bs = Bootstrap::new(reference, dc);
for method in methods {
let curve = bs.build(&instruments, method).unwrap_or_else(|e| {
panic!("method {method:?} failed: {e:?}");
});
let times: Vec<f64> = curve.times().to_vec();
let discounts: Vec<f64> = curve.discounts().to_vec();
let snapshot = CurveSnapshot {
reference_date: reference,
daycount: dc,
times: ×,
discounts: &discounts,
};
for inst in &instruments {
let r = instrument_residual(inst, reference, &snapshot).unwrap();
assert!(r.abs() < 1e-10, "method {method:?}: residual {r}");
}
}
}
#[test]
fn no_bracket_failure_on_pathological_deposit_rate() {
let reference = d(2024, 1, 2);
let dc = Daycount::Act360;
let dep = Deposit::new(reference, d(2024, 4, 2), -100.0, dc).unwrap();
let bs = Bootstrap::new(reference, dc);
let res = bs.build(&[Instrument::Deposit(dep)], Interpolation::LogLinear);
match res {
Err(BootstrapError::NoBracket { at_index: 0 }) => {}
Err(other) => panic!("expected NoBracket, got {other:?}"),
Ok(_) => panic!("expected NoBracket error, got Ok"),
}
}
#[test]
fn method_is_nonlocal_distinguishes_local_and_global_methods() {
assert!(method_is_nonlocal(Interpolation::CubicSpline(
SplineBoundary::NotAKnot
)));
assert!(method_is_nonlocal(Interpolation::HermiteBessel));
assert!(method_is_nonlocal(Interpolation::MonotoneHyman));
assert!(!method_is_nonlocal(Interpolation::ConvexMonotone));
assert!(!method_is_nonlocal(Interpolation::Linear));
assert!(!method_is_nonlocal(Interpolation::LogLinear));
assert!(!method_is_nonlocal(Interpolation::LinearInZero));
assert!(!method_is_nonlocal(Interpolation::PiecewiseConstantForward));
assert!(!method_is_nonlocal(Interpolation::MonotoneCubic));
assert!(!method_is_nonlocal(Interpolation::MonotoneSteffen));
}
#[test]
fn bootstrap_debug_includes_reference_date() {
let bs = Bootstrap::new(d(2024, 1, 2), Daycount::Act360);
let dbg = format!("{bs:?}");
assert!(dbg.contains("Bootstrap"));
}
#[test]
fn bootstrap_config_debug_includes_struct_name() {
let cfg = BootstrapConfig::default();
let dbg = format!("{cfg:?}");
assert!(dbg.contains("BootstrapConfig"));
}
#[test]
fn iterative_off_skips_outer_iteration_local_methods_still_work() {
let reference = d(2024, 1, 2);
let dc = Daycount::Act360;
let r_c = 0.04_f64;
let p1 = d(2024, 4, 2);
let p2 = d(2024, 7, 2);
let dep1 = Deposit::new(
reference,
p1,
flat_curve_quotes_for_dep(reference, dc, r_c, p1),
dc,
)
.unwrap();
let dep2 = Deposit::new(
reference,
p2,
flat_curve_quotes_for_dep(reference, dc, r_c, p2),
dc,
)
.unwrap();
let instruments = [Instrument::Deposit(dep1), Instrument::Deposit(dep2)];
let bs_on = Bootstrap::new(reference, dc);
let bs_off = Bootstrap::new(reference, dc).with_config(BootstrapConfig {
iterative: false,
..BootstrapConfig::default()
});
let curve_on = bs_on.build(&instruments, Interpolation::LogLinear).unwrap();
let curve_off = bs_off
.build(&instruments, Interpolation::LogLinear)
.unwrap();
for (a, b) in curve_on
.discounts()
.iter()
.zip(curve_off.discounts().iter())
{
assert!((a - b).abs() < 1e-14);
}
}
#[test]
fn bootstrapped_curve_has_canonical_anchor() {
let reference = d(2024, 1, 2);
let dc = Daycount::Act360;
let dep = Deposit::new(reference, d(2024, 7, 2), 0.05, Daycount::Act360).unwrap();
let bs = Bootstrap::new(reference, dc);
let curve = bs
.build(&[Instrument::Deposit(dep)], Interpolation::LogLinear)
.unwrap();
assert!((curve.times()[0] - 0.0).abs() < 1e-15);
assert!((curve.discounts()[0] - 1.0).abs() < 1e-15);
assert!((curve.discount(0.0).unwrap() - 1.0).abs() < 1e-15);
}
#[test]
fn bootstrapped_curve_pillar_count_equals_anchor_plus_n_instruments() {
let reference = d(2024, 1, 2);
let dc = Daycount::Act360;
let r_c = 0.04_f64;
let payments = [d(2024, 4, 2), d(2024, 7, 2), d(2024, 10, 2), d(2025, 1, 2)];
let instruments: Vec<Instrument> = payments
.iter()
.map(|&p| {
Instrument::Deposit(
Deposit::new(
reference,
p,
flat_curve_quotes_for_dep(reference, dc, r_c, p),
dc,
)
.unwrap(),
)
})
.collect();
let bs = Bootstrap::new(reference, dc);
let curve = bs.build(&instruments, Interpolation::LogLinear).unwrap();
assert_eq!(curve.times().len(), 5);
assert_eq!(curve.discounts().len(), 5);
}
#[test]
fn cubic_spline_outer_iteration_succeeds_with_small_iter_max() {
let reference = d(2024, 1, 2);
let dc = Daycount::Act360;
let r_c = 0.04_f64;
let payments = [d(2024, 4, 2), d(2024, 7, 2), d(2024, 10, 2), d(2025, 1, 2)];
let instruments: Vec<Instrument> = payments
.iter()
.map(|&p| {
Instrument::Deposit(
Deposit::new(
reference,
p,
flat_curve_quotes_for_dep(reference, dc, r_c, p),
dc,
)
.unwrap(),
)
})
.collect();
let bs = Bootstrap::new(reference, dc).with_config(BootstrapConfig {
iter_max: 5,
..BootstrapConfig::default()
});
let curve = bs
.build(
&instruments,
Interpolation::CubicSpline(SplineBoundary::NotAKnot),
)
.unwrap();
let times: Vec<f64> = curve.times().to_vec();
let discounts: Vec<f64> = curve.discounts().to_vec();
let snapshot = CurveSnapshot {
reference_date: reference,
daycount: dc,
times: ×,
discounts: &discounts,
};
for inst in &instruments {
let residual = instrument_residual(inst, reference, &snapshot).unwrap();
assert!(residual.abs() < 1e-10);
}
}
#[test]
fn cubic_spline_outer_iteration_caps_at_zero_returns_did_not_converge_or_ok() {
let reference = d(2024, 1, 2);
let dc = Daycount::Act360;
let r_c = 0.04_f64;
let payments = [d(2024, 4, 2), d(2024, 7, 2), d(2024, 10, 2)];
let instruments: Vec<Instrument> = payments
.iter()
.map(|&p| {
Instrument::Deposit(
Deposit::new(
reference,
p,
flat_curve_quotes_for_dep(reference, dc, r_c, p),
dc,
)
.unwrap(),
)
})
.collect();
let bs = Bootstrap::new(reference, dc).with_config(BootstrapConfig {
iter_max: 0,
..BootstrapConfig::default()
});
let res = bs.build(
&instruments,
Interpolation::CubicSpline(SplineBoundary::NotAKnot),
);
assert!(matches!(
res,
Err(BootstrapError::LegDidNotConverge {
at_index: usize::MAX,
..
})
));
}
fn flat_par_bond_coupon(
reference: Date,
issue: Date,
maturity: Date,
freq: Frequency,
coupon_dc: Daycount,
curve_dc: Daycount,
r_c: f64,
) -> f64 {
let schedule = SwapSchedule::from_regular(issue, maturity, freq).unwrap();
let mut annuity = 0.0_f64;
for i in 0..schedule.len() {
let s = schedule.period_start(i);
let e = schedule.period_end(i);
let tau = coupon_dc.year_fraction(s, e).unwrap();
let t_pay = curve_dc.year_fraction(reference, e).unwrap();
annuity += tau * (-r_c * t_pay).exp();
}
let t_n = curve_dc.year_fraction(reference, maturity).unwrap();
(1.0 - (-r_c * t_n).exp()) / annuity
}
#[test]
fn bond_bootstrap_deposits_plus_three_bonds_reprices_all() {
let reference = d(2024, 1, 2);
let dc = Daycount::Act360;
let r_c = 0.04_f64;
let dep_pays = [d(2024, 4, 2), d(2024, 7, 2), d(2024, 10, 2), d(2025, 1, 2)];
let mut instruments: Vec<Instrument> = dep_pays
.iter()
.map(|&p| {
Instrument::Deposit(
Deposit::new(
reference,
p,
flat_curve_quotes_for_dep(reference, dc, r_c, p),
dc,
)
.unwrap(),
)
})
.collect();
let bond_mats = [d(2026, 1, 2), d(2027, 1, 2), d(2029, 1, 2)];
for &m in &bond_mats {
let coupon =
flat_par_bond_coupon(reference, reference, m, Frequency::Annual, dc, dc, r_c);
let bond =
Bond::new(reference, m, coupon, Frequency::Annual, dc, 1.0, 1.0, 0.0).unwrap();
instruments.push(Instrument::Bond(bond));
}
let bs = Bootstrap::new(reference, dc);
let curve = bs.build(&instruments, Interpolation::LogLinear).unwrap();
let times: Vec<f64> = curve.times().to_vec();
let discounts: Vec<f64> = curve.discounts().to_vec();
let snapshot = CurveSnapshot {
reference_date: reference,
daycount: dc,
times: ×,
discounts: &discounts,
};
for inst in &instruments {
let residual = instrument_residual(inst, reference, &snapshot).unwrap();
assert!(
residual.abs() < 1e-10,
"bond bootstrap residual must be < 1e-10, got {residual}",
);
}
}
#[test]
fn bond_bootstrap_mixed_with_swap_reprices_all() {
let reference = d(2024, 1, 2);
let dc = Daycount::Act360;
let r_c = 0.035_f64;
let dep1_pay = d(2024, 4, 2);
let dep2_pay = d(2024, 7, 2);
let bond_mat = d(2027, 1, 2);
let swap_mat = d(2029, 1, 2);
let dep1 = Deposit::new(
reference,
dep1_pay,
flat_curve_quotes_for_dep(reference, dc, r_c, dep1_pay),
dc,
)
.unwrap();
let dep2 = Deposit::new(
reference,
dep2_pay,
flat_curve_quotes_for_dep(reference, dc, r_c, dep2_pay),
dc,
)
.unwrap();
let bond_coupon = flat_par_bond_coupon(
reference,
reference,
bond_mat,
Frequency::Annual,
dc,
dc,
r_c,
);
let bond = Bond::new(
reference,
bond_mat,
bond_coupon,
Frequency::Annual,
dc,
1.0,
1.0,
0.0,
)
.unwrap();
let par = flat_par_swap_rate(
reference,
reference,
swap_mat,
Frequency::SemiAnnual,
Daycount::Act360,
dc,
r_c,
);
let swap = SwapFixedFloat::new(
reference,
swap_mat,
par,
Frequency::SemiAnnual,
Daycount::Act360,
Frequency::Quarterly,
Daycount::Act360,
)
.unwrap();
let instruments = [
Instrument::Deposit(dep1),
Instrument::Deposit(dep2),
Instrument::Bond(bond),
Instrument::SwapFixedFloat(swap),
];
let bs = Bootstrap::new(reference, dc);
let curve = bs.build(&instruments, Interpolation::LogLinear).unwrap();
let times: Vec<f64> = curve.times().to_vec();
let discounts: Vec<f64> = curve.discounts().to_vec();
let snapshot = CurveSnapshot {
reference_date: reference,
daycount: dc,
times: ×,
discounts: &discounts,
};
for inst in &instruments {
let residual = instrument_residual(inst, reference, &snapshot).unwrap();
assert!(
residual.abs() < 1e-10,
"mixed bond+swap residual must be < 1e-10, got {residual}",
);
}
}
}