1use crate::date::Date;
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
45#[non_exhaustive]
46pub enum BusinessDayConvention {
47 Unadjusted,
49 Following,
51 ModifiedFollowing,
53 Preceding,
55 ModifiedPreceding,
57}
58
59pub trait Calendar {
66 fn name(&self) -> &'static str;
68
69 fn is_business_day(&self, date: Date) -> bool;
72
73 fn is_holiday(&self, date: Date) -> bool {
75 !date.is_weekend() && !self.is_business_day(date)
76 }
77
78 fn adjust(&self, date: Date, conv: BusinessDayConvention) -> Date {
81 match conv {
82 BusinessDayConvention::Unadjusted => date,
83 BusinessDayConvention::Following => self.roll(date, 1),
84 BusinessDayConvention::Preceding => self.roll(date, -1),
85 BusinessDayConvention::ModifiedFollowing => {
86 let rolled = self.roll(date, 1);
87 if rolled.month() != date.month() {
88 self.roll(date, -1)
89 } else {
90 rolled
91 }
92 }
93 BusinessDayConvention::ModifiedPreceding => {
94 let rolled = self.roll(date, -1);
95 if rolled.month() != date.month() {
96 self.roll(date, 1)
97 } else {
98 rolled
99 }
100 }
101 }
102 }
103
104 fn advance(&self, date: Date, n: i32) -> Date {
107 if n == 0 {
108 return date;
109 }
110 let step = if n > 0 { 1 } else { -1 };
111 let mut remaining = n.abs();
112 let mut d = date;
113 while remaining > 0 {
114 d = d.add_days(step);
115 if self.is_business_day(d) {
116 remaining -= 1;
117 }
118 }
119 d
120 }
121
122 fn business_days_between(&self, start: Date, end: Date) -> i32 {
126 if start == end {
127 return 0;
128 }
129 if end < start {
130 return -self.business_days_between(end, start);
131 }
132 let mut count = 0;
133 let mut d = start;
134 while d < end {
135 if self.is_business_day(d) {
136 count += 1;
137 }
138 d = d.add_days(1);
139 }
140 count
141 }
142
143 fn year_fraction_252(&self, start: Date, end: Date) -> f64 {
148 f64::from(self.business_days_between(start, end)) / 252.0
149 }
150
151 #[doc(hidden)]
155 fn roll(&self, date: Date, step: i32) -> Date {
156 let mut d = date;
157 while !self.is_business_day(d) {
158 d = d.add_days(step);
159 }
160 d
161 }
162}
163
164#[must_use]
169pub fn easter(year: i32) -> Date {
170 let a = year % 19;
171 let b = year / 100;
172 let c = year % 100;
173 let d = b / 4;
174 let e = b % 4;
175 let f = (b + 8) / 25;
176 let g = (b - f + 1) / 3;
177 let h = (19 * a + b - d - g + 15) % 30;
178 let i = c / 4;
179 let k = c % 4;
180 let l = (32 + 2 * e + 2 * i - h - k) % 7;
181 let m = (a + 11 * h + 22 * l) / 451;
182 let month = (h + l - 7 * m + 114) / 31; let day = ((h + l - 7 * m + 114) % 31) + 1;
184 Date::new(year, month as u32, day as u32).expect("computus yields a valid date")
185}
186
187#[derive(Debug, Clone, Copy, Default)]
189pub struct WeekendsOnly;
190
191impl Calendar for WeekendsOnly {
192 fn name(&self) -> &'static str {
193 "WeekendsOnly"
194 }
195 fn is_business_day(&self, date: Date) -> bool {
196 !date.is_weekend()
197 }
198}
199
200#[derive(Debug, Clone, Copy, Default)]
208pub struct Brazil;
209
210impl Brazil {
211 fn is_named_holiday(date: Date) -> bool {
212 let (y, m, d) = date.ymd();
213 if matches!(
214 (m, d),
215 (1, 1) | (4, 21) | (5, 1) | (9, 7) | (10, 12) | (11, 2) | (11, 15) | (12, 25)
216 ) {
217 return true;
218 }
219 if m == 11 && d == 20 && y >= 2024 {
221 return true;
222 }
223 let easter = easter(y);
224 let s = date.serial();
225 s == easter.add_days(-48).serial() || s == easter.add_days(-47).serial() || s == easter.add_days(-2).serial() || s == easter.add_days(60).serial() }
230}
231
232impl Calendar for Brazil {
233 fn name(&self) -> &'static str {
234 "Brazil"
235 }
236 fn is_business_day(&self, date: Date) -> bool {
237 !date.is_weekend() && !Self::is_named_holiday(date)
238 }
239}
240
241#[derive(Debug, Clone, Copy, Default)]
246pub struct Target2;
247
248impl Target2 {
249 fn is_named_holiday(date: Date) -> bool {
250 let (_, m, d) = date.ymd();
251 if matches!((m, d), (1, 1) | (5, 1) | (12, 25) | (12, 26)) {
252 return true;
253 }
254 let easter = easter(date.year());
255 let s = date.serial();
256 s == easter.add_days(-2).serial() || s == easter.add_days(1).serial() }
259}
260
261impl Calendar for Target2 {
262 fn name(&self) -> &'static str {
263 "TARGET2"
264 }
265 fn is_business_day(&self, date: Date) -> bool {
266 !date.is_weekend() && !Self::is_named_holiday(date)
267 }
268}
269
270#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
272pub enum JoinRule {
273 JoinHolidays,
276 JoinBusinessDays,
279}
280
281pub struct JoinCalendar {
286 calendars: Vec<Box<dyn Calendar>>,
287 rule: JoinRule,
288}
289
290impl JoinCalendar {
291 #[must_use]
297 pub fn new(calendars: Vec<Box<dyn Calendar>>, rule: JoinRule) -> Self {
298 Self { calendars, rule }
299 }
300}
301
302impl Calendar for JoinCalendar {
303 fn name(&self) -> &'static str {
304 "Joint"
305 }
306 fn is_business_day(&self, date: Date) -> bool {
307 match self.rule {
308 JoinRule::JoinHolidays => self.calendars.iter().all(|c| c.is_business_day(date)),
309 JoinRule::JoinBusinessDays => self.calendars.iter().any(|c| c.is_business_day(date)),
310 }
311 }
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317
318 fn d(y: i32, m: u32, day: u32) -> Date {
319 Date::new(y, m, day).unwrap()
320 }
321
322 #[test]
323 fn easter_known_values() {
324 assert_eq!(easter(2024), d(2024, 3, 31));
325 assert_eq!(easter(2025), d(2025, 4, 20));
326 assert_eq!(easter(2000), d(2000, 4, 23));
327 assert_eq!(easter(2027), d(2027, 3, 28));
328 }
329
330 #[test]
331 fn weekends_only_marks_weekends() {
332 let cal = WeekendsOnly;
333 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))); }
338
339 #[test]
340 fn brazil_fixed_holidays() {
341 let cal = Brazil;
342 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)));
345 assert!(cal.is_business_day(d(2025, 1, 2))); }
347
348 #[test]
349 fn brazil_moving_holidays_2025() {
350 let cal = Brazil;
351 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))); }
357
358 #[test]
359 fn brazil_black_awareness_from_2024() {
360 let cal = Brazil;
361 assert!(cal.is_business_day(d(2023, 11, 20)));
363 assert!(!cal.is_business_day(d(2024, 11, 20)));
365 }
366
367 #[test]
368 fn target2_holidays_2025() {
369 let cal = Target2;
370 assert!(!cal.is_business_day(d(2025, 1, 1)));
371 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)));
374 assert!(!cal.is_business_day(d(2025, 12, 26)));
375 assert!(cal.is_business_day(d(2025, 12, 24))); }
377
378 #[test]
379 fn following_rolls_forward() {
380 let cal = WeekendsOnly;
381 let adj = cal.adjust(d(2025, 1, 4), BusinessDayConvention::Following);
383 assert_eq!(adj, d(2025, 1, 6));
384 }
385
386 #[test]
387 fn preceding_rolls_backward() {
388 let cal = WeekendsOnly;
389 let adj = cal.adjust(d(2025, 1, 5), BusinessDayConvention::Preceding);
391 assert_eq!(adj, d(2025, 1, 3));
392 }
393
394 #[test]
395 fn modified_following_stays_in_month() {
396 let cal = WeekendsOnly;
397 let saturday = d(2025, 5, 31);
400 assert_eq!(
401 cal.adjust(saturday, BusinessDayConvention::Following),
402 d(2025, 6, 2)
403 );
404 let mf = cal.adjust(saturday, BusinessDayConvention::ModifiedFollowing);
405 assert_eq!(mf, d(2025, 5, 30));
406 assert_eq!(mf.month(), 5);
407 }
408
409 #[test]
410 fn modified_preceding_stays_in_month() {
411 let cal = WeekendsOnly;
412 let sunday = d(2025, 6, 1);
415 assert_eq!(
416 cal.adjust(sunday, BusinessDayConvention::Preceding),
417 d(2025, 5, 30)
418 );
419 let mp = cal.adjust(sunday, BusinessDayConvention::ModifiedPreceding);
420 assert_eq!(mp, d(2025, 6, 2));
421 assert_eq!(mp.month(), 6);
422 }
423
424 #[test]
425 fn unadjusted_is_identity() {
426 let cal = Brazil;
427 let holiday = d(2025, 1, 1);
428 assert_eq!(
429 cal.adjust(holiday, BusinessDayConvention::Unadjusted),
430 holiday
431 );
432 }
433
434 #[test]
435 fn adjust_business_day_unchanged() {
436 let cal = WeekendsOnly;
437 let wed = d(2025, 1, 8);
438 assert_eq!(cal.adjust(wed, BusinessDayConvention::Following), wed);
439 assert_eq!(cal.adjust(wed, BusinessDayConvention::Preceding), wed);
440 }
441
442 #[test]
443 fn advance_business_days() {
444 let cal = WeekendsOnly;
445 assert_eq!(cal.advance(d(2025, 1, 3), 1), d(2025, 1, 6));
447 assert_eq!(cal.advance(d(2025, 1, 6), -1), d(2025, 1, 3));
449 assert_eq!(cal.advance(d(2025, 1, 4), 0), d(2025, 1, 4));
451 }
452
453 #[test]
454 fn business_days_between_half_open() {
455 let cal = WeekendsOnly;
456 assert_eq!(cal.business_days_between(d(2025, 1, 6), d(2025, 1, 10)), 4);
458 assert_eq!(cal.business_days_between(d(2025, 1, 6), d(2025, 1, 6)), 0);
460 assert_eq!(cal.business_days_between(d(2025, 1, 10), d(2025, 1, 6)), -4);
461 }
462
463 #[test]
464 fn business_days_between_differs_by_calendar() {
465 let start = d(2025, 1, 1);
467 let end = d(2025, 1, 8);
468 assert_eq!(WeekendsOnly.business_days_between(start, end), 5);
470 assert_eq!(Brazil.business_days_between(start, end), 4);
472 }
473
474 #[test]
475 fn bus252_year_fraction() {
476 let cal = WeekendsOnly;
477 let yf = cal.year_fraction_252(d(2025, 1, 1), d(2025, 1, 8));
479 assert!((yf - 5.0 / 252.0).abs() < 1e-12);
480 }
481
482 #[test]
483 fn join_holidays_is_intersection_of_business_days() {
484 let joint = JoinCalendar::new(
485 vec![Box::new(Brazil), Box::new(Target2)],
486 JoinRule::JoinHolidays,
487 );
488 assert!(!joint.is_business_day(d(2025, 6, 19)));
491 assert!(!joint.is_business_day(d(2025, 12, 26)));
493 assert!(joint.is_business_day(d(2025, 1, 2)));
495 }
496
497 #[test]
498 fn join_business_days_is_union_of_business_days() {
499 let joint = JoinCalendar::new(
500 vec![Box::new(Brazil), Box::new(Target2)],
501 JoinRule::JoinBusinessDays,
502 );
503 assert!(joint.is_business_day(d(2025, 6, 19)));
505 assert!(joint.is_business_day(d(2025, 12, 26)));
507 assert!(!joint.is_business_day(d(2025, 1, 1)));
509 assert!(!joint.is_business_day(d(2025, 1, 4)));
511 }
512}