Skip to main content

finance_dates/
holiday.rs

1//! Holiday rule definitions evaluated lazily per year.
2
3use chrono::{Datelike, Duration, NaiveDate, Weekday as ChronoWeekday};
4
5pub use chrono::Weekday;
6
7/// Saturday→Friday, Sunday→Monday observance roll, US-style.
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum WeekendRoll {
10    /// No adjustment.
11    None,
12    /// Sat → Fri, Sun → Mon (US/Western convention).
13    NearestWeekday,
14}
15
16/// A holiday whose date depends only on the calendar year.
17#[derive(Clone, Debug)]
18pub enum HolidayRule {
19    /// Fixed month/day (e.g. Christmas = 12/25).
20    Fixed {
21        month: u32,
22        day: u32,
23        roll: WeekendRoll,
24        since_year: Option<i32>,
25    },
26    /// Nth weekday of a month (e.g. 3rd Monday in January = MLK Day).
27    /// `n` is 1-based; negative `n` counts from the end (-1 = last).
28    NthWeekday {
29        month: u32,
30        weekday: Weekday,
31        n: i32,
32        since_year: Option<i32>,
33    },
34    /// Easter Sunday plus offset days (e.g. Good Friday = -2, Easter Monday = +1).
35    EasterOffset {
36        offset_days: i32,
37        since_year: Option<i32>,
38    },
39    /// A static lookup table keyed by year (e.g. lunar holidays we don't compute).
40    Tabulated { table: &'static [(i32, u32, u32)] },
41}
42
43impl HolidayRule {
44    /// Return the observed date in `year`, or `None` if not yet observed.
45    pub fn observed_in(&self, year: i32) -> Option<NaiveDate> {
46        match self {
47            HolidayRule::Fixed {
48                month,
49                day,
50                roll,
51                since_year,
52            } => {
53                if let Some(y) = since_year {
54                    if year < *y {
55                        return None;
56                    }
57                }
58                let raw = NaiveDate::from_ymd_opt(year, *month, *day)?;
59                Some(apply_roll(raw, *roll))
60            }
61            HolidayRule::NthWeekday {
62                month,
63                weekday,
64                n,
65                since_year,
66            } => {
67                if let Some(y) = since_year {
68                    if year < *y {
69                        return None;
70                    }
71                }
72                nth_weekday_of_month(year, *month, *weekday, *n)
73            }
74            HolidayRule::EasterOffset {
75                offset_days,
76                since_year,
77            } => {
78                if let Some(y) = since_year {
79                    if year < *y {
80                        return None;
81                    }
82                }
83                let easter = easter_sunday(year)?;
84                Some(easter + Duration::days(*offset_days as i64))
85            }
86            HolidayRule::Tabulated { table } => table
87                .iter()
88                .find(|(y, _, _)| *y == year)
89                .and_then(|(_, m, d)| NaiveDate::from_ymd_opt(year, *m, *d)),
90        }
91    }
92
93    /// Return all dates this rule produces in `year`. Equivalent to
94    /// `observed_in` for single-date rules; for `Tabulated` returns every
95    /// row matching the year so multi-day closures are captured.
96    pub fn dates_in(&self, year: i32) -> Vec<NaiveDate> {
97        match self {
98            HolidayRule::Tabulated { table } => table
99                .iter()
100                .filter(|(y, _, _)| *y == year)
101                .filter_map(|(_, m, d)| NaiveDate::from_ymd_opt(year, *m, *d))
102                .collect(),
103            _ => self.observed_in(year).into_iter().collect(),
104        }
105    }
106}
107
108fn apply_roll(d: NaiveDate, roll: WeekendRoll) -> NaiveDate {
109    match roll {
110        WeekendRoll::None => d,
111        WeekendRoll::NearestWeekday => match d.weekday() {
112            ChronoWeekday::Sat => d - Duration::days(1),
113            ChronoWeekday::Sun => d + Duration::days(1),
114            _ => d,
115        },
116    }
117}
118
119/// Nth occurrence of `weekday` in the given month/year. `n` may be negative
120/// to count from the end (-1 = last).
121pub fn nth_weekday_of_month(year: i32, month: u32, weekday: Weekday, n: i32) -> Option<NaiveDate> {
122    if n == 0 {
123        return None;
124    }
125    if n > 0 {
126        let first = NaiveDate::from_ymd_opt(year, month, 1)?;
127        let offset = (weekday.num_days_from_monday() as i64
128            - first.weekday().num_days_from_monday() as i64)
129            .rem_euclid(7)
130            + 7 * (n as i64 - 1);
131        let candidate = first + Duration::days(offset);
132        if candidate.month() == month {
133            Some(candidate)
134        } else {
135            None
136        }
137    } else {
138        // last day of month
139        let last_of_month = match month {
140            12 => NaiveDate::from_ymd_opt(year + 1, 1, 1)? - Duration::days(1),
141            _ => NaiveDate::from_ymd_opt(year, month + 1, 1)? - Duration::days(1),
142        };
143        let back = (last_of_month.weekday().num_days_from_monday() as i64
144            - weekday.num_days_from_monday() as i64)
145            .rem_euclid(7);
146        let last_of_kind = last_of_month - Duration::days(back);
147        let candidate = last_of_kind - Duration::days(((-n - 1) as i64) * 7);
148        if candidate.month() == month {
149            Some(candidate)
150        } else {
151            None
152        }
153    }
154}
155
156/// Anonymous Gregorian (Meeus/Jones/Butcher) algorithm for Easter Sunday.
157pub fn easter_sunday(year: i32) -> Option<NaiveDate> {
158    let a = year % 19;
159    let b = year / 100;
160    let c = year % 100;
161    let d = b / 4;
162    let e = b % 4;
163    let f = (b + 8) / 25;
164    let g = (b - f + 1) / 3;
165    let h = (19 * a + b - d - g + 15) % 30;
166    let i = c / 4;
167    let k = c % 4;
168    let l = (32 + 2 * e + 2 * i - h - k) % 7;
169    let m = (a + 11 * h + 22 * l) / 451;
170    let month = (h + l - 7 * m + 114) / 31;
171    let day = ((h + l - 7 * m + 114) % 31) + 1;
172    NaiveDate::from_ymd_opt(year, month as u32, day as u32)
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn easter_known_dates() {
181        assert_eq!(
182            easter_sunday(2024).unwrap(),
183            NaiveDate::from_ymd_opt(2024, 3, 31).unwrap()
184        );
185        assert_eq!(
186            easter_sunday(2025).unwrap(),
187            NaiveDate::from_ymd_opt(2025, 4, 20).unwrap()
188        );
189        assert_eq!(
190            easter_sunday(2000).unwrap(),
191            NaiveDate::from_ymd_opt(2000, 4, 23).unwrap()
192        );
193    }
194
195    #[test]
196    fn nth_weekday_mlk_day() {
197        let d = nth_weekday_of_month(2024, 1, Weekday::Mon, 3).unwrap();
198        assert_eq!(d, NaiveDate::from_ymd_opt(2024, 1, 15).unwrap());
199    }
200
201    #[test]
202    fn nth_weekday_last_memorial_day() {
203        let d = nth_weekday_of_month(2024, 5, Weekday::Mon, -1).unwrap();
204        assert_eq!(d, NaiveDate::from_ymd_opt(2024, 5, 27).unwrap());
205    }
206
207    #[test]
208    fn nth_weekday_thanksgiving_2024() {
209        // 4th Thursday in November 2024 = Nov 28.
210        let d = nth_weekday_of_month(2024, 11, Weekday::Thu, 4).unwrap();
211        assert_eq!(d, NaiveDate::from_ymd_opt(2024, 11, 28).unwrap());
212    }
213
214    #[test]
215    fn weekend_roll_christmas_2022() {
216        let r = HolidayRule::Fixed {
217            month: 12,
218            day: 25,
219            roll: WeekendRoll::NearestWeekday,
220            since_year: None,
221        };
222        assert_eq!(
223            r.observed_in(2022).unwrap(),
224            NaiveDate::from_ymd_opt(2022, 12, 26).unwrap()
225        );
226    }
227
228    #[test]
229    fn juneteenth_since_2021() {
230        let r = HolidayRule::Fixed {
231            month: 6,
232            day: 19,
233            roll: WeekendRoll::NearestWeekday,
234            since_year: Some(2021),
235        };
236        assert!(r.observed_in(2020).is_none());
237        assert!(r.observed_in(2021).is_some());
238    }
239}