1use chrono::{Datelike, Duration, Months, NaiveDate, Weekday};
28use serde::{Deserialize, Serialize};
29use std::collections::BTreeSet;
30
31use crate::core::errors::RustyQLibError;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum BusinessDayConvention {
39 Unadjusted,
41 #[default]
43 Following,
44 ModifiedFollowing,
47 Preceding,
49 ModifiedPreceding,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(tag = "type", rename_all = "snake_case")]
59pub enum Calendar {
60 WeekendsOnly,
62 Target,
65 UsNyse,
67 UkSettlement,
70 Custom { holidays: BTreeSet<NaiveDate> },
72}
73
74impl Calendar {
75 pub fn is_weekend(date: NaiveDate) -> bool {
77 matches!(date.weekday(), Weekday::Sat | Weekday::Sun)
78 }
79
80 pub fn is_holiday(&self, date: NaiveDate) -> bool {
82 if Self::is_weekend(date) {
83 return false;
84 }
85 match self {
86 Calendar::WeekendsOnly => false,
87 Calendar::Target => is_target_holiday(date),
88 Calendar::UsNyse => is_nyse_holiday(date),
89 Calendar::UkSettlement => is_uk_holiday(date),
90 Calendar::Custom { holidays } => holidays.contains(&date),
91 }
92 }
93
94 pub fn is_business_day(&self, date: NaiveDate) -> bool {
96 !Self::is_weekend(date) && !self.is_holiday(date)
97 }
98
99 pub fn adjust(&self, date: NaiveDate, convention: BusinessDayConvention) -> NaiveDate {
101 use BusinessDayConvention::*;
102 if convention == Unadjusted || self.is_business_day(date) {
103 return date;
104 }
105 match convention {
106 Following => self.next_business_day(date),
107 Preceding => self.previous_business_day(date),
108 ModifiedFollowing => {
109 let next = self.next_business_day(date);
110 if next.month() != date.month() {
111 self.previous_business_day(date)
112 } else {
113 next
114 }
115 }
116 ModifiedPreceding => {
117 let prev = self.previous_business_day(date);
118 if prev.month() != date.month() {
119 self.next_business_day(date)
120 } else {
121 prev
122 }
123 }
124 Unadjusted => date,
125 }
126 }
127
128 fn next_business_day(&self, mut date: NaiveDate) -> NaiveDate {
131 while !self.is_business_day(date) {
132 date += Duration::days(1);
133 }
134 date
135 }
136
137 fn previous_business_day(&self, mut date: NaiveDate) -> NaiveDate {
138 while !self.is_business_day(date) {
139 date -= Duration::days(1);
140 }
141 date
142 }
143
144 pub fn add_business_days(&self, date: NaiveDate, n: i64) -> NaiveDate {
148 let mut d = date;
149 let step = if n >= 0 { 1 } else { -1 };
150 let mut remaining = n.abs();
151 while remaining > 0 {
152 d += Duration::days(step);
153 if self.is_business_day(d) {
154 remaining -= 1;
155 }
156 }
157 d
158 }
159
160 pub fn advance(
164 &self,
165 date: NaiveDate,
166 period: Period,
167 convention: BusinessDayConvention,
168 ) -> NaiveDate {
169 let moved = match period {
170 Period::Days(n) => return self.add_business_days(date, n),
171 Period::Weeks(n) => date + Duration::weeks(n),
172 Period::Months(n) => add_months_signed(date, n),
173 Period::Years(n) => add_months_signed(date, 12 * n),
174 };
175 self.adjust(moved, convention)
176 }
177
178 pub fn business_days_between(&self, from: NaiveDate, to: NaiveDate) -> i64 {
181 if to < from {
182 return -self.business_days_between(to, from);
183 }
184 let mut count = 0;
185 let mut d = from;
186 while d < to {
187 d += Duration::days(1);
188 if self.is_business_day(d) {
189 count += 1;
190 }
191 }
192 count
193 }
194}
195
196#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub enum Period {
200 Days(i64),
201 Weeks(i64),
202 Months(i32),
203 Years(i32),
204}
205
206fn add_months_signed(date: NaiveDate, n: i32) -> NaiveDate {
207 if n >= 0 {
208 date + Months::new(n as u32)
209 } else {
210 date - Months::new((-n) as u32)
211 }
212}
213
214pub fn easter_sunday(year: i32) -> NaiveDate {
218 let a = year % 19;
219 let b = year / 100;
220 let c = year % 100;
221 let d = b / 4;
222 let e = b % 4;
223 let f = (b + 8) / 25;
224 let g = (b - f + 1) / 3;
225 let h = (19 * a + b - d - g + 15) % 30;
226 let i = c / 4;
227 let k = c % 4;
228 let l = (32 + 2 * e + 2 * i - h - k) % 7;
229 let m = (a + 11 * h + 22 * l) / 451;
230 let month = (h + l - 7 * m + 114) / 31;
231 let day = ((h + l - 7 * m + 114) % 31) + 1;
232 NaiveDate::from_ymd_opt(year, month as u32, day as u32).expect("valid Easter date")
233}
234
235fn nth_weekday(year: i32, month: u32, weekday: Weekday, n: u32) -> NaiveDate {
237 let first = NaiveDate::from_ymd_opt(year, month, 1).expect("valid month start");
238 let offset = (7 + weekday.num_days_from_monday() as i64
239 - first.weekday().num_days_from_monday() as i64)
240 % 7;
241 first + Duration::days(offset + 7 * (n as i64 - 1))
242}
243
244fn last_weekday(year: i32, month: u32, weekday: Weekday) -> NaiveDate {
246 let first_next = if month == 12 {
247 NaiveDate::from_ymd_opt(year + 1, 1, 1)
248 } else {
249 NaiveDate::from_ymd_opt(year, month + 1, 1)
250 }
251 .expect("valid month start");
252 let last = first_next - Duration::days(1);
253 let offset = (7 + last.weekday().num_days_from_monday() as i64
254 - weekday.num_days_from_monday() as i64)
255 % 7;
256 last - Duration::days(offset)
257}
258
259fn is_target_holiday(date: NaiveDate) -> bool {
262 let (y, m, d) = (date.year(), date.month(), date.day());
263 if (m == 1 && d == 1) || (m == 5 && d == 1) || (m == 12 && d == 25) {
264 return true;
265 }
266 if m == 12 && d == 26 && y >= 2000 {
267 return true;
268 }
269 let easter = easter_sunday(y);
270 date == easter - Duration::days(2) || date == easter + Duration::days(1)
271}
272
273fn is_nyse_holiday(date: NaiveDate) -> bool {
280 let (y, m, d) = (date.year(), date.month(), date.day());
281 let wd = date.weekday();
282
283 if m == 1 && (d == 1 || (d == 2 && wd == Weekday::Mon)) {
285 return true;
286 }
287 if y >= 1998 && m == 1 && date == nth_weekday(y, 1, Weekday::Mon, 3) {
289 return true;
290 }
291 if m == 2 && date == nth_weekday(y, 2, Weekday::Mon, 3) {
293 return true;
294 }
295 if date == easter_sunday(y) - Duration::days(2) {
297 return true;
298 }
299 if m == 5 && date == last_weekday(y, 5, Weekday::Mon) {
301 return true;
302 }
303 if y >= 2022 && observed_on(date, 6, 19) {
305 return true;
306 }
307 if observed_on(date, 7, 4) {
309 return true;
310 }
311 if m == 9 && date == nth_weekday(y, 9, Weekday::Mon, 1) {
313 return true;
314 }
315 if m == 11 && date == nth_weekday(y, 11, Weekday::Thu, 4) {
317 return true;
318 }
319 if observed_on(date, 12, 25) {
321 return true;
322 }
323 false
324}
325
326fn observed_on(date: NaiveDate, month: u32, day: u32) -> bool {
330 let holiday = match NaiveDate::from_ymd_opt(date.year(), month, day) {
331 Some(d) => d,
332 None => return false,
333 };
334 let observed = match holiday.weekday() {
335 Weekday::Sat => holiday - Duration::days(1),
336 Weekday::Sun => holiday + Duration::days(1),
337 _ => holiday,
338 };
339 date == observed
340}
341
342fn is_uk_holiday(date: NaiveDate) -> bool {
347 let (y, m, d) = (date.year(), date.month(), date.day());
348 let wd = date.weekday();
349
350 if m == 1
352 && (d == 1
353 || (d == 2 && wd == Weekday::Mon)
354 || (d == 3 && wd == Weekday::Mon))
355 {
356 return true;
357 }
358 let easter = easter_sunday(y);
359 if date == easter - Duration::days(2) || date == easter + Duration::days(1) {
360 return true;
361 }
362 if m == 5 && date == nth_weekday(y, 5, Weekday::Mon, 1) {
364 return true;
365 }
366 if m == 5 && date == last_weekday(y, 5, Weekday::Mon) {
368 return true;
369 }
370 if m == 8 && date == last_weekday(y, 8, Weekday::Mon) {
372 return true;
373 }
374 if m == 12 {
377 let christmas = NaiveDate::from_ymd_opt(y, 12, 25).expect("valid date");
378 let (obs_christmas, obs_boxing) = match christmas.weekday() {
379 Weekday::Fri => (25, 28), Weekday::Sat => (27, 28), Weekday::Sun => (27, 28), _ => (25, 26),
383 };
384 if christmas.weekday() == Weekday::Sun {
386 return d == 26 || d == 27;
387 }
388 return d == obs_christmas || d == obs_boxing;
389 }
390 false
391}
392
393#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
398#[serde(rename_all = "snake_case")]
399pub enum DateGeneration {
400 #[default]
401 Backward,
402 Forward,
403}
404
405#[derive(Debug, Clone, PartialEq, Eq)]
409pub struct Schedule {
410 pub dates: Vec<NaiveDate>,
413}
414
415impl Schedule {
416 pub fn generate(
425 effective: NaiveDate,
426 termination: NaiveDate,
427 months: u32,
428 calendar: &Calendar,
429 convention: BusinessDayConvention,
430 generation: DateGeneration,
431 ) -> Result<Schedule, RustyQLibError> {
432 if months == 0 {
433 return Err(RustyQLibError::invalid_input(
434 "schedule",
435 "the period must be at least one month",
436 ));
437 }
438 if termination <= effective {
439 return Err(RustyQLibError::invalid_input(
440 "schedule",
441 format!("termination {termination} must be after the effective date {effective}"),
442 ));
443 }
444
445 let mut anchors: Vec<NaiveDate> = Vec::new();
446 match generation {
447 DateGeneration::Backward => {
448 let mut k = 0u32;
449 loop {
450 k += months;
451 let date = termination - Months::new(k);
452 if date <= effective {
453 break;
454 }
455 anchors.push(date);
456 }
457 anchors.reverse();
458 anchors.push(termination);
459 }
460 DateGeneration::Forward => {
461 let mut k = 0u32;
462 loop {
463 k += months;
464 let date = effective + Months::new(k);
465 if date >= termination {
466 break;
467 }
468 anchors.push(date);
469 }
470 anchors.push(termination);
471 }
472 }
473
474 let mut dates: Vec<NaiveDate> = anchors
475 .into_iter()
476 .map(|d| calendar.adjust(d, convention))
477 .collect();
478 dates.dedup();
479 let last = *dates.last().expect("schedule has at least one date");
481 dates.retain(|d| *d <= last);
482 dates.dedup();
483
484 Ok(Schedule { dates })
485 }
486
487 pub fn year_fractions(
490 &self,
491 valuation: NaiveDate,
492 day_count: crate::core::daycount::DayCountConvention,
493 ) -> Vec<f64> {
494 self.dates
495 .iter()
496 .map(|d| day_count.year_fraction(valuation, *d))
497 .collect()
498 }
499
500 pub fn len(&self) -> usize {
501 self.dates.len()
502 }
503
504 pub fn is_empty(&self) -> bool {
505 self.dates.is_empty()
506 }
507}
508
509#[cfg(test)]
510mod tests {
511 use super::*;
512
513 fn d(y: i32, m: u32, day: u32) -> NaiveDate {
514 NaiveDate::from_ymd_opt(y, m, day).unwrap()
515 }
516
517 #[test]
518 fn easter_matches_known_years() {
519 assert_eq!(easter_sunday(2024), d(2024, 3, 31));
520 assert_eq!(easter_sunday(2025), d(2025, 4, 20));
521 assert_eq!(easter_sunday(2026), d(2026, 4, 5));
522 assert_eq!(easter_sunday(2027), d(2027, 3, 28));
523 assert_eq!(easter_sunday(2030), d(2030, 4, 21));
524 }
525
526 #[test]
527 fn nyse_holidays_2026() {
528 let c = Calendar::UsNyse;
529 for holiday in [
530 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), ] {
541 assert!(!c.is_business_day(holiday), "{holiday} must be a holiday");
542 }
543 for business in [d(2026, 1, 2), d(2026, 4, 6), d(2026, 7, 6), d(2026, 11, 27)] {
545 assert!(c.is_business_day(business), "{business} must be a business day");
546 }
547 }
548
549 #[test]
550 fn nyse_sunday_new_year_observed_on_monday() {
551 assert!(!Calendar::UsNyse.is_business_day(d(2023, 1, 2)));
553 assert!(Calendar::UsNyse.is_business_day(d(2021, 12, 31)));
555 }
556
557 #[test]
558 fn target_holidays() {
559 let c = Calendar::Target;
560 for holiday in [
561 d(2026, 1, 1),
562 d(2026, 4, 3), d(2026, 4, 6), d(2026, 5, 1), d(2026, 12, 25),
566 d(2025, 12, 26),
568 ] {
569 assert!(!c.is_business_day(holiday), "{holiday} must be a holiday");
570 }
571 assert!(c.is_business_day(d(2026, 5, 25)), "no TARGET holiday on UK spring bank");
572 }
573
574 #[test]
575 fn uk_holidays_2026() {
576 let c = Calendar::UkSettlement;
577 for holiday in [
578 d(2026, 1, 1),
579 d(2026, 4, 3), d(2026, 4, 6), d(2026, 5, 4), d(2026, 5, 25), d(2026, 8, 31), d(2026, 12, 25),
585 d(2026, 12, 28), ] {
587 assert!(!c.is_business_day(holiday), "{holiday} must be a holiday");
588 }
589 assert!(!c.is_business_day(d(2021, 12, 27)));
591 assert!(!c.is_business_day(d(2021, 12, 28)));
592 assert!(c.is_business_day(d(2021, 12, 29)));
593 }
594
595 #[test]
596 fn adjust_conventions() {
597 use BusinessDayConvention::*;
598 let c = Calendar::WeekendsOnly;
599 let saturday = d(2026, 5, 30);
600 assert_eq!(c.adjust(saturday, Unadjusted), saturday);
601 assert_eq!(c.adjust(saturday, Following), d(2026, 6, 1));
602 assert_eq!(c.adjust(saturday, Preceding), d(2026, 5, 29));
603 let sunday_eom = d(2026, 5, 31);
606 assert_eq!(c.adjust(sunday_eom, Following), d(2026, 6, 1));
607 assert_eq!(c.adjust(sunday_eom, ModifiedFollowing), d(2026, 5, 29));
608 let sunday_som = d(2026, 11, 1);
610 assert_eq!(c.adjust(sunday_som, Preceding), d(2026, 10, 30));
611 assert_eq!(c.adjust(sunday_som, ModifiedPreceding), d(2026, 11, 2));
612 }
613
614 #[test]
615 fn business_day_arithmetic_and_settlement_lag() {
616 let c = Calendar::UsNyse;
617 assert_eq!(c.add_business_days(d(2026, 4, 1), 2), d(2026, 4, 6));
619 assert_eq!(c.add_business_days(d(2026, 4, 6), -1), d(2026, 4, 2));
621 assert_eq!(c.business_days_between(d(2026, 4, 1), d(2026, 4, 6)), 2);
623 assert_eq!(c.business_days_between(d(2026, 4, 6), d(2026, 4, 1)), -2);
624 }
625
626 #[test]
627 fn advance_periods_clamp_month_ends() {
628 let c = Calendar::WeekendsOnly;
629 assert_eq!(
632 c.advance(d(2026, 1, 31), Period::Months(1), BusinessDayConvention::Following),
633 d(2026, 3, 2)
634 );
635 assert_eq!(
636 c.advance(d(2026, 1, 31), Period::Months(1), BusinessDayConvention::ModifiedFollowing),
637 d(2026, 2, 27)
638 );
639 assert_eq!(
640 c.advance(d(2026, 3, 15), Period::Years(1), BusinessDayConvention::Following),
641 d(2027, 3, 15)
642 );
643 }
644
645 #[test]
646 fn custom_calendar_takes_explicit_holidays() {
647 let holidays: BTreeSet<NaiveDate> = [d(2026, 3, 17)].into();
648 let c = Calendar::Custom { holidays };
649 assert!(!c.is_business_day(d(2026, 3, 17)));
650 assert!(c.is_business_day(d(2026, 3, 18)));
651 }
652
653 #[test]
654 fn quarterly_backward_schedule_with_front_stub() {
655 let s = Schedule::generate(
657 d(2026, 1, 15),
658 d(2026, 11, 16),
659 3,
660 &Calendar::WeekendsOnly,
661 BusinessDayConvention::Following,
662 DateGeneration::Backward,
663 )
664 .unwrap();
665 assert_eq!(
666 s.dates,
667 vec![d(2026, 2, 16), d(2026, 5, 18), d(2026, 8, 17), d(2026, 11, 16)]
668 );
669 }
672
673 #[test]
674 fn forward_schedule_puts_stub_at_the_back() {
675 let s = Schedule::generate(
676 d(2026, 1, 15),
677 d(2026, 11, 16),
678 3,
679 &Calendar::WeekendsOnly,
680 BusinessDayConvention::Following,
681 DateGeneration::Forward,
682 )
683 .unwrap();
684 assert_eq!(
685 s.dates,
686 vec![d(2026, 4, 15), d(2026, 7, 15), d(2026, 10, 15), d(2026, 11, 16)]
687 );
688 }
689
690 #[test]
691 fn schedule_dates_avoid_holidays() {
692 let s = Schedule::generate(
694 d(2026, 1, 5),
695 d(2026, 6, 3),
696 1,
697 &Calendar::UsNyse,
698 BusinessDayConvention::Following,
699 DateGeneration::Backward,
700 )
701 .unwrap();
702 for date in &s.dates {
703 assert!(
704 Calendar::UsNyse.is_business_day(*date),
705 "{date} is not a business day"
706 );
707 }
708 assert!(s.dates.contains(&d(2026, 4, 6)));
710 }
711
712 #[test]
713 fn schedule_rejects_bad_inputs() {
714 let r = Schedule::generate(
715 d(2026, 5, 1),
716 d(2026, 1, 1),
717 3,
718 &Calendar::WeekendsOnly,
719 BusinessDayConvention::Following,
720 DateGeneration::Backward,
721 );
722 assert!(r.is_err());
723 let r = Schedule::generate(
724 d(2026, 1, 1),
725 d(2026, 5, 1),
726 0,
727 &Calendar::WeekendsOnly,
728 BusinessDayConvention::Following,
729 DateGeneration::Backward,
730 );
731 assert!(r.is_err());
732 }
733}