use crate::curves::DiscountCurve;
use crate::errors::CurveError;
use crate::types::{Compounding, Date};
#[derive(Debug)]
pub struct ZeroCurve<'a> {
curve: &'a DiscountCurve,
compounding: Compounding,
}
impl<'a> ZeroCurve<'a> {
#[must_use]
#[inline]
pub fn from(curve: &'a DiscountCurve, compounding: Compounding) -> Self {
Self { curve, compounding }
}
#[must_use]
#[inline]
pub fn compounding(&self) -> Compounding {
self.compounding
}
pub fn rate(&self, t: f64) -> Result<f64, CurveError> {
self.curve.zero_rate(t, self.compounding)
}
pub fn rate_at(&self, date: Date) -> Result<f64, CurveError> {
let t = self
.curve
.daycount()
.year_fraction(self.curve.reference_date(), date)?;
self.rate(t)
}
pub fn discount(&self, t: f64) -> Result<f64, CurveError> {
self.curve.discount(t)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::interpolation::Interpolation;
use crate::types::Daycount;
fn d(y: i32, m: u32, day: u32) -> Date {
Date::from_ymd(y, m, day).unwrap()
}
fn reference_date() -> Date {
d(2024, 1, 2)
}
fn flat_curve(r_c: f64) -> DiscountCurve {
let mut times = Vec::new();
let mut discs = Vec::new();
for i in 0..=120 {
let date = Date::from_serial(reference_date().serial() + i * 91);
let t = Daycount::Act365F
.year_fraction(reference_date(), date)
.unwrap();
times.push(t);
discs.push((-r_c * t).exp());
}
DiscountCurve::from_times_and_discounts(
reference_date(),
Daycount::Act365F,
×,
&discs,
Interpolation::LogLinear,
)
.unwrap()
}
#[test]
fn from_constructs_view() {
let curve = flat_curve(0.04);
let z = ZeroCurve::from(&curve, Compounding::Continuous);
assert_eq!(z.compounding(), Compounding::Continuous);
}
#[test]
fn rate_flat_curve_continuous() {
let curve = flat_curve(0.04);
let z = ZeroCurve::from(&curve, Compounding::Continuous);
for t in [0.5, 1.0, 5.0, 10.0] {
let r = z.rate(t).unwrap();
assert!((r - 0.04).abs() < 1e-12, "t={t}: {r}");
}
}
#[test]
fn rate_flat_curve_simple() {
let curve = flat_curve(0.04);
let z = ZeroCurve::from(&curve, Compounding::Simple);
for t in [0.5_f64, 1.0, 5.0] {
let r = z.rate(t).unwrap();
let expected = ((-0.04_f64 * t).exp().recip() - 1.0) / t;
assert!((r - expected).abs() < 1e-12, "t={t}: {r} vs {expected}");
}
}
#[test]
fn rate_round_trip_through_discount() {
let curve = flat_curve(0.04);
for compounding in [
Compounding::Continuous,
Compounding::Simple,
Compounding::Periodic {
periods_per_year: 2,
},
] {
let z = ZeroCurve::from(&curve, compounding);
for t in [0.5_f64, 1.0, 3.0, 7.0] {
let r = z.rate(t).unwrap();
let d = compounding.discount_from_rate(r, t).unwrap();
let d_curve = z.discount(t).unwrap();
assert!(
(d - d_curve).abs() < 1e-12,
"round-trip {compounding:?} at t={t}: {d} vs {d_curve}"
);
}
}
}
#[test]
fn discount_delegates_to_parent() {
let curve = flat_curve(0.04);
let z = ZeroCurve::from(&curve, Compounding::Continuous);
for t in [0.0_f64, 1.0, 5.0] {
let a = z.discount(t).unwrap();
let b = curve.discount(t).unwrap();
assert!((a - b).abs() < 1e-15, "t={t}: {a} vs {b}");
}
}
#[test]
fn rate_at_uses_curve_daycount() {
let curve = DiscountCurve::new(
reference_date(),
Daycount::Act365F,
&[(reference_date(), 1.0), (d(2025, 1, 2), (-0.04_f64).exp())],
Interpolation::LogLinear,
)
.unwrap();
let z = ZeroCurve::from(&curve, Compounding::Continuous);
let r = z.rate_at(d(2025, 1, 2)).unwrap();
let expected = 0.04 / (366.0_f64 / 365.0);
assert!((r - expected).abs() < 1e-12);
}
#[test]
fn rate_rejects_negative_t() {
let curve = flat_curve(0.04);
let z = ZeroCurve::from(&curve, Compounding::Continuous);
assert!(matches!(
z.rate(-1.0).unwrap_err(),
CurveError::InvalidTime { .. }
));
}
}