use crate::curves::DiscountCurve;
use crate::errors::CurveError;
use crate::types::{Date, Daycount, Frequency};
#[derive(Debug)]
pub struct ParCurve<'a> {
curve: &'a DiscountCurve,
}
impl<'a> ParCurve<'a> {
#[must_use]
#[inline]
pub fn from(curve: &'a DiscountCurve) -> Self {
Self { curve }
}
pub fn par_rate(
&self,
start: Date,
maturity: Date,
freq: Frequency,
daycount: Daycount,
) -> Result<f64, CurveError> {
self.curve.par_swap_rate(start, maturity, freq, daycount)
}
pub fn par_rate_from_anchor(
&self,
maturity: Date,
freq: Frequency,
daycount: Daycount,
) -> Result<f64, CurveError> {
self.curve
.par_swap_rate(self.curve.reference_date(), maturity, freq, daycount)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::interpolation::Interpolation;
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()
}
fn closed_form_par(
reference: Date,
maturity: Date,
freq: Frequency,
accrual_dc: Daycount,
r_c: f64,
) -> f64 {
let periods_per_year = freq.periods_per_year();
let months_per_period = i32::try_from(12 / periods_per_year).unwrap_or(1);
let mut dates = vec![reference];
let mut step: i32 = 1;
loop {
let nxt =
crate::types::Tenor::new(months_per_period * step, crate::types::TenorUnit::Months)
.add_to(reference);
dates.push(nxt);
if nxt.serial() == maturity.serial() {
break;
}
step += 1;
}
let mut annuity = 0.0_f64;
for i in 0..(dates.len() - 1) {
let start = dates[i];
let end = dates[i + 1];
let tau = accrual_dc.year_fraction(start, end).unwrap();
let t = Daycount::Act365F.year_fraction(reference, end).unwrap();
annuity += tau * (-r_c * t).exp();
}
let t_end = Daycount::Act365F
.year_fraction(reference, maturity)
.unwrap();
(1.0 - (-r_c * t_end).exp()) / annuity
}
#[test]
fn from_constructs_view() {
let curve = flat_curve(0.04);
let _p = ParCurve::from(&curve);
}
#[test]
fn par_rate_flat_curve_2y_semi_annual_matches_closed_form() {
let r_c = 0.04_f64;
let curve = flat_curve(r_c);
let p = ParCurve::from(&curve);
let par = p
.par_rate(
reference_date(),
d(2026, 1, 2),
Frequency::SemiAnnual,
Daycount::Act365F,
)
.unwrap();
let expected = closed_form_par(
reference_date(),
d(2026, 1, 2),
Frequency::SemiAnnual,
Daycount::Act365F,
r_c,
);
assert!(
(par - expected).abs() < 1e-12,
"par={par}, expected={expected}"
);
}
#[test]
fn par_rate_from_anchor_matches_par_rate() {
let curve = flat_curve(0.04);
let p = ParCurve::from(&curve);
let a = p
.par_rate(
reference_date(),
d(2026, 1, 2),
Frequency::Annual,
Daycount::Act365F,
)
.unwrap();
let b = p
.par_rate_from_anchor(d(2026, 1, 2), Frequency::Annual, Daycount::Act365F)
.unwrap();
assert!((a - b).abs() < 1e-15);
}
#[test]
fn par_rate_increases_with_frequency_on_upward_curve() {
let r_c = 0.04_f64;
let curve = flat_curve(r_c);
let p = ParCurve::from(&curve);
let p_a = p
.par_rate_from_anchor(d(2026, 1, 2), Frequency::Annual, Daycount::Act365F)
.unwrap();
let p_s = p
.par_rate_from_anchor(d(2026, 1, 2), Frequency::SemiAnnual, Daycount::Act365F)
.unwrap();
let p_q = p
.par_rate_from_anchor(d(2026, 1, 2), Frequency::Quarterly, Daycount::Act365F)
.unwrap();
assert!((p_a - r_c).abs() < 1e-2);
assert!((p_s - r_c).abs() < 1e-2);
assert!((p_q - r_c).abs() < 1e-2);
assert!(p_a > r_c);
assert!(p_s > r_c);
assert!(p_q > r_c);
assert!(p_q < p_s);
assert!(p_s < p_a);
}
#[test]
fn par_rate_rejects_inverted_dates() {
let curve = flat_curve(0.04);
let p = ParCurve::from(&curve);
let err = p
.par_rate(
d(2025, 1, 2),
d(2024, 1, 2),
Frequency::Annual,
Daycount::Act365F,
)
.unwrap_err();
assert!(matches!(err, CurveError::InvalidTime { .. }));
}
#[test]
fn par_rate_rejects_irregular_schedule() {
let curve = flat_curve(0.04);
let p = ParCurve::from(&curve);
let err = p
.par_rate(
d(2024, 1, 2),
d(2025, 2, 2),
Frequency::SemiAnnual,
Daycount::Act365F,
)
.unwrap_err();
assert!(matches!(err, CurveError::InvalidTime { .. }));
}
#[test]
fn par_rate_once_at_maturity_single_period() {
let r_c = 0.04_f64;
let curve = flat_curve(r_c);
let p = ParCurve::from(&curve);
let par = p
.par_rate_from_anchor(d(2025, 1, 2), Frequency::OnceAtMaturity, Daycount::Act365F)
.unwrap();
let t = Daycount::Act365F
.year_fraction(reference_date(), d(2025, 1, 2))
.unwrap();
let d_t = (-r_c * t).exp();
let expected = (1.0 - d_t) / (t * d_t);
assert!(
(par - expected).abs() < 1e-12,
"par={par}, expected={expected}"
);
}
}