use std::fmt;
use std::ops::Index;
use crate::calendar::{BusinessDayConvention, Calendar, WeekendsOnly};
use crate::date::{Date, Period, Unit};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum DateGeneration {
Backward,
Forward,
Zero,
ThirdWednesday,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum StubConvention {
ShortFront,
LongFront,
ShortBack,
LongBack,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ScheduleError {
EmptyRange { effective: Date, termination: Date },
NonPositiveTenor(i32),
StubDirectionMismatch {
rule: DateGeneration,
stub: StubConvention,
},
}
impl fmt::Display for ScheduleError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EmptyRange {
effective,
termination,
} => write!(
f,
"termination {termination} must be after effective {effective}"
),
Self::NonPositiveTenor(n) => write!(f, "tenor must be positive, got {n}"),
Self::StubDirectionMismatch { rule, stub } => {
write!(f, "stub {stub:?} is incompatible with rule {rule:?}")
}
}
}
}
impl std::error::Error for ScheduleError {}
#[must_use]
pub fn third_wednesday(year: i32, month: u32) -> Date {
let first = Date::new(year, month, 1).expect("first of month is valid");
let offset = (3 + 7 - first.weekday().number()) % 7;
first.add_days(offset as i32 + 14)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Schedule {
dates: Vec<Date>,
}
impl Schedule {
#[must_use]
pub fn builder(effective: Date, termination: Date, tenor: Period) -> ScheduleBuilder {
ScheduleBuilder::new(effective, termination, tenor)
}
#[must_use]
pub fn dates(&self) -> &[Date] {
&self.dates
}
#[must_use]
pub fn len(&self) -> usize {
self.dates.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.dates.is_empty()
}
#[must_use]
pub fn effective_date(&self) -> Date {
self.dates[0]
}
#[must_use]
pub fn termination_date(&self) -> Date {
self.dates[self.dates.len() - 1]
}
pub fn iter(&self) -> std::slice::Iter<'_, Date> {
self.dates.iter()
}
}
impl Index<usize> for Schedule {
type Output = Date;
fn index(&self, i: usize) -> &Date {
&self.dates[i]
}
}
impl<'a> IntoIterator for &'a Schedule {
type Item = &'a Date;
type IntoIter = std::slice::Iter<'a, Date>;
fn into_iter(self) -> Self::IntoIter {
self.dates.iter()
}
}
pub struct ScheduleBuilder {
effective: Date,
termination: Date,
tenor: Period,
calendar: Box<dyn Calendar>,
convention: BusinessDayConvention,
termination_convention: BusinessDayConvention,
end_of_month: bool,
rule: DateGeneration,
stub: Option<StubConvention>,
}
impl ScheduleBuilder {
fn new(effective: Date, termination: Date, tenor: Period) -> Self {
Self {
effective,
termination,
tenor,
calendar: Box::new(WeekendsOnly),
convention: BusinessDayConvention::ModifiedFollowing,
termination_convention: BusinessDayConvention::ModifiedFollowing,
end_of_month: false,
rule: DateGeneration::Backward,
stub: None,
}
}
#[must_use]
pub fn calendar(mut self, calendar: Box<dyn Calendar>) -> Self {
self.calendar = calendar;
self
}
#[must_use]
pub fn convention(mut self, convention: BusinessDayConvention) -> Self {
self.convention = convention;
self
}
#[must_use]
pub fn termination_convention(mut self, convention: BusinessDayConvention) -> Self {
self.termination_convention = convention;
self
}
#[must_use]
pub fn end_of_month(mut self, eom: bool) -> Self {
self.end_of_month = eom;
self
}
#[must_use]
pub fn rule(mut self, rule: DateGeneration) -> Self {
self.rule = rule;
self
}
#[must_use]
pub fn stub(mut self, stub: StubConvention) -> Self {
self.stub = Some(stub);
self
}
pub fn build(self) -> Result<Schedule, ScheduleError> {
if self.termination <= self.effective {
return Err(ScheduleError::EmptyRange {
effective: self.effective,
termination: self.termination,
});
}
if self.rule == DateGeneration::Zero {
return Ok(self.adjust_and_finish(vec![self.effective, self.termination]));
}
if self.tenor.num <= 0 {
return Err(ScheduleError::NonPositiveTenor(self.tenor.num));
}
let forward = self.rule == DateGeneration::Forward;
let stub = self.resolve_stub(forward)?;
let mut unadjusted = if forward {
self.generate_forward(stub)
} else {
self.generate_backward(stub)
};
if self.rule == DateGeneration::ThirdWednesday {
for date in &mut unadjusted {
*date = third_wednesday(date.year(), date.month());
}
}
Ok(self.adjust_and_finish(unadjusted))
}
fn resolve_stub(&self, forward: bool) -> Result<StubConvention, ScheduleError> {
match self.stub {
None => Ok(if forward {
StubConvention::ShortBack
} else {
StubConvention::ShortFront
}),
Some(s) => {
let ok = matches!(
(forward, s),
(true, StubConvention::ShortBack | StubConvention::LongBack)
| (
false,
StubConvention::ShortFront | StubConvention::LongFront
)
);
if ok {
Ok(s)
} else {
Err(ScheduleError::StubDirectionMismatch {
rule: self.rule,
stub: s,
})
}
}
}
}
fn seed(&self, anchor: Date, mult: i32) -> Date {
let shifted = anchor.add_period(Period {
num: self.tenor.num * mult,
unit: self.tenor.unit,
});
if self.end_of_month
&& matches!(self.tenor.unit, Unit::Months | Unit::Years)
&& anchor.is_end_of_month()
{
shifted.end_of_month()
} else {
shifted
}
}
fn generate_backward(&self, stub: StubConvention) -> Vec<Date> {
let mut tmp = Vec::new();
let mut i = 0;
loop {
let d = self.seed(self.termination, -i);
if d < self.effective {
break;
}
tmp.push(d);
if d == self.effective {
break;
}
i += 1;
}
let exact = tmp.last() == Some(&self.effective);
if !exact {
if stub == StubConvention::LongFront && tmp.len() >= 2 {
tmp.pop(); }
tmp.push(self.effective);
}
tmp.reverse();
tmp
}
fn generate_forward(&self, stub: StubConvention) -> Vec<Date> {
let mut tmp = Vec::new();
let mut i = 0;
loop {
let d = self.seed(self.effective, i);
if d > self.termination {
break;
}
tmp.push(d);
if d == self.termination {
break;
}
i += 1;
}
let exact = tmp.last() == Some(&self.termination);
if !exact {
if stub == StubConvention::LongBack && tmp.len() >= 2 {
tmp.pop(); }
tmp.push(self.termination);
}
tmp
}
fn adjust_and_finish(&self, unadjusted: Vec<Date>) -> Schedule {
let n = unadjusted.len();
let mut dates: Vec<Date> = unadjusted
.into_iter()
.enumerate()
.map(|(idx, d)| {
let conv = if idx == n - 1 {
self.termination_convention
} else {
self.convention
};
self.calendar.adjust(d, conv)
})
.collect();
dates.dedup();
Schedule { dates }
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::calendar::Brazil;
fn d(y: i32, m: u32, day: u32) -> Date {
Date::new(y, m, day).unwrap()
}
fn unadjusted_builder(eff: Date, term: Date, tenor: Period) -> ScheduleBuilder {
Schedule::builder(eff, term, tenor)
.convention(BusinessDayConvention::Unadjusted)
.termination_convention(BusinessDayConvention::Unadjusted)
}
#[test]
fn third_wednesday_known() {
assert_eq!(third_wednesday(2025, 3), d(2025, 3, 19));
assert_eq!(third_wednesday(2025, 12), d(2025, 12, 17));
}
#[test]
fn backward_even_periods_no_stub() {
let s = unadjusted_builder(d(2024, 1, 15), d(2025, 1, 15), Period::months(6))
.build()
.unwrap();
assert_eq!(s.dates(), &[d(2024, 1, 15), d(2024, 7, 15), d(2025, 1, 15)]);
assert_eq!(s.len(), 3);
assert_eq!(s.effective_date(), d(2024, 1, 15));
assert_eq!(s.termination_date(), d(2025, 1, 15));
}
#[test]
fn backward_short_front_stub() {
let s = unadjusted_builder(d(2024, 2, 10), d(2026, 1, 15), Period::months(6))
.build()
.unwrap();
assert_eq!(
s.dates(),
&[
d(2024, 2, 10), d(2024, 7, 15),
d(2025, 1, 15),
d(2025, 7, 15),
d(2026, 1, 15),
]
);
}
#[test]
fn backward_long_front_stub() {
let s = unadjusted_builder(d(2024, 2, 10), d(2026, 1, 15), Period::months(6))
.stub(StubConvention::LongFront)
.build()
.unwrap();
assert_eq!(
s.dates(),
&[
d(2024, 2, 10),
d(2025, 1, 15),
d(2025, 7, 15),
d(2026, 1, 15),
]
);
}
#[test]
fn forward_short_back_stub() {
let s = unadjusted_builder(d(2024, 1, 15), d(2025, 4, 10), Period::months(6))
.rule(DateGeneration::Forward)
.build()
.unwrap();
assert_eq!(
s.dates(),
&[
d(2024, 1, 15),
d(2024, 7, 15),
d(2025, 1, 15),
d(2025, 4, 10), ]
);
}
#[test]
fn forward_long_back_stub() {
let s = unadjusted_builder(d(2024, 1, 15), d(2025, 4, 10), Period::months(6))
.rule(DateGeneration::Forward)
.stub(StubConvention::LongBack)
.build()
.unwrap();
assert_eq!(s.dates(), &[d(2024, 1, 15), d(2024, 7, 15), d(2025, 4, 10)]);
}
#[test]
fn zero_rule_is_endpoints_only() {
let s = unadjusted_builder(d(2024, 1, 15), d(2034, 1, 15), Period::months(6))
.rule(DateGeneration::Zero)
.build()
.unwrap();
assert_eq!(s.dates(), &[d(2024, 1, 15), d(2034, 1, 15)]);
}
#[test]
fn end_of_month_rolling() {
let s = unadjusted_builder(d(2024, 1, 31), d(2024, 7, 31), Period::months(1))
.end_of_month(true)
.build()
.unwrap();
assert_eq!(
s.dates(),
&[
d(2024, 1, 31),
d(2024, 2, 29), d(2024, 3, 31),
d(2024, 4, 30),
d(2024, 5, 31),
d(2024, 6, 30),
d(2024, 7, 31),
]
);
}
#[test]
fn third_wednesday_rule_snaps_dates() {
let s = unadjusted_builder(d(2025, 3, 19), d(2025, 12, 17), Period::months(3))
.rule(DateGeneration::ThirdWednesday)
.build()
.unwrap();
assert_eq!(
s.dates(),
&[
d(2025, 3, 19),
d(2025, 6, 18),
d(2025, 9, 17),
d(2025, 12, 17),
]
);
}
#[test]
fn adjustment_moves_dates_to_business_days() {
let s = Schedule::builder(d(2024, 1, 13), d(2024, 7, 13), Period::months(3))
.calendar(Box::new(Brazil))
.convention(BusinessDayConvention::Following)
.build()
.unwrap();
for &date in s.dates() {
assert!(Brazil.is_business_day(date), "{date} should be adjusted");
}
}
#[test]
fn rejects_empty_range() {
let err = unadjusted_builder(d(2025, 1, 15), d(2025, 1, 15), Period::months(6))
.build()
.unwrap_err();
assert!(matches!(err, ScheduleError::EmptyRange { .. }));
}
#[test]
fn rejects_non_positive_tenor() {
let err = unadjusted_builder(d(2024, 1, 15), d(2025, 1, 15), Period::months(0))
.build()
.unwrap_err();
assert!(matches!(err, ScheduleError::NonPositiveTenor(0)));
}
#[test]
fn rejects_stub_direction_mismatch() {
let err = unadjusted_builder(d(2024, 1, 15), d(2025, 1, 15), Period::months(6))
.rule(DateGeneration::Backward)
.stub(StubConvention::ShortBack)
.build()
.unwrap_err();
assert!(matches!(err, ScheduleError::StubDirectionMismatch { .. }));
}
#[test]
fn iteration_and_indexing() {
let s = unadjusted_builder(d(2024, 1, 15), d(2025, 1, 15), Period::months(6))
.build()
.unwrap();
assert_eq!(s[0], d(2024, 1, 15));
let collected: Vec<Date> = s.iter().copied().collect();
assert_eq!(collected, s.dates().to_vec());
assert_eq!((&s).into_iter().count(), 3);
}
}