use std::collections::BTreeSet;
use chrono::Datelike;
use chrono::Duration;
use chrono::NaiveDate;
use chrono::Weekday;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum HolidayCalendar {
UnitedStates,
UnitedKingdom,
Target,
Tokyo,
Hkex,
Asx,
Sgx,
B3,
}
impl std::fmt::Display for HolidayCalendar {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnitedStates => write!(f, "US"),
Self::UnitedKingdom => write!(f, "UK"),
Self::Target => write!(f, "TARGET"),
Self::Tokyo => write!(f, "Tokyo"),
Self::Hkex => write!(f, "HKEX"),
Self::Asx => write!(f, "ASX"),
Self::Sgx => write!(f, "SGX"),
Self::B3 => write!(f, "B3"),
}
}
}
impl HolidayCalendar {
fn is_holiday(self, date: NaiveDate) -> bool {
match self {
Self::UnitedStates => is_us_holiday(date),
Self::UnitedKingdom => is_uk_holiday(date),
Self::Target => is_target_holiday(date),
Self::Tokyo => is_tokyo_holiday(date),
Self::Hkex => is_hkex_holiday(date),
Self::Asx => is_asx_holiday(date),
Self::Sgx => is_sgx_holiday(date),
Self::B3 => is_b3_holiday(date),
}
}
}
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum JointMode {
#[default]
AnyHoliday,
AllHolidays,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Calendar {
calendars: Vec<HolidayCalendar>,
extra_holidays: BTreeSet<NaiveDate>,
mode: JointMode,
}
impl Calendar {
pub fn new(kind: HolidayCalendar) -> Self {
Self {
calendars: vec![kind],
extra_holidays: BTreeSet::new(),
mode: JointMode::AnyHoliday,
}
}
pub fn joint(calendars: impl IntoIterator<Item = HolidayCalendar>) -> Self {
Self::joint_with_mode(calendars, JointMode::AnyHoliday)
}
pub fn joint_with_mode(
calendars: impl IntoIterator<Item = HolidayCalendar>,
mode: JointMode,
) -> Self {
Self {
calendars: calendars.into_iter().collect(),
extra_holidays: BTreeSet::new(),
mode,
}
}
pub fn add_holiday(&mut self, date: NaiveDate) {
self.extra_holidays.insert(date);
}
pub fn remove_holiday(&mut self, date: NaiveDate) {
self.extra_holidays.remove(&date);
}
pub fn is_weekend(&self, date: NaiveDate) -> bool {
matches!(date.weekday(), Weekday::Sat | Weekday::Sun)
}
pub fn is_holiday(&self, date: NaiveDate) -> bool {
if self.extra_holidays.contains(&date) {
return true;
}
if self.calendars.is_empty() {
return false;
}
match self.mode {
JointMode::AnyHoliday => self.calendars.iter().any(|c| c.is_holiday(date)),
JointMode::AllHolidays => self.calendars.iter().all(|c| c.is_holiday(date)),
}
}
pub fn is_business_day(&self, date: NaiveDate) -> bool {
!self.is_weekend(date) && !self.is_holiday(date)
}
pub fn business_days_between(&self, d1: NaiveDate, d2: NaiveDate) -> i64 {
let step = if d2 >= d1 { 1 } else { -1 };
let delta = Duration::days(step);
let mut count: i64 = 0;
let mut d = d1;
while d != d2 {
if self.is_business_day(d) {
count += step;
}
d += delta;
}
count
}
pub fn advance(&self, mut date: NaiveDate, business_days: i32) -> NaiveDate {
let step = if business_days >= 0 { 1 } else { -1 };
let delta = Duration::days(step);
let mut remaining = business_days.unsigned_abs();
while remaining > 0 {
date += delta;
if self.is_business_day(date) {
remaining -= 1;
}
}
date
}
pub fn next_business_day(&self, date: NaiveDate) -> NaiveDate {
let mut d = date + Duration::days(1);
while !self.is_business_day(d) {
d += Duration::days(1);
}
d
}
pub fn previous_business_day(&self, date: NaiveDate) -> NaiveDate {
let mut d = date - Duration::days(1);
while !self.is_business_day(d) {
d -= Duration::days(1);
}
d
}
}
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).unwrap()
}
fn vernal_equinox_day(year: i32) -> u32 {
let y = year as f64;
let base = if year <= 1979 {
20.8357
} else if year <= 2099 {
20.8431
} else {
21.851
};
(base + 0.242194 * (y - 1980.0) - ((y - 1980.0) / 4.0).floor()) as u32
}
fn autumnal_equinox_day(year: i32) -> u32 {
let y = year as f64;
let base = if year <= 1979 {
23.2588
} else if year <= 2099 {
23.2488
} else {
24.2488
};
(base + 0.242194 * (y - 1980.0) - ((y - 1980.0) / 4.0).floor()) as u32
}
fn is_us_holiday(date: NaiveDate) -> bool {
let (y, m, d) = (date.year(), date.month(), date.day());
let w = date.weekday();
use Weekday::*;
if (m == 1 && d == 1 && w != Sat && w != Sun)
|| (m == 12 && d == 31 && w == Fri)
|| (m == 1 && d == 2 && w == Mon)
{
return true;
}
if y >= 1983 && m == 1 && w == Mon && (15..=21).contains(&d) {
return true;
}
if m == 2 && w == Mon && (15..=21).contains(&d) {
return true;
}
if m == 5 && w == Mon && d >= 25 {
return true;
}
if y >= 2021
&& ((m == 6 && d == 19 && w != Sat && w != Sun)
|| (m == 6 && d == 18 && w == Fri)
|| (m == 6 && d == 20 && w == Mon))
{
return true;
}
if (m == 7 && d == 4 && w != Sat && w != Sun)
|| (m == 7 && d == 3 && w == Fri)
|| (m == 7 && d == 5 && w == Mon)
{
return true;
}
if m == 9 && w == Mon && d <= 7 {
return true;
}
if m == 10 && w == Mon && (8..=14).contains(&d) {
return true;
}
if (m == 11 && d == 11 && w != Sat && w != Sun)
|| (m == 11 && d == 10 && w == Fri)
|| (m == 11 && d == 12 && w == Mon)
{
return true;
}
if m == 11 && w == Thu && (22..=28).contains(&d) {
return true;
}
if (m == 12 && d == 25 && w != Sat && w != Sun)
|| (m == 12 && d == 24 && w == Fri)
|| (m == 12 && d == 26 && w == Mon)
{
return true;
}
false
}
fn is_uk_holiday(date: NaiveDate) -> bool {
let (y, m, d) = (date.year(), date.month(), date.day());
let w = date.weekday();
use Weekday::*;
if (m == 1 && d == 1 && w != Sat && w != Sun)
|| (m == 1 && d == 2 && w == Mon)
|| (m == 1 && d == 3 && w == Mon)
{
return true;
}
let easter = easter_sunday(y);
if date == easter - Duration::days(2) {
return true;
}
if date == easter + Duration::days(1) {
return true;
}
if m == 5 && w == Mon && d <= 7 {
return true;
}
if m == 5 && w == Mon && d >= 25 {
return true;
}
if m == 8 && w == Mon && d >= 25 {
return true;
}
if (d == 25 || (d == 27 && matches!(w, Mon | Tue))) && m == 12 {
return true;
}
if (d == 26 || (d == 28 && matches!(w, Mon | Tue))) && m == 12 {
return true;
}
false
}
fn is_target_holiday(date: NaiveDate) -> bool {
let (y, m, d) = (date.year(), date.month(), date.day());
if m == 1 && d == 1 {
return true;
}
let easter = easter_sunday(y);
if date == easter - Duration::days(2) {
return true;
}
if date == easter + Duration::days(1) {
return true;
}
if m == 5 && d == 1 {
return true;
}
if m == 12 && d == 25 {
return true;
}
if m == 12 && d == 26 {
return true;
}
false
}
fn is_tokyo_holiday(date: NaiveDate) -> bool {
let (y, m, d) = (date.year(), date.month(), date.day());
let w = date.weekday();
use Weekday::*;
if m == 1 && d <= 3 {
return true;
}
if m == 1 && w == Mon && (8..=14).contains(&d) {
return true;
}
if is_observed_jp(date, m, d, 2, 11) {
return true;
}
if y >= 2020 && is_observed_jp(date, m, d, 2, 23) {
return true;
}
if (1989..=2018).contains(&y) && is_observed_jp(date, m, d, 12, 23) {
return true;
}
let ve = vernal_equinox_day(y);
if is_observed_jp(date, m, d, 3, ve) {
return true;
}
if is_observed_jp(date, m, d, 4, 29) {
return true;
}
if m == 5 && d == 3 && w != Sat && w != Sun {
return true;
}
if m == 5 && d == 4 && w != Sat && w != Sun {
return true;
}
if is_observed_jp(date, m, d, 5, 5) {
return true;
}
if m == 5 && d == 6 && matches!(w, Mon | Tue | Wed) {
return true;
}
if m == 7 && w == Mon && (15..=21).contains(&d) {
return true;
}
if y >= 2016 && is_observed_jp(date, m, d, 8, 11) {
return true;
}
if m == 9 && w == Mon && (15..=21).contains(&d) {
return true;
}
let ae = autumnal_equinox_day(y);
if is_observed_jp(date, m, d, 9, ae) {
return true;
}
if m == 9 && w == Tue {
let prev = date - Duration::days(1);
let next = date + Duration::days(1);
if prev.weekday() == Mon
&& (15..=21).contains(&prev.day())
&& next.month() == 9
&& next.day() == ae
{
return true;
}
}
if m == 10 && w == Mon && (8..=14).contains(&d) {
return true;
}
if is_observed_jp(date, m, d, 11, 3) {
return true;
}
if is_observed_jp(date, m, d, 11, 23) {
return true;
}
false
}
fn is_observed_jp(date: NaiveDate, m: u32, d: u32, hol_month: u32, hol_day: u32) -> bool {
if m == hol_month && d == hol_day && date.weekday() != Weekday::Sun {
return true;
}
if m == hol_month
&& d == hol_day + 1
&& date.weekday() == Weekday::Mon
&& NaiveDate::from_ymd_opt(date.year(), hol_month, hol_day)
.is_some_and(|h| h.weekday() == Weekday::Sun)
{
return true;
}
false
}
fn is_asx_holiday(date: NaiveDate) -> bool {
let (y, m, d) = (date.year(), date.month(), date.day());
let w = date.weekday();
use Weekday::*;
if (m == 1 && d == 1 && w != Sat && w != Sun)
|| (m == 1 && d == 2 && w == Mon)
|| (m == 1 && d == 3 && w == Mon)
{
return true;
}
if (m == 1 && d == 26 && w != Sat && w != Sun)
|| (m == 1 && d == 27 && w == Mon)
|| (m == 1 && d == 28 && w == Mon)
{
return true;
}
let easter = easter_sunday(y);
if date == easter - Duration::days(2) || date == easter + Duration::days(1) {
return true;
}
if (m == 4 && d == 25 && w != Sat && w != Sun)
|| (m == 4 && d == 26 && w == Mon)
|| (m == 4 && d == 27 && w == Mon)
{
return true;
}
if m == 6 && w == Mon && (8..=14).contains(&d) {
return true;
}
if (m == 12 && d == 25 && w != Sat && w != Sun) || (m == 12 && d == 27 && matches!(w, Mon | Tue))
{
return true;
}
if (m == 12 && d == 26 && w != Sat && w != Sun) || (m == 12 && d == 28 && matches!(w, Mon | Tue))
{
return true;
}
false
}
fn is_b3_holiday(date: NaiveDate) -> bool {
let (y, m, d) = (date.year(), date.month(), date.day());
if m == 1 && d == 1 {
return true;
}
let easter = easter_sunday(y);
if date == easter - Duration::days(48) || date == easter - Duration::days(47) {
return true;
}
if date == easter - Duration::days(2) {
return true;
}
if m == 4 && d == 21 {
return true;
}
if m == 5 && d == 1 {
return true;
}
if date == easter + Duration::days(60) {
return true;
}
if m == 9 && d == 7 {
return true;
}
if m == 10 && d == 12 {
return true;
}
if m == 11 && d == 2 {
return true;
}
if m == 11 && d == 15 {
return true;
}
if y >= 2024 && m == 11 && d == 20 {
return true;
}
if m == 12 && d == 24 {
return true;
}
if m == 12 && d == 25 {
return true;
}
if m == 12 && d == 31 {
return true;
}
false
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LunarHoliday {
LunarNewYear,
ChingMing,
BuddhaOrVesak,
DragonBoat,
MidAutumnNext,
ChungYeung,
EidAlFitr,
EidAlAdha,
Deepavali,
}
static LUNAR_TABLE: &[(i32, u32, u32, LunarHoliday)] = &[
(2020, 1, 27, LunarHoliday::LunarNewYear),
(2020, 1, 28, LunarHoliday::LunarNewYear),
(2020, 1, 29, LunarHoliday::LunarNewYear),
(2020, 4, 4, LunarHoliday::ChingMing),
(2020, 4, 30, LunarHoliday::BuddhaOrVesak),
(2020, 6, 25, LunarHoliday::DragonBoat),
(2020, 10, 2, LunarHoliday::MidAutumnNext),
(2020, 10, 26, LunarHoliday::ChungYeung),
(2020, 1, 25, LunarHoliday::LunarNewYear),
(2020, 5, 7, LunarHoliday::BuddhaOrVesak),
(2020, 5, 24, LunarHoliday::EidAlFitr),
(2020, 7, 31, LunarHoliday::EidAlAdha),
(2020, 11, 14, LunarHoliday::Deepavali),
(2021, 2, 12, LunarHoliday::LunarNewYear),
(2021, 2, 15, LunarHoliday::LunarNewYear),
(2021, 4, 5, LunarHoliday::ChingMing),
(2021, 5, 19, LunarHoliday::BuddhaOrVesak),
(2021, 6, 14, LunarHoliday::DragonBoat),
(2021, 9, 22, LunarHoliday::MidAutumnNext),
(2021, 10, 14, LunarHoliday::ChungYeung),
(2021, 5, 13, LunarHoliday::EidAlFitr),
(2021, 7, 20, LunarHoliday::EidAlAdha),
(2021, 11, 4, LunarHoliday::Deepavali),
(2022, 2, 1, LunarHoliday::LunarNewYear),
(2022, 2, 2, LunarHoliday::LunarNewYear),
(2022, 2, 3, LunarHoliday::LunarNewYear),
(2022, 4, 5, LunarHoliday::ChingMing),
(2022, 5, 9, LunarHoliday::BuddhaOrVesak),
(2022, 6, 3, LunarHoliday::DragonBoat),
(2022, 9, 12, LunarHoliday::MidAutumnNext),
(2022, 10, 4, LunarHoliday::ChungYeung),
(2022, 5, 3, LunarHoliday::EidAlFitr),
(2022, 7, 11, LunarHoliday::EidAlAdha),
(2022, 10, 24, LunarHoliday::Deepavali),
(2023, 1, 23, LunarHoliday::LunarNewYear),
(2023, 1, 24, LunarHoliday::LunarNewYear),
(2023, 1, 25, LunarHoliday::LunarNewYear),
(2023, 4, 5, LunarHoliday::ChingMing),
(2023, 5, 26, LunarHoliday::BuddhaOrVesak),
(2023, 6, 22, LunarHoliday::DragonBoat),
(2023, 10, 2, LunarHoliday::MidAutumnNext),
(2023, 10, 23, LunarHoliday::ChungYeung),
(2023, 4, 22, LunarHoliday::EidAlFitr),
(2023, 6, 29, LunarHoliday::EidAlAdha),
(2023, 11, 13, LunarHoliday::Deepavali),
(2024, 2, 12, LunarHoliday::LunarNewYear),
(2024, 2, 13, LunarHoliday::LunarNewYear),
(2024, 4, 4, LunarHoliday::ChingMing),
(2024, 5, 15, LunarHoliday::BuddhaOrVesak),
(2024, 6, 10, LunarHoliday::DragonBoat),
(2024, 9, 18, LunarHoliday::MidAutumnNext),
(2024, 10, 11, LunarHoliday::ChungYeung),
(2024, 2, 10, LunarHoliday::LunarNewYear), (2024, 4, 10, LunarHoliday::EidAlFitr),
(2024, 6, 17, LunarHoliday::EidAlAdha),
(2024, 10, 31, LunarHoliday::Deepavali),
(2025, 1, 29, LunarHoliday::LunarNewYear),
(2025, 1, 30, LunarHoliday::LunarNewYear),
(2025, 1, 31, LunarHoliday::LunarNewYear),
(2025, 4, 4, LunarHoliday::ChingMing),
(2025, 5, 5, LunarHoliday::BuddhaOrVesak),
(2025, 5, 31, LunarHoliday::DragonBoat),
(2025, 10, 7, LunarHoliday::MidAutumnNext),
(2025, 10, 29, LunarHoliday::ChungYeung),
(2025, 3, 31, LunarHoliday::EidAlFitr),
(2025, 6, 7, LunarHoliday::EidAlAdha),
(2025, 10, 20, LunarHoliday::Deepavali),
];
fn lunar_holiday_on(date: NaiveDate, tag: LunarHoliday) -> bool {
let (y, m, d) = (date.year(), date.month(), date.day());
LUNAR_TABLE
.iter()
.any(|&(yr, mo, da, t)| yr == y && mo == m && da == d && t == tag)
}
fn is_hkex_holiday(date: NaiveDate) -> bool {
let (y, m, d) = (date.year(), date.month(), date.day());
let w = date.weekday();
use Weekday::*;
if (m == 1 && d == 1 && w != Sat && w != Sun)
|| (m == 1 && d == 2 && w == Mon)
|| (m == 1 && d == 3 && w == Mon)
{
return true;
}
let easter = easter_sunday(y);
if date == easter - Duration::days(2)
|| date == easter - Duration::days(1)
|| date == easter + Duration::days(1)
{
return true;
}
if (m == 5 && d == 1 && w != Sat && w != Sun) || (m == 5 && d == 2 && w == Mon) {
return true;
}
if (m == 7 && d == 1 && w != Sat && w != Sun) || (m == 7 && d == 2 && w == Mon) {
return true;
}
if (m == 10 && d == 1 && w != Sat && w != Sun) || (m == 10 && d == 2 && w == Mon) {
return true;
}
if (m == 12 && d == 25 && w != Sat && w != Sun) || (m == 12 && d == 27 && matches!(w, Mon | Tue))
{
return true;
}
if (m == 12 && d == 26 && w != Sat && w != Sun) || (m == 12 && d == 28 && matches!(w, Mon | Tue))
{
return true;
}
for tag in [
LunarHoliday::LunarNewYear,
LunarHoliday::ChingMing,
LunarHoliday::BuddhaOrVesak,
LunarHoliday::DragonBoat,
LunarHoliday::MidAutumnNext,
LunarHoliday::ChungYeung,
] {
if lunar_holiday_on(date, tag) {
return true;
}
}
false
}
fn is_sgx_holiday(date: NaiveDate) -> bool {
let (_y, m, d) = (date.year(), date.month(), date.day());
let w = date.weekday();
use Weekday::*;
if (m == 1 && d == 1 && w != Sat && w != Sun) || (m == 1 && d == 2 && w == Mon) {
return true;
}
let easter = easter_sunday(date.year());
if date == easter - Duration::days(2) {
return true;
}
if (m == 5 && d == 1 && w != Sat && w != Sun) || (m == 5 && d == 2 && w == Mon) {
return true;
}
if (m == 8 && d == 9 && w != Sat && w != Sun) || (m == 8 && d == 10 && w == Mon) {
return true;
}
if (m == 12 && d == 25 && w != Sat && w != Sun) || (m == 12 && d == 26 && w == Mon) {
return true;
}
for tag in [
LunarHoliday::LunarNewYear,
LunarHoliday::BuddhaOrVesak,
LunarHoliday::EidAlFitr,
LunarHoliday::EidAlAdha,
LunarHoliday::Deepavali,
] {
if lunar_holiday_on(date, tag) {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use chrono::NaiveDate;
use super::*;
#[test]
fn weekend_detection() {
let cal = Calendar::new(HolidayCalendar::UnitedStates);
assert!(cal.is_weekend(NaiveDate::from_ymd_opt(2024, 1, 6).unwrap()));
assert!(cal.is_weekend(NaiveDate::from_ymd_opt(2024, 1, 7).unwrap()));
assert!(!cal.is_weekend(NaiveDate::from_ymd_opt(2024, 1, 8).unwrap()));
}
#[test]
fn us_independence_day() {
let cal = Calendar::new(HolidayCalendar::UnitedStates);
assert!(cal.is_holiday(NaiveDate::from_ymd_opt(2024, 7, 4).unwrap()));
}
#[test]
fn target_christmas() {
let cal = Calendar::new(HolidayCalendar::Target);
assert!(cal.is_holiday(NaiveDate::from_ymd_opt(2024, 12, 25).unwrap()));
}
#[test]
fn manual_holiday_addition() {
let mut cal = Calendar::new(HolidayCalendar::UnitedStates);
let date = NaiveDate::from_ymd_opt(2024, 3, 15).unwrap();
assert!(!cal.is_holiday(date));
cal.add_holiday(date);
assert!(cal.is_holiday(date));
cal.remove_holiday(date);
assert!(!cal.is_holiday(date));
}
#[test]
fn joint_any_mode_is_union() {
let cal = Calendar::joint([HolidayCalendar::UnitedStates, HolidayCalendar::Target]);
let jul4 = NaiveDate::from_ymd_opt(2024, 7, 4).unwrap();
assert!(cal.is_holiday(jul4), "union: US holiday counts");
let may1 = NaiveDate::from_ymd_opt(2024, 5, 1).unwrap();
assert!(cal.is_holiday(may1), "union: TARGET holiday counts");
}
#[test]
fn next_and_previous_business_day_skip_weekend() {
let cal = Calendar::new(HolidayCalendar::UnitedStates);
let fri = NaiveDate::from_ymd_opt(2024, 1, 5).unwrap();
let mon = NaiveDate::from_ymd_opt(2024, 1, 8).unwrap();
assert_eq!(cal.next_business_day(fri), mon);
assert_eq!(cal.previous_business_day(mon), fri);
}
#[test]
fn next_business_day_is_strictly_after() {
let cal = Calendar::new(HolidayCalendar::UnitedStates);
let mon = NaiveDate::from_ymd_opt(2024, 1, 8).unwrap();
let tue = NaiveDate::from_ymd_opt(2024, 1, 9).unwrap();
assert_eq!(cal.next_business_day(mon), tue);
}
#[test]
fn previous_business_day_skips_holiday() {
let cal = Calendar::new(HolidayCalendar::UnitedStates);
let mlk = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
let fri = NaiveDate::from_ymd_opt(2024, 1, 12).unwrap();
assert_eq!(cal.previous_business_day(mlk), fri);
}
#[test]
fn hkex_2024_official_holidays() {
let cal = Calendar::new(HolidayCalendar::Hkex);
let dates = [
(2024, 1, 1), (2024, 2, 12), (2024, 2, 13), (2024, 3, 29), (2024, 4, 1), (2024, 4, 4), (2024, 5, 1), (2024, 5, 15), (2024, 6, 10), (2024, 7, 1), (2024, 9, 18), (2024, 10, 1), (2024, 10, 11), (2024, 12, 25), (2024, 12, 26), ];
for (y, m, d) in dates {
let date = NaiveDate::from_ymd_opt(y, m, d).unwrap();
assert!(cal.is_holiday(date), "HKEX missed: {date}");
}
}
#[test]
fn asx_2024_core_holidays() {
let cal = Calendar::new(HolidayCalendar::Asx);
let dates = [
(2024, 1, 1), (2024, 1, 26), (2024, 3, 29), (2024, 4, 1), (2024, 4, 25), (2024, 6, 10), (2024, 12, 25), (2024, 12, 26), ];
for (y, m, d) in dates {
let date = NaiveDate::from_ymd_opt(y, m, d).unwrap();
assert!(cal.is_holiday(date), "ASX missed: {date}");
}
}
#[test]
fn asx_anzac_day_weekend_rollover() {
let cal = Calendar::new(HolidayCalendar::Asx);
let sat = NaiveDate::from_ymd_opt(2026, 4, 25).unwrap();
let mon = NaiveDate::from_ymd_opt(2026, 4, 27).unwrap();
assert!(
!cal.is_holiday(sat),
"weekend ANZAC suppressed (matches US convention)"
);
assert!(
cal.is_weekend(sat),
"Apr 25 2026 is Saturday (caught by is_weekend)"
);
assert!(cal.is_holiday(mon), "Mon Apr 27 should be ANZAC substitute");
}
#[test]
fn sgx_2024_core_holidays() {
let cal = Calendar::new(HolidayCalendar::Sgx);
let dates = [
(2024, 1, 1), (2024, 3, 29), (2024, 4, 10), (2024, 5, 1), (2024, 5, 22), (2024, 6, 17), (2024, 8, 9), (2024, 10, 31), (2024, 12, 25), ];
for (y, m, d) in dates {
let date = NaiveDate::from_ymd_opt(y, m, d).unwrap();
if (m, d) == (5, 22) {
continue;
}
assert!(cal.is_holiday(date), "SGX missed: {date}");
}
}
#[test]
fn b3_2024_core_holidays() {
let cal = Calendar::new(HolidayCalendar::B3);
let dates = [
(2024, 1, 1), (2024, 2, 12), (2024, 2, 13), (2024, 3, 29), (2024, 4, 21), (2024, 5, 1), (2024, 5, 30), (2024, 9, 7), (2024, 10, 12), (2024, 11, 2), (2024, 11, 15), (2024, 11, 20), (2024, 12, 24), (2024, 12, 25), (2024, 12, 31), ];
for (y, m, d) in dates {
let date = NaiveDate::from_ymd_opt(y, m, d).unwrap();
assert!(cal.is_holiday(date), "B3 missed: {date}");
}
}
#[test]
fn b3_black_awareness_only_from_2024() {
let cal = Calendar::new(HolidayCalendar::B3);
let pre = NaiveDate::from_ymd_opt(2023, 11, 20).unwrap();
let post = NaiveDate::from_ymd_opt(2024, 11, 20).unwrap();
assert!(!cal.is_holiday(pre), "Black Awareness was not B3 pre-2024");
assert!(
cal.is_holiday(post),
"Black Awareness is a B3 holiday from 2024"
);
}
#[test]
fn b3_independence_day_no_weekend_rollover() {
let cal = Calendar::new(HolidayCalendar::B3);
let sat = NaiveDate::from_ymd_opt(2024, 9, 7).unwrap();
assert!(
cal.is_holiday(sat),
"Sep 7 stays a holiday even on Saturday"
);
let next_mon = NaiveDate::from_ymd_opt(2024, 9, 9).unwrap();
assert!(
!cal.is_holiday(next_mon),
"no Mon substitute in BR convention"
);
}
#[test]
fn joint_all_mode_is_intersection() {
let cal = Calendar::joint_with_mode(
[HolidayCalendar::UnitedStates, HolidayCalendar::Target],
JointMode::AllHolidays,
);
let jul4 = NaiveDate::from_ymd_opt(2024, 7, 4).unwrap();
assert!(
!cal.is_holiday(jul4),
"intersection: US-only ≠ joint holiday"
);
let may1 = NaiveDate::from_ymd_opt(2024, 5, 1).unwrap();
assert!(
!cal.is_holiday(may1),
"intersection: TARGET-only ≠ joint holiday"
);
let dec25 = NaiveDate::from_ymd_opt(2024, 12, 25).unwrap();
assert!(
cal.is_holiday(dec25),
"intersection: Christmas is shared → joint holiday"
);
}
}