use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use crate::interpolation;
pub use crate::tenor::Tenor;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum YieldType {
Par,
Zero,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct YieldCurve {
pub date: NaiveDate,
pub yield_type: YieldType,
pub points: BTreeMap<u32, f64>,
}
impl YieldCurve {
pub fn new(date: NaiveDate) -> Self {
Self {
date,
yield_type: YieldType::Par,
points: BTreeMap::new(),
}
}
pub fn insert(&mut self, days: u32, rate: f64) {
self.points.insert(days, rate);
}
pub fn get(&self, tenor: impl Into<Tenor>) -> Option<f64> {
if self.points.is_empty() {
return None;
}
interpolation::linear(&self.points, tenor.into().as_days())
}
pub fn yield_at(&self, tenor: impl Into<Tenor>) -> Option<f64> {
self.get(tenor)
}
pub fn len(&self) -> usize {
self.points.len()
}
pub fn is_empty(&self) -> bool {
self.points.is_empty()
}
pub fn to_continuous_map(&self) -> HashMap<u32, f64> {
self.points.iter().map(|(&k, &v)| (k, v)).collect()
}
pub fn bootstrap_zero(&self) -> crate::error::Result<YieldCurve> {
use crate::error::Error;
if self.yield_type == YieldType::Zero {
return Err(Error::Other(
"bootstrap_zero called on an already-zero curve".into(),
));
}
if self.points.is_empty() {
return Ok(YieldCurve {
date: self.date,
yield_type: YieldType::Zero,
points: BTreeMap::new(),
});
}
let mut zero_points: BTreeMap<u32, f64> = BTreeMap::new();
for (&days, &par_cont) in &self.points {
let t_years = days as f64 / 365.0;
if days <= 365 {
zero_points.insert(days, par_cont);
} else {
let par_cont_semi = par_cont / 2.0; let bey_half = par_cont_semi.exp() - 1.0; let c = bey_half;
let n_semi = (t_years * 2.0).round() as u32;
let mut coupon_df_sum = 0.0_f64;
for i in 1..n_semi {
let coupon_days = (i as f64 / 2.0 * 365.0).round() as u32;
let z_coupon = df_from_zero_points(&zero_points, coupon_days);
coupon_df_sum += z_coupon;
}
let numerator = 1.0 - c * coupon_df_sum;
let denominator = 1.0 + c;
if denominator <= 0.0 || numerator <= 0.0 {
return Err(Error::Other(format!(
"bootstrap_zero: non-positive discount factor at {days}d \
(numerator={numerator:.6}, denominator={denominator:.6}). \
Curve may be severely inverted or data is degenerate."
)));
}
let df_t = numerator / denominator;
let z_t = -df_t.ln() / t_years;
zero_points.insert(days, z_t);
}
}
Ok(YieldCurve {
date: self.date,
yield_type: YieldType::Zero,
points: zero_points,
})
}
}
fn df_from_zero_points(zero_points: &BTreeMap<u32, f64>, days: u32) -> f64 {
if zero_points.is_empty() {
return 1.0;
}
let z = interpolation::linear(zero_points, days).unwrap_or(0.0);
let t_years = days as f64 / 365.0;
(-z * t_years).exp()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SofrRate {
pub date: NaiveDate,
pub rate: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TermStructure {
pub date: NaiveDate,
pub treasury: YieldCurve,
pub sofr: Option<SofrRate>,
}
impl TermStructure {
pub fn rate_for(&self, tenor: impl Into<Tenor>) -> Option<f64> {
let days = tenor.into().as_days();
let mut points: BTreeMap<u32, f64> = self.treasury.points.clone();
if let Some(sofr) = &self.sofr {
points.insert(1, sofr.rate);
}
if points.is_empty() {
return None;
}
interpolation::linear(&points, days)
}
#[deprecated(
since = "0.2.0",
note = "use `rate_for(Tenor::days(d))` or `rate_for(Tenor::Y10)` instead"
)]
pub fn rate_for_days(&self, days: u32) -> Option<f64> {
self.rate_for(days)
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDate;
fn date() -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 4, 15).unwrap()
}
#[test]
fn yield_curve_exact_lookup() {
let mut c = YieldCurve::new(date());
c.insert(365, 0.04);
assert!((c.get(365).unwrap() - 0.04).abs() < 1e-12);
}
#[test]
fn yield_curve_interpolates() {
let mut c = YieldCurve::new(date());
c.insert(30, 0.04);
c.insert(60, 0.05);
let mid = c.get(45).unwrap();
assert!((mid - 0.045).abs() < 1e-12);
}
#[test]
fn yield_curve_empty_returns_none() {
let c = YieldCurve::new(date());
assert!(c.get(30).is_none());
}
#[test]
fn yield_curve_defaults_to_par_type() {
let c = YieldCurve::new(date());
assert_eq!(c.yield_type, YieldType::Par);
}
#[test]
fn term_structure_uses_sofr_anchor() {
let date = date();
let mut treasury = YieldCurve::new(date);
treasury.insert(365, 0.04);
let sofr = Some(SofrRate { date, rate: 0.05 });
let ts = TermStructure {
date,
treasury,
sofr,
};
let r1 = ts.rate_for(Tenor::ON).unwrap();
assert!((r1 - 0.05).abs() < 1e-12);
let r365 = ts.rate_for(Tenor::Y1).unwrap();
assert!((r365 - 0.04).abs() < 1e-12);
}
#[test]
fn to_continuous_map_roundtrip() {
let d = date();
let mut c = YieldCurve::new(d);
c.insert(365, 0.042);
c.insert(3650, 0.048);
let map = c.to_continuous_map();
assert_eq!(map.len(), 2);
assert!((map[&365] - 0.042).abs() < 1e-12);
assert!((map[&3650] - 0.048).abs() < 1e-12);
}
fn flat_par_curve(rate: f64) -> YieldCurve {
let d = NaiveDate::from_ymd_opt(2020, 3, 20).unwrap();
let mut c = YieldCurve::new(d);
for &days in &[
30u32, 91, 182, 365, 730, 1095, 1825, 2555, 3650, 7300, 10950,
] {
c.insert(days, rate);
}
c
}
fn steep_par_curve() -> YieldCurve {
let d = NaiveDate::from_ymd_opt(2020, 3, 20).unwrap();
let mut c = YieldCurve::new(d);
c.insert(30, 0.005);
c.insert(91, 0.007);
c.insert(182, 0.010);
c.insert(365, 0.015);
c.insert(730, 0.020);
c.insert(1095, 0.025);
c.insert(1825, 0.030);
c.insert(2555, 0.035);
c.insert(3650, 0.040);
c.insert(7300, 0.043);
c.insert(10950, 0.045);
c
}
fn inverted_par_curve() -> YieldCurve {
let d = NaiveDate::from_ymd_opt(2020, 3, 20).unwrap();
let mut c = YieldCurve::new(d);
c.insert(30, 0.055);
c.insert(91, 0.054);
c.insert(182, 0.052);
c.insert(365, 0.050);
c.insert(730, 0.048);
c.insert(1095, 0.046);
c.insert(1825, 0.044);
c.insert(2555, 0.042);
c.insert(3650, 0.040);
c.insert(7300, 0.038);
c.insert(10950, 0.036);
c
}
#[test]
fn bootstrap_flat_curve_zero_approx_par() {
let par = flat_par_curve(0.05);
let zero = par.bootstrap_zero().unwrap();
assert_eq!(zero.yield_type, YieldType::Zero);
for (&days, &par_rate) in &par.points {
let z = zero.get(days).unwrap();
assert!(
(z - par_rate).abs() < 5e-4,
"flat: days={days}, par={par_rate:.6}, zero={z:.6}, diff={:.6}",
(z - par_rate).abs()
);
}
}
#[test]
fn bootstrap_steep_curve_zeros_above_par() {
let par = steep_par_curve();
let zero = par.bootstrap_zero().unwrap();
for &days in &[730u32, 1095, 1825, 2555, 3650] {
let p = par.points[&days];
let z = zero.get(days).unwrap();
assert!(
z >= p - 1e-9,
"steep: days={days}, zero={z:.6} should be ≥ par={p:.6}"
);
}
}
#[test]
fn bootstrap_inverted_curve_zeros_below_par() {
let par = inverted_par_curve();
let zero = par.bootstrap_zero().unwrap();
for &days in &[730u32, 1095, 1825, 2555, 3650] {
let p = par.points[&days];
let z = zero.get(days).unwrap();
assert!(
z <= p + 1e-9,
"inverted: days={days}, zero={z:.6} should be ≤ par={p:.6}"
);
}
}
#[test]
fn bootstrap_short_end_par_equals_zero() {
let par = steep_par_curve();
let zero = par.bootstrap_zero().unwrap();
for &days in &[30u32, 91, 182, 365] {
let p = par.points[&days];
let z = zero.points[&days];
assert!(
(z - p).abs() < 1e-12,
"short end: days={days}, par={p:.8}, zero={z:.8}"
);
}
}
#[test]
fn bootstrap_single_knot_short_end() {
let d = NaiveDate::from_ymd_opt(2020, 1, 2).unwrap();
let mut par = YieldCurve::new(d);
par.insert(30, 0.04);
let zero = par.bootstrap_zero().unwrap();
assert_eq!(zero.points[&30], 0.04);
}
#[test]
fn bootstrap_empty_curve() {
let d = NaiveDate::from_ymd_opt(2020, 1, 2).unwrap();
let par = YieldCurve::new(d);
let zero = par.bootstrap_zero().unwrap();
assert!(zero.points.is_empty());
assert_eq!(zero.yield_type, YieldType::Zero);
}
#[test]
fn bootstrap_zero_on_zero_curve_is_err() {
let d = NaiveDate::from_ymd_opt(2020, 1, 2).unwrap();
let mut z = YieldCurve::new(d);
z.yield_type = YieldType::Zero;
z.insert(365, 0.04);
assert!(z.bootstrap_zero().is_err());
}
#[test]
fn bootstrap_sparse_curve() {
let d = NaiveDate::from_ymd_opt(2020, 3, 20).unwrap();
let mut par = YieldCurve::new(d);
par.insert(365, 0.02);
par.insert(3650, 0.04);
let zero = par.bootstrap_zero().unwrap();
assert!((zero.points[&365] - 0.02).abs() < 1e-12);
let z10 = zero.points[&3650];
assert!(
z10 > 0.04 - 1e-6,
"sparse 10Y zero={z10:.6} expected ≥ 0.04"
);
}
#[test]
fn bootstrap_near_zero_rates() {
let par = flat_par_curve(0.001);
let zero = par.bootstrap_zero().unwrap();
for (&days, &par_rate) in &par.points {
let z = zero.get(days).unwrap();
assert!(
(z - par_rate).abs() < 5e-4,
"near-zero: days={days}, par={par_rate:.6}, zero={z:.6}"
);
}
}
#[test]
fn bootstrap_high_rate_environment() {
let par = flat_par_curve(0.08);
let zero = par.bootstrap_zero().unwrap();
for &days in par.points.keys() {
let z = zero.get(days).unwrap();
assert!(z > 0.0, "high-rate: negative zero at {days}d: {z}");
}
}
}
pub type YieldCurveDay = YieldCurve;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SofrDay {
pub date: NaiveDate,
pub rate: f64,
}