use crate::errors::CurveError;
use crate::interpolation::{Interpolation, InterpolationImpl};
use crate::types::{Compounding, Date, Daycount, Frequency, Tenor, TenorUnit};
#[derive(Debug, Clone)]
pub struct DiscountCurve {
reference_date: Date,
daycount: Daycount,
times: Vec<f64>,
discounts: Vec<f64>,
interpolation: Interpolation,
interpolator: InterpolationImpl,
}
impl DiscountCurve {
pub fn new(
reference_date: Date,
daycount: Daycount,
nodes: &[(Date, f64)],
interpolation: Interpolation,
) -> Result<Self, CurveError> {
if nodes.len() < 2 {
return Err(CurveError::TooFewNodes { found: nodes.len() });
}
let (first_date, first_d) = nodes[0];
if first_date != reference_date || (first_d - 1.0).abs() > f64::EPSILON {
return Err(CurveError::AnchorNotUnit);
}
let n = nodes.len();
let mut times = Vec::with_capacity(n);
let mut discounts = Vec::with_capacity(n);
for (i, &(date, disc)) in nodes.iter().enumerate() {
if !disc.is_finite() || disc <= 0.0 {
return Err(CurveError::NonPositiveDiscount {
at_index: i,
value: disc,
});
}
if i > 0 {
let prev_date = nodes[i - 1].0;
if date == prev_date {
let t = daycount.year_fraction(reference_date, date)?;
return Err(CurveError::DuplicateNode { t });
}
if date.serial() < prev_date.serial() {
return Err(CurveError::NodesNotIncreasing { at_index: i });
}
}
let t = daycount.year_fraction(reference_date, date)?;
times.push(t);
discounts.push(disc);
}
let knots: Vec<(f64, f64)> = times
.iter()
.copied()
.zip(discounts.iter().copied())
.collect();
let interpolator = interpolation.build(&knots)?;
Ok(Self {
reference_date,
daycount,
times,
discounts,
interpolation,
interpolator,
})
}
pub fn from_times_and_discounts(
reference_date: Date,
daycount: Daycount,
times: &[f64],
discounts: &[f64],
interpolation: Interpolation,
) -> Result<Self, CurveError> {
if times.len() != discounts.len() {
return Err(CurveError::TooFewNodes {
found: times.len().min(discounts.len()),
});
}
if times.len() < 2 {
return Err(CurveError::TooFewNodes { found: times.len() });
}
#[allow(clippy::float_cmp)]
let anchor_ok = times[0] == 0.0 && discounts[0] == 1.0;
if !anchor_ok {
return Err(CurveError::AnchorNotUnit);
}
for (i, (&t, &d)) in times.iter().zip(discounts.iter()).enumerate() {
if !t.is_finite() || t < 0.0 {
return Err(CurveError::InvalidTime { t });
}
if !d.is_finite() || d <= 0.0 {
return Err(CurveError::NonPositiveDiscount {
at_index: i,
value: d,
});
}
if i > 0 {
let prev = times[i - 1];
#[allow(clippy::float_cmp)]
let duplicate = t == prev;
if duplicate {
return Err(CurveError::DuplicateNode { t });
}
if t < prev {
return Err(CurveError::NodesNotIncreasing { at_index: i });
}
}
}
let knots: Vec<(f64, f64)> = times
.iter()
.copied()
.zip(discounts.iter().copied())
.collect();
let interpolator = interpolation.build(&knots)?;
Ok(Self {
reference_date,
daycount,
times: times.to_vec(),
discounts: discounts.to_vec(),
interpolation,
interpolator,
})
}
#[must_use]
#[inline]
pub fn reference_date(&self) -> Date {
self.reference_date
}
#[must_use]
#[inline]
pub fn daycount(&self) -> Daycount {
self.daycount
}
#[must_use]
#[inline]
pub fn interpolation(&self) -> Interpolation {
self.interpolation
}
#[must_use]
#[inline]
pub fn times(&self) -> &[f64] {
&self.times
}
#[must_use]
#[inline]
pub fn discounts(&self) -> &[f64] {
&self.discounts
}
pub fn discount(&self, t: f64) -> Result<f64, CurveError> {
if !t.is_finite() || t < 0.0 {
return Err(CurveError::InvalidTime { t });
}
Ok(self.interpolator.eval(t))
}
pub fn discount_at(&self, date: Date) -> Result<f64, CurveError> {
let t = self.daycount.year_fraction(self.reference_date, date)?;
self.discount(t)
}
pub fn zero_rate(&self, t: f64, compounding: Compounding) -> Result<f64, CurveError> {
if !t.is_finite() || t < 0.0 {
return Err(CurveError::InvalidTime { t });
}
if t == 0.0 {
return Ok(0.0);
}
let d = self.interpolator.eval(t);
let r = compounding.rate_from_discount(d, t)?;
Ok(r)
}
pub fn instantaneous_forward(&self, t: f64) -> Result<f64, CurveError> {
if !t.is_finite() || t < 0.0 {
return Err(CurveError::InvalidTime { t });
}
let d = self.interpolator.eval(t);
if d <= 0.0 || !d.is_finite() {
return Err(CurveError::NonPositiveDiscount {
at_index: 0,
value: d,
});
}
if let Some(dprime) = self.interpolator.deriv(t) {
if dprime.is_finite() {
return Ok(-dprime / d);
}
}
let h = 1e-7_f64.max(t.abs() * 1e-7);
let d_right = self.interpolator.eval(t + h);
if d_right <= 0.0 || !d_right.is_finite() {
return Err(CurveError::NonPositiveDiscount {
at_index: 0,
value: d_right,
});
}
Ok(-(d_right.ln() - d.ln()) / h)
}
pub fn forward_rate(&self, t1: f64, t2: f64, _daycount: Daycount) -> Result<f64, CurveError> {
if !t1.is_finite() || t1 < 0.0 {
return Err(CurveError::InvalidTime { t: t1 });
}
if !t2.is_finite() || t2 <= t1 {
return Err(CurveError::InvalidTime { t: t2 });
}
let d1 = self.interpolator.eval(t1);
let d2 = self.interpolator.eval(t2);
if d1 <= 0.0 || !d1.is_finite() {
return Err(CurveError::NonPositiveDiscount {
at_index: 0,
value: d1,
});
}
if d2 <= 0.0 || !d2.is_finite() {
return Err(CurveError::NonPositiveDiscount {
at_index: 0,
value: d2,
});
}
let tau = t2 - t1;
Ok((d1 / d2 - 1.0) / tau)
}
pub fn par_swap_rate(
&self,
start: Date,
maturity: Date,
freq: Frequency,
daycount: Daycount,
) -> Result<f64, CurveError> {
if start.serial() >= maturity.serial() {
return Err(CurveError::InvalidTime {
t: f64::from(start.days_between(maturity)),
});
}
let n = freq.periods_per_year();
let schedule: Vec<Date> = if matches!(freq, Frequency::OnceAtMaturity) {
vec![start, maturity]
} else {
if n == 0 {
return Err(CurveError::InvalidTime { t: 0.0 });
}
let months_per_period = i32::try_from(12 / n).unwrap_or(1);
let mut dates = vec![start];
let mut k: i32 = 1;
loop {
let total_months = months_per_period
.checked_mul(k)
.ok_or(CurveError::InvalidTime { t: f64::from(k) })?;
let next = Tenor::new(total_months, TenorUnit::Months).add_to(start);
if next.serial() > maturity.serial() {
return Err(CurveError::InvalidTime {
t: f64::from(next.days_between(maturity)),
});
}
dates.push(next);
if next.serial() == maturity.serial() {
break;
}
k = k
.checked_add(1)
.ok_or(CurveError::InvalidTime { t: f64::from(k) })?;
if k > 10_000 {
return Err(CurveError::InvalidTime { t: f64::from(k) });
}
}
dates
};
let t_start = self.daycount.year_fraction(self.reference_date, start)?;
let t_end = self.daycount.year_fraction(self.reference_date, maturity)?;
let d_start = self.discount(t_start)?;
let d_end = self.discount(t_end)?;
let mut annuity = 0.0_f64;
for i in 0..(schedule.len() - 1) {
let period_start = schedule[i];
let period_end = schedule[i + 1];
let tau = daycount.year_fraction(period_start, period_end)?;
let t_pay = self
.daycount
.year_fraction(self.reference_date, period_end)?;
let d_pay = self.discount(t_pay)?;
if d_pay <= 0.0 {
return Err(CurveError::NonPositiveDiscount {
at_index: i,
value: d_pay,
});
}
annuity += tau * d_pay;
}
if annuity <= 0.0 || !annuity.is_finite() {
return Err(CurveError::NonPositiveDiscount {
at_index: 0,
value: annuity,
});
}
Ok((d_start - d_end) / annuity)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::interpolation::SplineBoundary;
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_tables(reference: Date, daycount: Daycount, r_c: f64) -> (Vec<f64>, Vec<f64>) {
let mut times = Vec::new();
let mut discs = Vec::new();
for i in 0..=120 {
let date = Date::from_serial(reference.serial() + i * 91);
let t = daycount.year_fraction(reference, date).unwrap();
times.push(t);
discs.push((-r_c * t).exp());
}
(times, discs)
}
#[test]
fn new_accepts_minimal_two_node_curve() {
let curve = DiscountCurve::new(
reference_date(),
Daycount::Act365F,
&[(reference_date(), 1.0), (d(2025, 1, 2), 0.95)],
Interpolation::LogLinear,
)
.unwrap();
assert_eq!(curve.reference_date(), reference_date());
assert_eq!(curve.times().len(), 2);
assert_eq!(curve.discounts().len(), 2);
}
#[test]
fn new_rejects_too_few_nodes() {
let err = DiscountCurve::new(
reference_date(),
Daycount::Act365F,
&[(reference_date(), 1.0)],
Interpolation::LogLinear,
)
.unwrap_err();
assert!(matches!(err, CurveError::TooFewNodes { found: 1 }));
}
#[test]
fn new_rejects_missing_anchor() {
let err = DiscountCurve::new(
reference_date(),
Daycount::Act365F,
&[(d(2024, 2, 2), 1.0), (d(2025, 1, 2), 0.95)],
Interpolation::LogLinear,
)
.unwrap_err();
assert!(matches!(err, CurveError::AnchorNotUnit));
}
#[test]
fn new_rejects_anchor_not_unit() {
let err = DiscountCurve::new(
reference_date(),
Daycount::Act365F,
&[(reference_date(), 0.99), (d(2025, 1, 2), 0.95)],
Interpolation::LogLinear,
)
.unwrap_err();
assert!(matches!(err, CurveError::AnchorNotUnit));
}
#[test]
fn new_rejects_non_increasing_dates() {
let err = DiscountCurve::new(
reference_date(),
Daycount::Act365F,
&[
(reference_date(), 1.0),
(d(2026, 1, 2), 0.90),
(d(2025, 1, 2), 0.95),
],
Interpolation::LogLinear,
)
.unwrap_err();
assert!(matches!(
err,
CurveError::NodesNotIncreasing { at_index: 2 }
));
}
#[test]
fn new_rejects_duplicate_dates() {
let err = DiscountCurve::new(
reference_date(),
Daycount::Act365F,
&[
(reference_date(), 1.0),
(d(2025, 1, 2), 0.95),
(d(2025, 1, 2), 0.94),
],
Interpolation::LogLinear,
)
.unwrap_err();
assert!(matches!(err, CurveError::DuplicateNode { .. }));
}
#[test]
fn new_rejects_non_positive_discount() {
let err = DiscountCurve::new(
reference_date(),
Daycount::Act365F,
&[(reference_date(), 1.0), (d(2025, 1, 2), -0.5)],
Interpolation::LogLinear,
)
.unwrap_err();
assert!(matches!(
err,
CurveError::NonPositiveDiscount { at_index: 1, .. }
));
}
#[test]
fn new_rejects_nan_discount() {
let err = DiscountCurve::new(
reference_date(),
Daycount::Act365F,
&[(reference_date(), 1.0), (d(2025, 1, 2), f64::NAN)],
Interpolation::LogLinear,
)
.unwrap_err();
assert!(matches!(
err,
CurveError::NonPositiveDiscount { at_index: 1, .. }
));
}
#[test]
fn from_times_and_discounts_rejects_anchor_not_unit() {
let err = DiscountCurve::from_times_and_discounts(
reference_date(),
Daycount::Act365F,
&[0.5, 1.0],
&[1.0, 0.95],
Interpolation::LogLinear,
)
.unwrap_err();
assert!(matches!(err, CurveError::AnchorNotUnit));
let err = DiscountCurve::from_times_and_discounts(
reference_date(),
Daycount::Act365F,
&[0.0, 1.0],
&[0.99, 0.95],
Interpolation::LogLinear,
)
.unwrap_err();
assert!(matches!(err, CurveError::AnchorNotUnit));
}
#[test]
fn from_times_and_discounts_rejects_mismatched_lengths() {
let err = DiscountCurve::from_times_and_discounts(
reference_date(),
Daycount::Act365F,
&[0.0, 1.0, 2.0],
&[1.0, 0.95],
Interpolation::LogLinear,
)
.unwrap_err();
assert!(matches!(err, CurveError::TooFewNodes { .. }));
}
#[test]
fn from_times_and_discounts_rejects_nan_time() {
let err = DiscountCurve::from_times_and_discounts(
reference_date(),
Daycount::Act365F,
&[0.0, f64::NAN],
&[1.0, 0.95],
Interpolation::LogLinear,
)
.unwrap_err();
assert!(matches!(err, CurveError::InvalidTime { .. }));
}
#[test]
fn discount_knot_reproduction_log_linear() {
let (times, discs) = flat_curve_tables(reference_date(), Daycount::Act365F, 0.04);
let curve = DiscountCurve::from_times_and_discounts(
reference_date(),
Daycount::Act365F,
×,
&discs,
Interpolation::LogLinear,
)
.unwrap();
for (&t, &dd) in times.iter().zip(discs.iter()) {
let v = curve.discount(t).unwrap();
assert!((v - dd).abs() < 1e-14, "knot ({t}, {dd}) -> {v}");
}
}
#[test]
fn discount_knot_reproduction_all_methods() {
let (times, discs) = flat_curve_tables(reference_date(), Daycount::Act365F, 0.03);
for method in [
Interpolation::Linear,
Interpolation::LogLinear,
Interpolation::LinearInZero,
Interpolation::PiecewiseConstantForward,
Interpolation::CubicSpline(SplineBoundary::NotAKnot),
Interpolation::CubicSpline(SplineBoundary::Natural),
Interpolation::HermiteBessel,
Interpolation::MonotoneCubic,
Interpolation::MonotoneHyman,
Interpolation::MonotoneSteffen,
] {
let curve = DiscountCurve::from_times_and_discounts(
reference_date(),
Daycount::Act365F,
×,
&discs,
method,
)
.unwrap();
for (&t, &dd) in times.iter().zip(discs.iter()) {
let v = curve.discount(t).unwrap();
assert!(
(v - dd).abs() < 1e-12,
"method {method:?}: knot ({t}, {dd}) -> {v}"
);
}
}
}
#[test]
fn discount_anchor_is_unit_to_round_off() {
let curve = DiscountCurve::new(
reference_date(),
Daycount::Act365F,
&[(reference_date(), 1.0), (d(2025, 1, 2), 0.95)],
Interpolation::LogLinear,
)
.unwrap();
assert!((curve.discount(0.0).unwrap() - 1.0).abs() < 1e-15);
assert!((curve.discount_at(reference_date()).unwrap() - 1.0).abs() < 1e-15);
}
#[test]
fn discount_rejects_negative_time() {
let curve = DiscountCurve::new(
reference_date(),
Daycount::Act365F,
&[(reference_date(), 1.0), (d(2025, 1, 2), 0.95)],
Interpolation::LogLinear,
)
.unwrap();
assert!(matches!(
curve.discount(-1.0).unwrap_err(),
CurveError::InvalidTime { .. }
));
}
#[test]
fn discount_rejects_non_finite_time() {
let curve = DiscountCurve::new(
reference_date(),
Daycount::Act365F,
&[(reference_date(), 1.0), (d(2025, 1, 2), 0.95)],
Interpolation::LogLinear,
)
.unwrap();
assert!(matches!(
curve.discount(f64::NAN).unwrap_err(),
CurveError::InvalidTime { .. }
));
}
#[test]
fn zero_rate_flat_curve_continuous() {
let r_c = 0.04;
let (times, discs) = flat_curve_tables(reference_date(), Daycount::Act365F, r_c);
let curve = DiscountCurve::from_times_and_discounts(
reference_date(),
Daycount::Act365F,
×,
&discs,
Interpolation::LogLinear,
)
.unwrap();
for t in [0.25, 1.0, 5.0, 10.0, 25.0] {
let z = curve.zero_rate(t, Compounding::Continuous).unwrap();
assert!(
(z - r_c).abs() < 1e-12,
"zero rate at t={t}: got {z}, expected {r_c}"
);
}
}
#[test]
fn zero_rate_at_anchor_is_zero() {
let curve = DiscountCurve::new(
reference_date(),
Daycount::Act365F,
&[(reference_date(), 1.0), (d(2025, 1, 2), 0.95)],
Interpolation::LogLinear,
)
.unwrap();
let z = curve.zero_rate(0.0, Compounding::Continuous).unwrap();
assert!((z - 0.0).abs() < 1e-15);
}
#[test]
fn zero_rate_each_compounding_variant() {
let r_c = 0.05_f64;
let curve = DiscountCurve::from_times_and_discounts(
reference_date(),
Daycount::Act365F,
&[0.0, 1.0, 2.0],
&[1.0, (-r_c).exp(), (-2.0 * r_c).exp()],
Interpolation::LogLinear,
)
.unwrap();
let d_1y = (-r_c).exp();
let z_c = curve.zero_rate(1.0, Compounding::Continuous).unwrap();
assert!((z_c - r_c).abs() < 1e-12);
let z_s = curve.zero_rate(1.0, Compounding::Simple).unwrap();
assert!((z_s - (1.0 / d_1y - 1.0)).abs() < 1e-12);
let z_p = curve
.zero_rate(
1.0,
Compounding::Periodic {
periods_per_year: 2,
},
)
.unwrap();
let expected = 2.0 * (d_1y.powf(-0.5) - 1.0);
assert!((z_p - expected).abs() < 1e-12);
}
#[test]
fn zero_rate_rejects_negative_time() {
let curve = DiscountCurve::new(
reference_date(),
Daycount::Act365F,
&[(reference_date(), 1.0), (d(2025, 1, 2), 0.95)],
Interpolation::LogLinear,
)
.unwrap();
assert!(matches!(
curve.zero_rate(-1.0, Compounding::Continuous).unwrap_err(),
CurveError::InvalidTime { .. }
));
}
#[test]
fn instantaneous_forward_flat_curve_loglinear() {
let r_c = 0.04_f64;
let (times, discs) = flat_curve_tables(reference_date(), Daycount::Act365F, r_c);
let curve = DiscountCurve::from_times_and_discounts(
reference_date(),
Daycount::Act365F,
×,
&discs,
Interpolation::LogLinear,
)
.unwrap();
for t in [0.1, 0.5, 1.0, 2.0, 5.0] {
let f = curve.instantaneous_forward(t).unwrap();
assert!(
(f - r_c).abs() < 1e-10,
"instantaneous fwd at t={t}: got {f}, expected {r_c}"
);
}
}
#[test]
fn instantaneous_forward_flat_curve_smooth_methods() {
let r_c = 0.04_f64;
let (times, discs) = flat_curve_tables(reference_date(), Daycount::Act365F, r_c);
for method in [
Interpolation::CubicSpline(SplineBoundary::NotAKnot),
Interpolation::MonotoneCubic,
Interpolation::HermiteBessel,
] {
let curve = DiscountCurve::from_times_and_discounts(
reference_date(),
Daycount::Act365F,
×,
&discs,
method,
)
.unwrap();
let f = curve.instantaneous_forward(1.5).unwrap();
assert!(
(f - r_c).abs() < 5e-3,
"method {method:?}: fwd at 1.5y -> {f}"
);
}
}
#[test]
fn instantaneous_forward_rejects_negative_time() {
let curve = DiscountCurve::new(
reference_date(),
Daycount::Act365F,
&[(reference_date(), 1.0), (d(2025, 1, 2), 0.95)],
Interpolation::LogLinear,
)
.unwrap();
assert!(matches!(
curve.instantaneous_forward(-0.5).unwrap_err(),
CurveError::InvalidTime { .. }
));
}
#[test]
fn forward_rate_flat_curve_closed_form() {
let r_c = 0.04_f64;
let (times, discs) = flat_curve_tables(reference_date(), Daycount::Act365F, r_c);
let curve = DiscountCurve::from_times_and_discounts(
reference_date(),
Daycount::Act365F,
×,
&discs,
Interpolation::LogLinear,
)
.unwrap();
let cases = [(1.0_f64, 2.0_f64), (0.5, 3.0), (2.0, 5.0)];
for (t1, t2) in cases {
let l = curve.forward_rate(t1, t2, Daycount::Act365F).unwrap();
let expected = (r_c * (t2 - t1)).exp_m1() / (t2 - t1);
assert!(
(l - expected).abs() < 1e-12,
"fwd[{t1},{t2}] -> {l}, expected {expected}"
);
}
}
#[test]
fn forward_rate_rejects_t2_le_t1() {
let curve = DiscountCurve::new(
reference_date(),
Daycount::Act365F,
&[(reference_date(), 1.0), (d(2025, 1, 2), 0.95)],
Interpolation::LogLinear,
)
.unwrap();
assert!(matches!(
curve.forward_rate(2.0, 1.0, Daycount::Act365F).unwrap_err(),
CurveError::InvalidTime { .. }
));
assert!(matches!(
curve.forward_rate(2.0, 2.0, Daycount::Act365F).unwrap_err(),
CurveError::InvalidTime { .. }
));
}
#[test]
fn par_swap_rate_flat_curve_2y_semi_annual() {
let r_c = 0.04_f64;
let (times, discs) = flat_curve_tables(reference_date(), Daycount::Act365F, r_c);
let curve = DiscountCurve::from_times_and_discounts(
reference_date(),
Daycount::Act365F,
×,
&discs,
Interpolation::LogLinear,
)
.unwrap();
let par = curve
.par_swap_rate(
reference_date(),
d(2026, 1, 2),
Frequency::SemiAnnual,
Daycount::Act365F,
)
.unwrap();
let mut annuity = 0.0_f64;
let pillars = [d(2024, 7, 2), d(2025, 1, 2), d(2025, 7, 2), d(2026, 1, 2)];
let mut prev = reference_date();
for date in pillars {
let tau = Daycount::Act365F.year_fraction(prev, date).unwrap();
let t = Daycount::Act365F
.year_fraction(reference_date(), date)
.unwrap();
annuity += tau * (-r_c * t).exp();
prev = date;
}
let t_end = Daycount::Act365F
.year_fraction(reference_date(), d(2026, 1, 2))
.unwrap();
let expected = (1.0 - (-r_c * t_end).exp()) / annuity;
assert!(
(par - expected).abs() < 1e-12,
"par={par}, expected={expected}"
);
}
#[test]
fn par_swap_rate_rejects_start_ge_maturity() {
let curve = DiscountCurve::new(
reference_date(),
Daycount::Act365F,
&[(reference_date(), 1.0), (d(2025, 1, 2), 0.95)],
Interpolation::LogLinear,
)
.unwrap();
let err = curve
.par_swap_rate(
d(2025, 1, 2),
reference_date(),
Frequency::SemiAnnual,
Daycount::Act365F,
)
.unwrap_err();
assert!(matches!(err, CurveError::InvalidTime { .. }));
}
#[test]
fn par_swap_rate_rejects_irregular_schedule() {
let curve = DiscountCurve::new(
reference_date(),
Daycount::Act365F,
&[(reference_date(), 1.0), (d(2025, 1, 2), 0.95)],
Interpolation::LogLinear,
)
.unwrap();
let err = curve
.par_swap_rate(
d(2024, 1, 2),
d(2025, 2, 2),
Frequency::SemiAnnual,
Daycount::Act365F,
)
.unwrap_err();
assert!(matches!(err, CurveError::InvalidTime { .. }));
}
#[test]
fn discount_agrees_with_curve_snapshot() {
use crate::instruments::CurveSnapshot;
let reference = reference_date();
let dc = Daycount::Act365F;
let r_c = 0.04_f64;
let (times, discs) = flat_curve_tables(reference, dc, r_c);
let curve = DiscountCurve::from_times_and_discounts(
reference,
dc,
×,
&discs,
Interpolation::LogLinear,
)
.unwrap();
let snap = CurveSnapshot {
reference_date: reference,
daycount: dc,
times: ×,
discounts: &discs,
};
for &t in × {
let a = curve.discount(t).unwrap();
let b = snap.discount_at(t).unwrap();
assert!((a - b).abs() < 1e-14, "knot t={t}: {a} vs {b}");
}
for i in 1..=10 {
let t = (f64::from(i) / 11.0) * 25.0;
let a = curve.discount(t).unwrap();
let b = snap.discount_at(t).unwrap();
assert!((a - b).abs() < 1e-14, "probe t={t}: {a} vs {b}");
}
}
#[test]
fn accessors_round_trip_constructor_inputs() {
let curve = DiscountCurve::new(
reference_date(),
Daycount::Act365F,
&[(reference_date(), 1.0), (d(2025, 1, 2), 0.95)],
Interpolation::LogLinear,
)
.unwrap();
assert_eq!(curve.reference_date(), reference_date());
assert_eq!(curve.daycount(), Daycount::Act365F);
assert_eq!(curve.interpolation(), Interpolation::LogLinear);
assert_eq!(curve.times().len(), 2);
assert_eq!(curve.discounts().len(), 2);
}
#[test]
fn clone_yields_equivalent_curve() {
let curve = DiscountCurve::new(
reference_date(),
Daycount::Act365F,
&[(reference_date(), 1.0), (d(2025, 1, 2), 0.95)],
Interpolation::LogLinear,
)
.unwrap();
let copy = curve.clone();
assert!((curve.discount(0.5).unwrap() - copy.discount(0.5).unwrap()).abs() < 1e-15);
}
}