use crate::errors::BootstrapError;
use crate::types::{Date, Frequency, Tenor, TenorUnit};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SwapSchedule {
dates: Vec<Date>,
}
impl SwapSchedule {
pub fn from_regular(
start: Date,
maturity: Date,
freq: Frequency,
) -> Result<Self, BootstrapError> {
if start.serial() >= maturity.serial() {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "schedule start must precede maturity",
});
}
if matches!(freq, Frequency::OnceAtMaturity) {
return Ok(Self {
dates: vec![start, maturity],
});
}
let n = freq.periods_per_year();
if n == 0 {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "frequency periods_per_year must be positive",
});
}
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(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "schedule overflow",
})?;
let next = Tenor::new(total_months, TenorUnit::Months).add_to(start);
if next.serial() > maturity.serial() {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "schedule is not regular at the requested frequency",
});
}
dates.push(next);
if next.serial() == maturity.serial() {
break;
}
k = k.checked_add(1).ok_or(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "schedule overflow",
})?;
if k > 10_000 {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "schedule exceeded 10000 periods",
});
}
}
Ok(Self { dates })
}
pub fn from_dates(dates: &[Date]) -> Result<Self, BootstrapError> {
if dates.len() < 2 {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "schedule requires at least two boundary dates",
});
}
for w in dates.windows(2) {
if w[0].serial() >= w[1].serial() {
return Err(BootstrapError::InvalidInstrument {
at_index: 0,
reason: "schedule dates must be strictly increasing",
});
}
}
Ok(Self {
dates: dates.to_vec(),
})
}
#[must_use]
#[inline]
pub fn len(&self) -> usize {
self.dates.len().saturating_sub(1)
}
#[must_use]
#[inline]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
#[must_use]
#[inline]
pub fn start(&self) -> Date {
self.dates[0]
}
#[must_use]
#[inline]
pub fn maturity(&self) -> Date {
self.dates[self.dates.len() - 1]
}
#[must_use]
#[inline]
pub fn period_start(&self, i: usize) -> Date {
self.dates[i]
}
#[must_use]
#[inline]
pub fn period_end(&self, i: usize) -> Date {
self.dates[i + 1]
}
#[must_use]
#[inline]
pub fn payment_date(&self, i: usize) -> Date {
self.dates[i + 1]
}
#[must_use]
#[inline]
pub fn dates(&self) -> &[Date] {
&self.dates
}
}
#[cfg(test)]
mod tests {
use super::*;
fn d(y: i32, m: u32, day: u32) -> Date {
Date::from_ymd(y, m, day).unwrap()
}
#[test]
fn regular_2y_semi_annual_has_four_periods() {
let s = d(2024, 1, 2);
let m = d(2026, 1, 2);
let sch = SwapSchedule::from_regular(s, m, Frequency::SemiAnnual).unwrap();
assert_eq!(sch.len(), 4);
assert_eq!(sch.start(), s);
assert_eq!(sch.maturity(), m);
assert_eq!(sch.period_end(0), d(2024, 7, 2));
assert_eq!(sch.period_end(1), d(2025, 1, 2));
assert_eq!(sch.period_end(2), d(2025, 7, 2));
assert_eq!(sch.period_end(3), m);
}
#[test]
fn regular_1y_annual_single_period() {
let s = d(2024, 6, 15);
let m = d(2025, 6, 15);
let sch = SwapSchedule::from_regular(s, m, Frequency::Annual).unwrap();
assert_eq!(sch.len(), 1);
assert_eq!(sch.period_start(0), s);
assert_eq!(sch.period_end(0), m);
}
#[test]
fn regular_3y_quarterly_has_twelve_periods() {
let s = d(2024, 1, 2);
let m = d(2027, 1, 2);
let sch = SwapSchedule::from_regular(s, m, Frequency::Quarterly).unwrap();
assert_eq!(sch.len(), 12);
}
#[test]
fn regular_5y_monthly_has_sixty_periods() {
let s = d(2024, 1, 2);
let m = d(2029, 1, 2);
let sch = SwapSchedule::from_regular(s, m, Frequency::Monthly).unwrap();
assert_eq!(sch.len(), 60);
}
#[test]
fn once_at_maturity_single_period() {
let s = d(2024, 1, 2);
let m = d(2025, 1, 2);
let sch = SwapSchedule::from_regular(s, m, Frequency::OnceAtMaturity).unwrap();
assert_eq!(sch.len(), 1);
}
#[test]
fn irregular_schedule_rejected_at_regular_constructor() {
let s = d(2024, 1, 2);
let m = d(2025, 2, 2);
let err = SwapSchedule::from_regular(s, m, Frequency::SemiAnnual).unwrap_err();
assert!(matches!(
err,
BootstrapError::InvalidInstrument {
reason: r,
..
} if r.contains("not regular")
));
}
#[test]
fn start_after_maturity_rejected() {
let s = d(2026, 1, 2);
let m = d(2024, 1, 2);
let err = SwapSchedule::from_regular(s, m, Frequency::Annual).unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn equal_start_and_maturity_rejected() {
let s = d(2024, 1, 2);
let err = SwapSchedule::from_regular(s, s, Frequency::Annual).unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn from_dates_accepts_strictly_increasing() {
let dates = vec![d(2024, 1, 2), d(2024, 7, 2), d(2025, 1, 2)];
let sch = SwapSchedule::from_dates(&dates).unwrap();
assert_eq!(sch.len(), 2);
assert_eq!(sch.dates(), &dates[..]);
}
#[test]
fn from_dates_rejects_short() {
let dates = vec![d(2024, 1, 2)];
let err = SwapSchedule::from_dates(&dates).unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn from_dates_rejects_non_increasing() {
let dates = vec![d(2024, 1, 2), d(2024, 1, 2), d(2025, 1, 2)];
let err = SwapSchedule::from_dates(&dates).unwrap_err();
assert!(matches!(err, BootstrapError::InvalidInstrument { .. }));
}
#[test]
fn is_empty_false_after_construction() {
let s = d(2024, 1, 2);
let m = d(2025, 1, 2);
let sch = SwapSchedule::from_regular(s, m, Frequency::Annual).unwrap();
assert!(!sch.is_empty());
}
}