use chrono::{Datelike, Duration, Months, NaiveDate, Weekday};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use crate::core::errors::RustyQLibError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BusinessDayConvention {
Unadjusted,
#[default]
Following,
ModifiedFollowing,
Preceding,
ModifiedPreceding,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Calendar {
WeekendsOnly,
Target,
UsNyse,
UkSettlement,
Custom { holidays: BTreeSet<NaiveDate> },
}
impl Calendar {
pub fn is_weekend(date: NaiveDate) -> bool {
matches!(date.weekday(), Weekday::Sat | Weekday::Sun)
}
pub fn is_holiday(&self, date: NaiveDate) -> bool {
if Self::is_weekend(date) {
return false;
}
match self {
Calendar::WeekendsOnly => false,
Calendar::Target => is_target_holiday(date),
Calendar::UsNyse => is_nyse_holiday(date),
Calendar::UkSettlement => is_uk_holiday(date),
Calendar::Custom { holidays } => holidays.contains(&date),
}
}
pub fn is_business_day(&self, date: NaiveDate) -> bool {
!Self::is_weekend(date) && !self.is_holiday(date)
}
pub fn adjust(&self, date: NaiveDate, convention: BusinessDayConvention) -> NaiveDate {
use BusinessDayConvention::*;
if convention == Unadjusted || self.is_business_day(date) {
return date;
}
match convention {
Following => self.next_business_day(date),
Preceding => self.previous_business_day(date),
ModifiedFollowing => {
let next = self.next_business_day(date);
if next.month() != date.month() {
self.previous_business_day(date)
} else {
next
}
}
ModifiedPreceding => {
let prev = self.previous_business_day(date);
if prev.month() != date.month() {
self.next_business_day(date)
} else {
prev
}
}
Unadjusted => date,
}
}
fn next_business_day(&self, mut date: NaiveDate) -> NaiveDate {
while !self.is_business_day(date) {
date += Duration::days(1);
}
date
}
fn previous_business_day(&self, mut date: NaiveDate) -> NaiveDate {
while !self.is_business_day(date) {
date -= Duration::days(1);
}
date
}
pub fn add_business_days(&self, date: NaiveDate, n: i64) -> NaiveDate {
let mut d = date;
let step = if n >= 0 { 1 } else { -1 };
let mut remaining = n.abs();
while remaining > 0 {
d += Duration::days(step);
if self.is_business_day(d) {
remaining -= 1;
}
}
d
}
pub fn advance(
&self,
date: NaiveDate,
period: Period,
convention: BusinessDayConvention,
) -> NaiveDate {
let moved = match period {
Period::Days(n) => return self.add_business_days(date, n),
Period::Weeks(n) => date + Duration::weeks(n),
Period::Months(n) => add_months_signed(date, n),
Period::Years(n) => add_months_signed(date, 12 * n),
};
self.adjust(moved, convention)
}
pub fn business_days_between(&self, from: NaiveDate, to: NaiveDate) -> i64 {
if to < from {
return -self.business_days_between(to, from);
}
let mut count = 0;
let mut d = from;
while d < to {
d += Duration::days(1);
if self.is_business_day(d) {
count += 1;
}
}
count
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Period {
Days(i64),
Weeks(i64),
Months(i32),
Years(i32),
}
fn add_months_signed(date: NaiveDate, n: i32) -> NaiveDate {
if n >= 0 {
date + Months::new(n as u32)
} else {
date - Months::new((-n) as u32)
}
}
pub fn easter_sunday(year: i32) -> NaiveDate {
let a = year % 19;
let b = year / 100;
let c = year % 100;
let d = b / 4;
let e = b % 4;
let f = (b + 8) / 25;
let g = (b - f + 1) / 3;
let h = (19 * a + b - d - g + 15) % 30;
let i = c / 4;
let k = c % 4;
let l = (32 + 2 * e + 2 * i - h - k) % 7;
let m = (a + 11 * h + 22 * l) / 451;
let month = (h + l - 7 * m + 114) / 31;
let day = ((h + l - 7 * m + 114) % 31) + 1;
NaiveDate::from_ymd_opt(year, month as u32, day as u32).expect("valid Easter date")
}
fn nth_weekday(year: i32, month: u32, weekday: Weekday, n: u32) -> NaiveDate {
let first = NaiveDate::from_ymd_opt(year, month, 1).expect("valid month start");
let offset = (7 + weekday.num_days_from_monday() as i64
- first.weekday().num_days_from_monday() as i64)
% 7;
first + Duration::days(offset + 7 * (n as i64 - 1))
}
fn last_weekday(year: i32, month: u32, weekday: Weekday) -> NaiveDate {
let first_next = if month == 12 {
NaiveDate::from_ymd_opt(year + 1, 1, 1)
} else {
NaiveDate::from_ymd_opt(year, month + 1, 1)
}
.expect("valid month start");
let last = first_next - Duration::days(1);
let offset = (7 + last.weekday().num_days_from_monday() as i64
- weekday.num_days_from_monday() as i64)
% 7;
last - Duration::days(offset)
}
fn is_target_holiday(date: NaiveDate) -> bool {
let (y, m, d) = (date.year(), date.month(), date.day());
if (m == 1 && d == 1) || (m == 5 && d == 1) || (m == 12 && d == 25) {
return true;
}
if m == 12 && d == 26 && y >= 2000 {
return true;
}
let easter = easter_sunday(y);
date == easter - Duration::days(2) || date == easter + Duration::days(1)
}
fn is_nyse_holiday(date: NaiveDate) -> bool {
let (y, m, d) = (date.year(), date.month(), date.day());
let wd = date.weekday();
if m == 1 && (d == 1 || (d == 2 && wd == Weekday::Mon)) {
return true;
}
if y >= 1998 && m == 1 && date == nth_weekday(y, 1, Weekday::Mon, 3) {
return true;
}
if m == 2 && date == nth_weekday(y, 2, Weekday::Mon, 3) {
return true;
}
if date == easter_sunday(y) - Duration::days(2) {
return true;
}
if m == 5 && date == last_weekday(y, 5, Weekday::Mon) {
return true;
}
if y >= 2022 && observed_on(date, 6, 19) {
return true;
}
if observed_on(date, 7, 4) {
return true;
}
if m == 9 && date == nth_weekday(y, 9, Weekday::Mon, 1) {
return true;
}
if m == 11 && date == nth_weekday(y, 11, Weekday::Thu, 4) {
return true;
}
if observed_on(date, 12, 25) {
return true;
}
false
}
fn observed_on(date: NaiveDate, month: u32, day: u32) -> bool {
let holiday = match NaiveDate::from_ymd_opt(date.year(), month, day) {
Some(d) => d,
None => return false,
};
let observed = match holiday.weekday() {
Weekday::Sat => holiday - Duration::days(1),
Weekday::Sun => holiday + Duration::days(1),
_ => holiday,
};
date == observed
}
fn is_uk_holiday(date: NaiveDate) -> bool {
let (y, m, d) = (date.year(), date.month(), date.day());
let wd = date.weekday();
if m == 1
&& (d == 1
|| (d == 2 && wd == Weekday::Mon)
|| (d == 3 && wd == Weekday::Mon))
{
return true;
}
let easter = easter_sunday(y);
if date == easter - Duration::days(2) || date == easter + Duration::days(1) {
return true;
}
if m == 5 && date == nth_weekday(y, 5, Weekday::Mon, 1) {
return true;
}
if m == 5 && date == last_weekday(y, 5, Weekday::Mon) {
return true;
}
if m == 8 && date == last_weekday(y, 8, Weekday::Mon) {
return true;
}
if m == 12 {
let christmas = NaiveDate::from_ymd_opt(y, 12, 25).expect("valid date");
let (obs_christmas, obs_boxing) = match christmas.weekday() {
Weekday::Fri => (25, 28), Weekday::Sat => (27, 28), Weekday::Sun => (27, 28), _ => (25, 26),
};
if christmas.weekday() == Weekday::Sun {
return d == 26 || d == 27;
}
return d == obs_christmas || d == obs_boxing;
}
false
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DateGeneration {
#[default]
Backward,
Forward,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Schedule {
pub dates: Vec<NaiveDate>,
}
impl Schedule {
pub fn generate(
effective: NaiveDate,
termination: NaiveDate,
months: u32,
calendar: &Calendar,
convention: BusinessDayConvention,
generation: DateGeneration,
) -> Result<Schedule, RustyQLibError> {
if months == 0 {
return Err(RustyQLibError::invalid_input(
"schedule",
"the period must be at least one month",
));
}
if termination <= effective {
return Err(RustyQLibError::invalid_input(
"schedule",
format!("termination {termination} must be after the effective date {effective}"),
));
}
let mut anchors: Vec<NaiveDate> = Vec::new();
match generation {
DateGeneration::Backward => {
let mut k = 0u32;
loop {
k += months;
let date = termination - Months::new(k);
if date <= effective {
break;
}
anchors.push(date);
}
anchors.reverse();
anchors.push(termination);
}
DateGeneration::Forward => {
let mut k = 0u32;
loop {
k += months;
let date = effective + Months::new(k);
if date >= termination {
break;
}
anchors.push(date);
}
anchors.push(termination);
}
}
let mut dates: Vec<NaiveDate> = anchors
.into_iter()
.map(|d| calendar.adjust(d, convention))
.collect();
dates.dedup();
let last = *dates.last().expect("schedule has at least one date");
dates.retain(|d| *d <= last);
dates.dedup();
Ok(Schedule { dates })
}
pub fn year_fractions(
&self,
valuation: NaiveDate,
day_count: crate::core::daycount::DayCountConvention,
) -> Vec<f64> {
self.dates
.iter()
.map(|d| day_count.year_fraction(valuation, *d))
.collect()
}
pub fn len(&self) -> usize {
self.dates.len()
}
pub fn is_empty(&self) -> bool {
self.dates.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
#[test]
fn easter_matches_known_years() {
assert_eq!(easter_sunday(2024), d(2024, 3, 31));
assert_eq!(easter_sunday(2025), d(2025, 4, 20));
assert_eq!(easter_sunday(2026), d(2026, 4, 5));
assert_eq!(easter_sunday(2027), d(2027, 3, 28));
assert_eq!(easter_sunday(2030), d(2030, 4, 21));
}
#[test]
fn nyse_holidays_2026() {
let c = Calendar::UsNyse;
for holiday in [
d(2026, 1, 1), d(2026, 1, 19), d(2026, 2, 16), d(2026, 4, 3), d(2026, 5, 25), d(2026, 6, 19), d(2026, 7, 3), d(2026, 9, 7), d(2026, 11, 26), d(2026, 12, 25), ] {
assert!(!c.is_business_day(holiday), "{holiday} must be a holiday");
}
for business in [d(2026, 1, 2), d(2026, 4, 6), d(2026, 7, 6), d(2026, 11, 27)] {
assert!(c.is_business_day(business), "{business} must be a business day");
}
}
#[test]
fn nyse_sunday_new_year_observed_on_monday() {
assert!(!Calendar::UsNyse.is_business_day(d(2023, 1, 2)));
assert!(Calendar::UsNyse.is_business_day(d(2021, 12, 31)));
}
#[test]
fn target_holidays() {
let c = Calendar::Target;
for holiday in [
d(2026, 1, 1),
d(2026, 4, 3), d(2026, 4, 6), d(2026, 5, 1), d(2026, 12, 25),
d(2025, 12, 26),
] {
assert!(!c.is_business_day(holiday), "{holiday} must be a holiday");
}
assert!(c.is_business_day(d(2026, 5, 25)), "no TARGET holiday on UK spring bank");
}
#[test]
fn uk_holidays_2026() {
let c = Calendar::UkSettlement;
for holiday in [
d(2026, 1, 1),
d(2026, 4, 3), d(2026, 4, 6), d(2026, 5, 4), d(2026, 5, 25), d(2026, 8, 31), d(2026, 12, 25),
d(2026, 12, 28), ] {
assert!(!c.is_business_day(holiday), "{holiday} must be a holiday");
}
assert!(!c.is_business_day(d(2021, 12, 27)));
assert!(!c.is_business_day(d(2021, 12, 28)));
assert!(c.is_business_day(d(2021, 12, 29)));
}
#[test]
fn adjust_conventions() {
use BusinessDayConvention::*;
let c = Calendar::WeekendsOnly;
let saturday = d(2026, 5, 30);
assert_eq!(c.adjust(saturday, Unadjusted), saturday);
assert_eq!(c.adjust(saturday, Following), d(2026, 6, 1));
assert_eq!(c.adjust(saturday, Preceding), d(2026, 5, 29));
let sunday_eom = d(2026, 5, 31);
assert_eq!(c.adjust(sunday_eom, Following), d(2026, 6, 1));
assert_eq!(c.adjust(sunday_eom, ModifiedFollowing), d(2026, 5, 29));
let sunday_som = d(2026, 11, 1);
assert_eq!(c.adjust(sunday_som, Preceding), d(2026, 10, 30));
assert_eq!(c.adjust(sunday_som, ModifiedPreceding), d(2026, 11, 2));
}
#[test]
fn business_day_arithmetic_and_settlement_lag() {
let c = Calendar::UsNyse;
assert_eq!(c.add_business_days(d(2026, 4, 1), 2), d(2026, 4, 6));
assert_eq!(c.add_business_days(d(2026, 4, 6), -1), d(2026, 4, 2));
assert_eq!(c.business_days_between(d(2026, 4, 1), d(2026, 4, 6)), 2);
assert_eq!(c.business_days_between(d(2026, 4, 6), d(2026, 4, 1)), -2);
}
#[test]
fn advance_periods_clamp_month_ends() {
let c = Calendar::WeekendsOnly;
assert_eq!(
c.advance(d(2026, 1, 31), Period::Months(1), BusinessDayConvention::Following),
d(2026, 3, 2)
);
assert_eq!(
c.advance(d(2026, 1, 31), Period::Months(1), BusinessDayConvention::ModifiedFollowing),
d(2026, 2, 27)
);
assert_eq!(
c.advance(d(2026, 3, 15), Period::Years(1), BusinessDayConvention::Following),
d(2027, 3, 15)
);
}
#[test]
fn custom_calendar_takes_explicit_holidays() {
let holidays: BTreeSet<NaiveDate> = [d(2026, 3, 17)].into();
let c = Calendar::Custom { holidays };
assert!(!c.is_business_day(d(2026, 3, 17)));
assert!(c.is_business_day(d(2026, 3, 18)));
}
#[test]
fn quarterly_backward_schedule_with_front_stub() {
let s = Schedule::generate(
d(2026, 1, 15),
d(2026, 11, 16),
3,
&Calendar::WeekendsOnly,
BusinessDayConvention::Following,
DateGeneration::Backward,
)
.unwrap();
assert_eq!(
s.dates,
vec![d(2026, 2, 16), d(2026, 5, 18), d(2026, 8, 17), d(2026, 11, 16)]
);
}
#[test]
fn forward_schedule_puts_stub_at_the_back() {
let s = Schedule::generate(
d(2026, 1, 15),
d(2026, 11, 16),
3,
&Calendar::WeekendsOnly,
BusinessDayConvention::Following,
DateGeneration::Forward,
)
.unwrap();
assert_eq!(
s.dates,
vec![d(2026, 4, 15), d(2026, 7, 15), d(2026, 10, 15), d(2026, 11, 16)]
);
}
#[test]
fn schedule_dates_avoid_holidays() {
let s = Schedule::generate(
d(2026, 1, 5),
d(2026, 6, 3),
1,
&Calendar::UsNyse,
BusinessDayConvention::Following,
DateGeneration::Backward,
)
.unwrap();
for date in &s.dates {
assert!(
Calendar::UsNyse.is_business_day(*date),
"{date} is not a business day"
);
}
assert!(s.dates.contains(&d(2026, 4, 6)));
}
#[test]
fn schedule_rejects_bad_inputs() {
let r = Schedule::generate(
d(2026, 5, 1),
d(2026, 1, 1),
3,
&Calendar::WeekendsOnly,
BusinessDayConvention::Following,
DateGeneration::Backward,
);
assert!(r.is_err());
let r = Schedule::generate(
d(2026, 1, 1),
d(2026, 5, 1),
0,
&Calendar::WeekendsOnly,
BusinessDayConvention::Following,
DateGeneration::Backward,
);
assert!(r.is_err());
}
}