use crate::date::Date;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum BusinessDayConvention {
Unadjusted,
Following,
ModifiedFollowing,
Preceding,
ModifiedPreceding,
}
pub trait Calendar {
fn name(&self) -> &'static str;
fn is_business_day(&self, date: Date) -> bool;
fn is_holiday(&self, date: Date) -> bool {
!date.is_weekend() && !self.is_business_day(date)
}
fn adjust(&self, date: Date, conv: BusinessDayConvention) -> Date {
match conv {
BusinessDayConvention::Unadjusted => date,
BusinessDayConvention::Following => self.roll(date, 1),
BusinessDayConvention::Preceding => self.roll(date, -1),
BusinessDayConvention::ModifiedFollowing => {
let rolled = self.roll(date, 1);
if rolled.month() != date.month() {
self.roll(date, -1)
} else {
rolled
}
}
BusinessDayConvention::ModifiedPreceding => {
let rolled = self.roll(date, -1);
if rolled.month() != date.month() {
self.roll(date, 1)
} else {
rolled
}
}
}
}
fn advance(&self, date: Date, n: i32) -> Date {
if n == 0 {
return date;
}
let step = if n > 0 { 1 } else { -1 };
let mut remaining = n.abs();
let mut d = date;
while remaining > 0 {
d = d.add_days(step);
if self.is_business_day(d) {
remaining -= 1;
}
}
d
}
fn business_days_between(&self, start: Date, end: Date) -> i32 {
if start == end {
return 0;
}
if end < start {
return -self.business_days_between(end, start);
}
let mut count = 0;
let mut d = start;
while d < end {
if self.is_business_day(d) {
count += 1;
}
d = d.add_days(1);
}
count
}
fn year_fraction_252(&self, start: Date, end: Date) -> f64 {
f64::from(self.business_days_between(start, end)) / 252.0
}
#[doc(hidden)]
fn roll(&self, date: Date, step: i32) -> Date {
let mut d = date;
while !self.is_business_day(d) {
d = d.add_days(step);
}
d
}
}
#[must_use]
pub fn easter(year: i32) -> Date {
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;
Date::new(year, month as u32, day as u32).expect("computus yields a valid date")
}
#[derive(Debug, Clone, Copy, Default)]
pub struct WeekendsOnly;
impl Calendar for WeekendsOnly {
fn name(&self) -> &'static str {
"WeekendsOnly"
}
fn is_business_day(&self, date: Date) -> bool {
!date.is_weekend()
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Brazil;
impl Brazil {
fn is_named_holiday(date: Date) -> bool {
let (y, m, d) = date.ymd();
if matches!(
(m, d),
(1, 1) | (4, 21) | (5, 1) | (9, 7) | (10, 12) | (11, 2) | (11, 15) | (12, 25)
) {
return true;
}
if m == 11 && d == 20 && y >= 2024 {
return true;
}
let easter = easter(y);
let s = date.serial();
s == easter.add_days(-48).serial() || s == easter.add_days(-47).serial() || s == easter.add_days(-2).serial() || s == easter.add_days(60).serial() }
}
impl Calendar for Brazil {
fn name(&self) -> &'static str {
"Brazil"
}
fn is_business_day(&self, date: Date) -> bool {
!date.is_weekend() && !Self::is_named_holiday(date)
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Target2;
impl Target2 {
fn is_named_holiday(date: Date) -> bool {
let (_, m, d) = date.ymd();
if matches!((m, d), (1, 1) | (5, 1) | (12, 25) | (12, 26)) {
return true;
}
let easter = easter(date.year());
let s = date.serial();
s == easter.add_days(-2).serial() || s == easter.add_days(1).serial() }
}
impl Calendar for Target2 {
fn name(&self) -> &'static str {
"TARGET2"
}
fn is_business_day(&self, date: Date) -> bool {
!date.is_weekend() && !Self::is_named_holiday(date)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum JoinRule {
JoinHolidays,
JoinBusinessDays,
}
pub struct JoinCalendar {
calendars: Vec<Box<dyn Calendar>>,
rule: JoinRule,
}
impl JoinCalendar {
#[must_use]
pub fn new(calendars: Vec<Box<dyn Calendar>>, rule: JoinRule) -> Self {
Self { calendars, rule }
}
}
impl Calendar for JoinCalendar {
fn name(&self) -> &'static str {
"Joint"
}
fn is_business_day(&self, date: Date) -> bool {
match self.rule {
JoinRule::JoinHolidays => self.calendars.iter().all(|c| c.is_business_day(date)),
JoinRule::JoinBusinessDays => self.calendars.iter().any(|c| c.is_business_day(date)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn d(y: i32, m: u32, day: u32) -> Date {
Date::new(y, m, day).unwrap()
}
#[test]
fn easter_known_values() {
assert_eq!(easter(2024), d(2024, 3, 31));
assert_eq!(easter(2025), d(2025, 4, 20));
assert_eq!(easter(2000), d(2000, 4, 23));
assert_eq!(easter(2027), d(2027, 3, 28));
}
#[test]
fn weekends_only_marks_weekends() {
let cal = WeekendsOnly;
assert!(!cal.is_business_day(d(2025, 1, 4))); assert!(!cal.is_business_day(d(2025, 1, 5))); assert!(cal.is_business_day(d(2025, 1, 6))); assert!(cal.is_business_day(d(2025, 1, 1))); }
#[test]
fn brazil_fixed_holidays() {
let cal = Brazil;
assert!(!cal.is_business_day(d(2025, 1, 1))); assert!(!cal.is_business_day(d(2025, 4, 21))); assert!(cal.is_holiday(d(2025, 4, 21)));
assert!(cal.is_business_day(d(2025, 1, 2))); }
#[test]
fn brazil_moving_holidays_2025() {
let cal = Brazil;
assert!(!cal.is_business_day(d(2025, 3, 3))); assert!(!cal.is_business_day(d(2025, 3, 4))); assert!(!cal.is_business_day(d(2025, 4, 18))); assert!(!cal.is_business_day(d(2025, 6, 19))); }
#[test]
fn brazil_black_awareness_from_2024() {
let cal = Brazil;
assert!(cal.is_business_day(d(2023, 11, 20)));
assert!(!cal.is_business_day(d(2024, 11, 20)));
}
#[test]
fn target2_holidays_2025() {
let cal = Target2;
assert!(!cal.is_business_day(d(2025, 1, 1)));
assert!(!cal.is_business_day(d(2025, 4, 18))); assert!(!cal.is_business_day(d(2025, 4, 21))); assert!(!cal.is_business_day(d(2025, 5, 1)));
assert!(!cal.is_business_day(d(2025, 12, 26)));
assert!(cal.is_business_day(d(2025, 12, 24))); }
#[test]
fn following_rolls_forward() {
let cal = WeekendsOnly;
let adj = cal.adjust(d(2025, 1, 4), BusinessDayConvention::Following);
assert_eq!(adj, d(2025, 1, 6));
}
#[test]
fn preceding_rolls_backward() {
let cal = WeekendsOnly;
let adj = cal.adjust(d(2025, 1, 5), BusinessDayConvention::Preceding);
assert_eq!(adj, d(2025, 1, 3));
}
#[test]
fn modified_following_stays_in_month() {
let cal = WeekendsOnly;
let saturday = d(2025, 5, 31);
assert_eq!(
cal.adjust(saturday, BusinessDayConvention::Following),
d(2025, 6, 2)
);
let mf = cal.adjust(saturday, BusinessDayConvention::ModifiedFollowing);
assert_eq!(mf, d(2025, 5, 30));
assert_eq!(mf.month(), 5);
}
#[test]
fn modified_preceding_stays_in_month() {
let cal = WeekendsOnly;
let sunday = d(2025, 6, 1);
assert_eq!(
cal.adjust(sunday, BusinessDayConvention::Preceding),
d(2025, 5, 30)
);
let mp = cal.adjust(sunday, BusinessDayConvention::ModifiedPreceding);
assert_eq!(mp, d(2025, 6, 2));
assert_eq!(mp.month(), 6);
}
#[test]
fn unadjusted_is_identity() {
let cal = Brazil;
let holiday = d(2025, 1, 1);
assert_eq!(
cal.adjust(holiday, BusinessDayConvention::Unadjusted),
holiday
);
}
#[test]
fn adjust_business_day_unchanged() {
let cal = WeekendsOnly;
let wed = d(2025, 1, 8);
assert_eq!(cal.adjust(wed, BusinessDayConvention::Following), wed);
assert_eq!(cal.adjust(wed, BusinessDayConvention::Preceding), wed);
}
#[test]
fn advance_business_days() {
let cal = WeekendsOnly;
assert_eq!(cal.advance(d(2025, 1, 3), 1), d(2025, 1, 6));
assert_eq!(cal.advance(d(2025, 1, 6), -1), d(2025, 1, 3));
assert_eq!(cal.advance(d(2025, 1, 4), 0), d(2025, 1, 4));
}
#[test]
fn business_days_between_half_open() {
let cal = WeekendsOnly;
assert_eq!(cal.business_days_between(d(2025, 1, 6), d(2025, 1, 10)), 4);
assert_eq!(cal.business_days_between(d(2025, 1, 6), d(2025, 1, 6)), 0);
assert_eq!(cal.business_days_between(d(2025, 1, 10), d(2025, 1, 6)), -4);
}
#[test]
fn business_days_between_differs_by_calendar() {
let start = d(2025, 1, 1);
let end = d(2025, 1, 8);
assert_eq!(WeekendsOnly.business_days_between(start, end), 5);
assert_eq!(Brazil.business_days_between(start, end), 4);
}
#[test]
fn bus252_year_fraction() {
let cal = WeekendsOnly;
let yf = cal.year_fraction_252(d(2025, 1, 1), d(2025, 1, 8));
assert!((yf - 5.0 / 252.0).abs() < 1e-12);
}
#[test]
fn join_holidays_is_intersection_of_business_days() {
let joint = JoinCalendar::new(
vec![Box::new(Brazil), Box::new(Target2)],
JoinRule::JoinHolidays,
);
assert!(!joint.is_business_day(d(2025, 6, 19)));
assert!(!joint.is_business_day(d(2025, 12, 26)));
assert!(joint.is_business_day(d(2025, 1, 2)));
}
#[test]
fn join_business_days_is_union_of_business_days() {
let joint = JoinCalendar::new(
vec![Box::new(Brazil), Box::new(Target2)],
JoinRule::JoinBusinessDays,
);
assert!(joint.is_business_day(d(2025, 6, 19)));
assert!(joint.is_business_day(d(2025, 12, 26)));
assert!(!joint.is_business_day(d(2025, 1, 1)));
assert!(!joint.is_business_day(d(2025, 1, 4)));
}
}