rateslib/scheduling/calendars/
dateroll.rs

1use chrono::prelude::*;
2use chrono::Days;
3use pyo3::exceptions::PyValueError;
4use pyo3::PyErr;
5use std::cmp::Ordering;
6
7use crate::scheduling::{Adjuster, Adjustment};
8
9/// Simple date adjustment defining business, settleable and holidays and rolling.
10pub trait DateRoll {
11    /// Returns whether the date is part of the general working week.
12    fn is_weekday(&self, date: &NaiveDateTime) -> bool;
13
14    /// Returns whether the date is a specific holiday excluded from the regular working week.
15    fn is_holiday(&self, date: &NaiveDateTime) -> bool;
16
17    /// Returns whether the date is valid relative to an associated settlement calendar.
18    ///
19    /// If the holiday calendar object has no associated settlement calendar this should return `true`
20    /// for any date.
21    fn is_settlement(&self, date: &NaiveDateTime) -> bool;
22
23    /// Returns whether the date is a business day, i.e. part of the working week and not a holiday.
24    fn is_bus_day(&self, date: &NaiveDateTime) -> bool {
25        self.is_weekday(date) && !self.is_holiday(date)
26    }
27
28    /// Returns whether the date is not a business day, i.e. either not in working week or a specific holiday.
29    fn is_non_bus_day(&self, date: &NaiveDateTime) -> bool {
30        !self.is_bus_day(date)
31    }
32
33    /// Return the `date`, if a business day, or get the next business date after `date`.
34    fn roll_forward_bus_day(&self, date: &NaiveDateTime) -> NaiveDateTime {
35        let mut new_date = *date;
36        while !self.is_bus_day(&new_date) {
37            new_date = new_date + Days::new(1);
38        }
39        new_date
40    }
41
42    /// Return the `date`, if a business day, or get the business day preceding `date`.
43    fn roll_backward_bus_day(&self, date: &NaiveDateTime) -> NaiveDateTime {
44        let mut new_date = *date;
45        while !self.is_bus_day(&new_date) {
46            new_date = new_date - Days::new(1);
47        }
48        new_date
49    }
50
51    /// Return the `date`, if a business day, or get the proceeding business date, without rolling
52    /// into a new month.
53    fn roll_mod_forward_bus_day(&self, date: &NaiveDateTime) -> NaiveDateTime {
54        let new_date = self.roll_forward_bus_day(date);
55        if new_date.month() != date.month() {
56            self.roll_backward_bus_day(date)
57        } else {
58            new_date
59        }
60    }
61
62    /// Return the `date`, if a business day, or get the proceeding business date, without rolling
63    /// into a new month.
64    fn roll_mod_backward_bus_day(&self, date: &NaiveDateTime) -> NaiveDateTime {
65        let new_date = self.roll_backward_bus_day(date);
66        if new_date.month() != date.month() {
67            self.roll_forward_bus_day(date)
68        } else {
69            new_date
70        }
71    }
72
73    /// Return the date, if a business day that can be settled, or the proceeding date that is such.
74    ///
75    /// If the calendar has no associated settlement calendar this is identical to `roll_forward_bus_day`.
76    fn roll_forward_settled_bus_day(&self, date: &NaiveDateTime) -> NaiveDateTime {
77        let mut new_date = self.roll_forward_bus_day(date);
78        while !self.is_settlement(&new_date) {
79            new_date = self.roll_forward_bus_day(&(new_date + Days::new(1)));
80        }
81        new_date
82    }
83
84    /// Return the date, if a business day that can be settled, or the preceding date that is such.
85    ///
86    /// If the calendar has no associated settlement calendar this is identical to `roll_backward_bus_day`.
87    fn roll_backward_settled_bus_day(&self, date: &NaiveDateTime) -> NaiveDateTime {
88        let mut new_date = self.roll_backward_bus_day(date);
89        while !self.is_settlement(&new_date) {
90            new_date = self.roll_backward_bus_day(&(new_date - Days::new(1)));
91        }
92        new_date
93    }
94
95    /// Return the `date`, if a business day that can be settled, or get the proceeding
96    /// such date, without rolling into a new month.
97    fn roll_forward_mod_settled_bus_day(&self, date: &NaiveDateTime) -> NaiveDateTime {
98        let new_date = self.roll_forward_settled_bus_day(date);
99        if new_date.month() != date.month() {
100            self.roll_backward_settled_bus_day(date)
101        } else {
102            new_date
103        }
104    }
105
106    /// Return the `date`, if a business day that can be settled, or get the preceding such date, without rolling
107    /// into a new month.
108    fn roll_backward_mod_settled_bus_day(&self, date: &NaiveDateTime) -> NaiveDateTime {
109        let new_date = self.roll_backward_settled_bus_day(date);
110        if new_date.month() != date.month() {
111            self.roll_forward_settled_bus_day(date)
112        } else {
113            new_date
114        }
115    }
116
117    /// Adjust a date by a number of business days, under lag rules.
118    ///
119    /// *Note*: if the number of business days is **zero** a non-business day will be rolled
120    /// **forwards**.
121    ///
122    /// *Note*: `settlement` enforcement is handled post date determination. If the number of
123    /// business `days` is zero or greater the date is rolled forwards to the nearest settleable
124    /// day if not already one.
125    /// If the number of business `days` is less than zero then the date is rolled backwards
126    /// to the nearest settleable date.
127    ///
128    /// *Note*: if the given `date` is a non-business date adding or subtracting 1 business
129    /// day is equivalent to the rolling forwards or backwards, respectively.
130    fn lag_bus_days(&self, date: &NaiveDateTime, days: i32, settlement: bool) -> NaiveDateTime {
131        if self.is_bus_day(date) {
132            return self.add_bus_days(date, days, settlement).unwrap();
133        }
134        match days.cmp(&0_i32) {
135            Ordering::Equal => self
136                .add_bus_days(&self.roll_forward_bus_day(date), 0, settlement)
137                .unwrap(),
138            Ordering::Less => self
139                .add_bus_days(&self.roll_backward_bus_day(date), days + 1, settlement)
140                .unwrap(),
141            Ordering::Greater => self
142                .add_bus_days(&self.roll_forward_bus_day(date), days - 1, settlement)
143                .unwrap(),
144        }
145    }
146
147    /// Add a given number of calendar days to a `date` with the result adjusted to a business day that may or may not
148    /// allow `settlement`.
149    fn add_cal_days(&self, date: &NaiveDateTime, days: i32, adjuster: &Adjuster) -> NaiveDateTime
150    where
151        Self: Sized,
152    {
153        let new_date = if days < 0 {
154            *date - Days::new(u64::try_from(-days).unwrap())
155        } else {
156            *date + Days::new(u64::try_from(days).unwrap())
157        };
158        adjuster.adjust(&new_date, self)
159    }
160
161    /// Add a given number of business days to a `date` with the result adjusted to a business day that may or may
162    /// not allow `settlement`.
163    ///
164    /// *Note*: When adding a positive number of business days the only sensible modifier is
165    /// `Modifier::F` and when subtracting business days it is `Modifier::P`.
166    fn add_bus_days(
167        &self,
168        date: &NaiveDateTime,
169        days: i32,
170        settlement: bool,
171    ) -> Result<NaiveDateTime, PyErr> {
172        if self.is_non_bus_day(date) {
173            return Err(PyValueError::new_err(
174                "Cannot add business days to an input `date` that is not a business day.",
175            ));
176        }
177        let mut new_date = *date;
178        let mut counter: i32 = 0;
179        if days < 0 {
180            // then we subtract business days
181            while counter > days {
182                new_date = self.roll_backward_bus_day(&(new_date - Days::new(1)));
183                counter -= 1;
184            }
185        } else {
186            // add business days
187            while counter < days {
188                new_date = self.roll_forward_bus_day(&(new_date + Days::new(1)));
189                counter += 1;
190            }
191        }
192
193        if !settlement {
194            Ok(new_date)
195        } else if days < 0 {
196            Ok(self.roll_backward_settled_bus_day(&new_date))
197        } else {
198            Ok(self.roll_forward_settled_bus_day(&new_date))
199        }
200    }
201
202    /// Return a vector of business dates between a start and end, inclusive.
203    fn bus_date_range(
204        &self,
205        start: &NaiveDateTime,
206        end: &NaiveDateTime,
207    ) -> Result<Vec<NaiveDateTime>, PyErr> {
208        if self.is_non_bus_day(start) || self.is_non_bus_day(end) {
209            return Err(PyValueError::new_err("`start` and `end` for a calendar `bus_date_range` must both be valid business days"));
210        }
211        let mut vec = Vec::new();
212        let mut sample_date = *start;
213        while sample_date <= *end {
214            vec.push(sample_date);
215            sample_date = self.add_bus_days(&sample_date, 1, false)?;
216        }
217        Ok(vec)
218    }
219
220    /// Return a vector of calendar dates between a start and end, inclusive
221    fn cal_date_range(
222        &self,
223        start: &NaiveDateTime,
224        end: &NaiveDateTime,
225    ) -> Result<Vec<NaiveDateTime>, PyErr> {
226        let mut vec = Vec::new();
227        let mut sample_date = *start;
228        while sample_date <= *end {
229            vec.push(sample_date);
230            sample_date = sample_date + Days::new(1);
231        }
232        Ok(vec)
233    }
234
235    /// Print a representation of the month of the object.
236    fn print_month(&self, year: i32, month: u8) -> String {
237        let _map: Vec<String> = vec![
238            format!("        January {}\n", year),
239            format!("       February {}\n", year),
240            format!("          March {}\n", year),
241            format!("          April {}\n", year),
242            format!("            May {}\n", year),
243            format!("           June {}\n", year),
244            format!("           July {}\n", year),
245            format!("         August {}\n", year),
246            format!("      September {}\n", year),
247            format!("        October {}\n", year),
248            format!("       November {}\n", year),
249            format!("       December {}\n", year),
250        ];
251        let mut output = _map[(month - 1) as usize].clone();
252        output += "Su Mo Tu We Th Fr Sa\n";
253
254        let month_obj = Month::try_from(month).unwrap();
255        let days: u8 = month_obj.num_days(year).unwrap();
256        let weekday = NaiveDate::from_ymd_opt(year, month.into(), 1)
257            .unwrap()
258            .weekday()
259            .num_days_from_monday();
260        let idx_start: u32 = (weekday + 1) % 7;
261
262        let mut arr: [String; 42] = std::array::from_fn(|_| String::from("  "));
263        for i in 0..days {
264            let date = NaiveDate::from_ymd_opt(year, month.into(), (i + 1).into())
265                .expect("`year`, `month` `day` are invalid.")
266                .and_hms_opt(0, 0, 0)
267                .unwrap();
268            let s: String = {
269                if self.is_bus_day(&date) && self.is_settlement(&date) {
270                    format!("{:>2}", i + 1)
271                } else if self.is_bus_day(&date) && !self.is_settlement(&date) {
272                    " X".to_string()
273                } else if !self.is_bus_day(&date)
274                    && matches!(date.weekday(), Weekday::Sat | Weekday::Sun)
275                {
276                    " .".to_string()
277                } else {
278                    " *".to_string()
279                }
280            };
281            let index: u32 = i as u32 + idx_start;
282            arr[index as usize] = s;
283        }
284
285        for row in 0..6 {
286            output += &format!(
287                "{} {} {} {} {} {} {}\n",
288                &arr[row * 7],
289                &arr[row * 7 + 1],
290                &arr[row * 7 + 2],
291                &arr[row * 7 + 3],
292                &arr[row * 7 + 4],
293                &arr[row * 7 + 5],
294                &arr[row * 7 + 6]
295            );
296        }
297        output
298    }
299
300    /// Print a representation of a year of the object.
301    fn print_year(&self, year: i32) -> String {
302        let mut data: Vec<Vec<String>> = vec![];
303        for i in 1..13 {
304            data.push(
305                self.print_month(year, i)
306                    .lines()
307                    .map(|s| s.to_string())
308                    .collect(),
309            );
310        }
311        let mut output = "\n".to_string();
312        for i in 0..8 {
313            output += &format!(
314                "{}   {}   {}   {}\n",
315                data[0][i], data[3][i], data[6][i], data[9][i]
316            );
317        }
318        for i in 0..8 {
319            output += &format!(
320                "{}   {}   {}   {}\n",
321                data[1][i], data[4][i], data[7][i], data[10][i]
322            );
323        }
324        for i in 0..8 {
325            output += &format!(
326                "{}   {}   {}   {}\n",
327                data[2][i], data[5][i], data[8][i], data[11][i]
328            );
329        }
330        output += "Legend:\n";
331        output += "'1-31': Settleable business day         'X': Non-settleable business day\n";
332        output += "   '.': Non-business weekend            '*': Non-business day\n";
333        output
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use crate::scheduling::{ndt, Cal, CalendarAdjustment, UnionCal};
341
342    fn fixture_hol_cal() -> Cal {
343        let hols = vec![ndt(2015, 9, 5), ndt(2015, 9, 7)]; // Saturday and Monday
344        Cal::new(hols, vec![5, 6])
345    }
346
347    #[test]
348    fn test_roll_forward_bus_day() {
349        let cal = fixture_hol_cal();
350        let hol =
351            NaiveDateTime::parse_from_str("2015-09-07 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
352        let next = cal.roll_forward_bus_day(&hol);
353        assert_eq!(
354            next,
355            NaiveDateTime::parse_from_str("2015-09-08 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap()
356        );
357
358        let sat =
359            NaiveDateTime::parse_from_str("2015-09-05 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
360        let next = cal.roll_forward_bus_day(&sat);
361        assert_eq!(
362            next,
363            NaiveDateTime::parse_from_str("2015-09-08 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap()
364        );
365
366        let fri =
367            NaiveDateTime::parse_from_str("2015-09-04 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
368        let next = cal.roll_forward_bus_day(&fri);
369        assert_eq!(
370            next,
371            NaiveDateTime::parse_from_str("2015-09-04 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap()
372        )
373    }
374
375    #[test]
376    fn test_roll_backward_bus_day() {
377        let cal = fixture_hol_cal();
378        let hol =
379            NaiveDateTime::parse_from_str("2015-09-07 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
380        let prev = cal.roll_backward_bus_day(&hol);
381        assert_eq!(
382            prev,
383            NaiveDateTime::parse_from_str("2015-09-04 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap()
384        );
385
386        let fri =
387            NaiveDateTime::parse_from_str("2015-09-04 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
388        let next = cal.roll_backward_bus_day(&fri);
389        assert_eq!(
390            next,
391            NaiveDateTime::parse_from_str("2015-09-04 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap()
392        )
393    }
394
395    #[test]
396    fn test_is_business_day() {
397        let cal = fixture_hol_cal();
398        let hol =
399            NaiveDateTime::parse_from_str("2015-09-07 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
400        let no_hol =
401            NaiveDateTime::parse_from_str("2015-09-10 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
402        let saturday =
403            NaiveDateTime::parse_from_str("2024-01-06 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
404        assert!(!cal.is_bus_day(&hol)); // Monday in Hol list
405        assert!(cal.is_bus_day(&no_hol)); //Thursday
406        assert!(!cal.is_bus_day(&saturday)); // Saturday
407    }
408
409    #[test]
410    fn test_is_non_business_day() {
411        let cal = fixture_hol_cal();
412        let hol =
413            NaiveDateTime::parse_from_str("2015-09-07 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
414        let no_hol =
415            NaiveDateTime::parse_from_str("2015-09-10 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
416        let saturday =
417            NaiveDateTime::parse_from_str("2024-01-06 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
418        assert!(cal.is_non_bus_day(&hol)); // Monday in Hol list
419        assert!(!cal.is_non_bus_day(&no_hol)); //Thursday
420        assert!(cal.is_non_bus_day(&saturday)); // Saturday
421    }
422
423    #[test]
424    fn test_lag_bus_days() {
425        let cal = fixture_hol_cal();
426        let result = cal.lag_bus_days(&ndt(2015, 9, 7), 1, true);
427        assert_eq!(result, ndt(2015, 9, 8));
428
429        let result = cal.lag_bus_days(&ndt(2025, 2, 15), -1, true);
430        assert_eq!(result, ndt(2025, 2, 14));
431
432        let result = cal.lag_bus_days(&ndt(2015, 9, 7), 0, true);
433        assert_eq!(result, ndt(2015, 9, 8))
434    }
435
436    #[test]
437    fn test_add_days() {
438        let hols = vec![
439            NaiveDateTime::parse_from_str("2015-09-08 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(),
440            NaiveDateTime::parse_from_str("2015-09-10 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(),
441        ];
442        let settle =
443            vec![
444                NaiveDateTime::parse_from_str("2015-09-11 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(),
445            ];
446        let hcal = Cal::new(hols, vec![5, 6]);
447        let scal = Cal::new(settle, vec![5, 6]);
448        let cal = UnionCal::new(vec![hcal], vec![scal].into());
449
450        // without settlement constraint 11th is a valid forward roll date
451        let tue =
452            NaiveDateTime::parse_from_str("2015-09-08 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
453        let next = cal.add_cal_days(&tue, 2, &Adjuster::Following {});
454        assert_eq!(
455            next,
456            NaiveDateTime::parse_from_str("2015-09-11 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap()
457        );
458
459        // with settlement constraint 11th is invalid. Pushed to 14th over weekend.-
460        let tue =
461            NaiveDateTime::parse_from_str("2015-09-08 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
462        let next = cal.add_cal_days(&tue, 2, &Adjuster::FollowingSettle {});
463        assert_eq!(
464            next,
465            NaiveDateTime::parse_from_str("2015-09-14 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap()
466        );
467
468        // without settlement constraint 11th is a valid previous roll date
469        let tue =
470            NaiveDateTime::parse_from_str("2015-09-15 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
471        let prev = cal.add_cal_days(&tue, -2, &Adjuster::Previous {});
472        assert_eq!(
473            prev,
474            NaiveDateTime::parse_from_str("2015-09-11 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap()
475        );
476
477        // with settlement constraint 11th is invalid. Pushed to 9th over holiday.
478        let tue =
479            NaiveDateTime::parse_from_str("2015-09-15 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
480        let prev = cal.add_cal_days(&tue, -2, &Adjuster::PreviousSettle {});
481        assert_eq!(
482            prev,
483            NaiveDateTime::parse_from_str("2015-09-09 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap()
484        );
485    }
486
487    #[test]
488    fn test_add_bus_days() {
489        let hols = vec![ndt(2015, 9, 8), ndt(2015, 9, 10)];
490        let settle = vec![ndt(2015, 9, 11)];
491
492        let hcal = Cal::new(hols, vec![5, 6]);
493        let scal = Cal::new(settle, vec![5, 6]);
494        let cal = UnionCal::new(vec![hcal], vec![scal].into());
495
496        // without settlement constraint 11th is a valid forward roll date
497        let mon = ndt(2015, 9, 7);
498        let next = cal.add_bus_days(&mon, 2, false).unwrap();
499        assert_eq!(next, ndt(2015, 9, 11));
500
501        // with settlement constraint 11th is invalid. Pushed to 14th over weekend.-
502        let next = cal.add_bus_days(&mon, 2, true).unwrap();
503        assert_eq!(next, ndt(2015, 9, 14));
504
505        // without settlement constraint 11th is a valid previous roll date
506        let tue = ndt(2015, 9, 15);
507        let prev = cal.add_bus_days(&tue, -2, false).unwrap();
508        assert_eq!(prev, ndt(2015, 9, 11));
509
510        // with settlement constraint 11th is invalid. Pushed to 9th over holiday.
511        let prev = cal.add_bus_days(&tue, -2, true).unwrap();
512        assert_eq!(prev, ndt(2015, 9, 9));
513    }
514
515    #[test]
516    fn test_add_bus_days_error() {
517        let cal = fixture_hol_cal();
518        match cal.add_bus_days(&ndt(2015, 9, 7), 3, true) {
519            Ok(_) => assert!(false),
520            Err(_) => assert!(true),
521        }
522    }
523
524    #[test]
525    fn test_add_bus_days_with_settlement() {
526        let cal = Cal::new(vec![ndt(2024, 6, 5)], vec![5, 6]);
527        let settle = Cal::new(vec![ndt(2024, 6, 4), ndt(2024, 6, 6)], vec![5, 6]);
528        let union = UnionCal::new(vec![cal], Some(vec![settle]));
529
530        let result = union.add_bus_days(&ndt(2024, 6, 4), 1, false).unwrap();
531        assert_eq!(result, ndt(2024, 6, 6)); //
532        let result = union.add_bus_days(&ndt(2024, 6, 4), 1, true).unwrap();
533        assert_eq!(result, ndt(2024, 6, 7)); //
534
535        let result = union.add_bus_days(&ndt(2024, 6, 6), -1, false).unwrap();
536        assert_eq!(result, ndt(2024, 6, 4)); //
537        let result = union.add_bus_days(&ndt(2024, 6, 6), -1, true).unwrap();
538        assert_eq!(result, ndt(2024, 6, 3)); //
539    }
540
541    #[test]
542    fn test_rolls() {
543        let cal = fixture_hol_cal();
544        let udates = vec![
545            ndt(2015, 9, 4),
546            ndt(2015, 9, 5),
547            ndt(2015, 9, 6),
548            ndt(2015, 9, 7),
549        ];
550        let result = cal.adjusts(&udates, &Adjuster::Following {});
551        assert_eq!(
552            result,
553            vec![
554                ndt(2015, 9, 4),
555                ndt(2015, 9, 8),
556                ndt(2015, 9, 8),
557                ndt(2015, 9, 8)
558            ]
559        );
560    }
561
562    #[test]
563    fn test_lag_bus_days_zero_with_settlement() {
564        // Test ModifiedPrevious and ModifiedPreviousSettle from the book diagram
565        let cal = Cal::new(vec![ndt(2000, 6, 27)], vec![]);
566        let settle = Cal::new(vec![ndt(2000, 6, 26), ndt(2000, 6, 28)], vec![]);
567        let uni = UnionCal::new(vec![cal], Some(vec![settle]));
568
569        // adding zero bus days not settleable yields 28th June
570        assert_eq!(
571            ndt(2000, 6, 28),
572            uni.lag_bus_days(&ndt(2000, 6, 27), 0, false)
573        );
574
575        // adding zero bus days settleable yields 29th June
576        assert_eq!(
577            ndt(2000, 6, 29),
578            uni.lag_bus_days(&ndt(2000, 6, 27), 0, true)
579        );
580
581        // adding zero bus days not settleable yields 28th June
582        assert_eq!(
583            ndt(2000, 6, 28),
584            uni.lag_bus_days(&ndt(2000, 6, 28), 0, false)
585        );
586
587        // adding zero bus days settleable yields 29th June
588        assert_eq!(
589            ndt(2000, 6, 29),
590            uni.lag_bus_days(&ndt(2000, 6, 28), 0, true)
591        );
592    }
593
594    #[test]
595    fn test_print_month() {
596        let cal = Cal::new(vec![ndt(2026, 1, 1), ndt(2026, 1, 19)], vec![5, 6]);
597        let result = cal.print_month(2026, 1);
598        let raw_output = r#"        January 2026
599Su Mo Tu We Th Fr Sa
600             *  2  .
601 .  5  6  7  8  9  .
602 . 12 13 14 15 16  .
603 .  * 20 21 22 23  .
604 . 26 27 28 29 30  .
605$$$$$$$$$$$$$$$$$$$$
606"#;
607        let expected = raw_output.replace("$", " ");
608        assert_eq!(result, expected);
609    }
610
611    #[test]
612    fn test_print_year() {
613        let cal = Cal::new(vec![ndt(2026, 1, 1), ndt(2026, 1, 19)], vec![5, 6]);
614        let result = cal.print_year(2026);
615        println!("{}", result);
616        let raw_output = r#"
617        January 2026             April 2026              July 2026           October 2026
618Su Mo Tu We Th Fr Sa   Su Mo Tu We Th Fr Sa   Su Mo Tu We Th Fr Sa   Su Mo Tu We Th Fr Sa
619             *  2  .             1  2  3  .             1  2  3  .                1  2  .
620 .  5  6  7  8  9  .    .  6  7  8  9 10  .    .  6  7  8  9 10  .    .  5  6  7  8  9  .
621 . 12 13 14 15 16  .    . 13 14 15 16 17  .    . 13 14 15 16 17  .    . 12 13 14 15 16  .
622 .  * 20 21 22 23  .    . 20 21 22 23 24  .    . 20 21 22 23 24  .    . 19 20 21 22 23  .
623 . 26 27 28 29 30  .    . 27 28 29 30          . 27 28 29 30 31       . 26 27 28 29 30  .
624$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$
625       February 2026               May 2026            August 2026          November 2026
626Su Mo Tu We Th Fr Sa   Su Mo Tu We Th Fr Sa   Su Mo Tu We Th Fr Sa   Su Mo Tu We Th Fr Sa
627 .  2  3  4  5  6  .                   1  .                      .    .  2  3  4  5  6  .
628 .  9 10 11 12 13  .    .  4  5  6  7  8  .    .  3  4  5  6  7  .    .  9 10 11 12 13  .
629 . 16 17 18 19 20  .    . 11 12 13 14 15  .    . 10 11 12 13 14  .    . 16 17 18 19 20  .
630 . 23 24 25 26 27  .    . 18 19 20 21 22  .    . 17 18 19 20 21  .    . 23 24 25 26 27  .
631                        . 25 26 27 28 29  .    . 24 25 26 27 28  .    . 30$$$$$$$$$$$$$$$
632                        .                      . 31$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$
633          March 2026              June 2026         September 2026          December 2026
634Su Mo Tu We Th Fr Sa   Su Mo Tu We Th Fr Sa   Su Mo Tu We Th Fr Sa   Su Mo Tu We Th Fr Sa
635 .  2  3  4  5  6  .       1  2  3  4  5  .          1  2  3  4  .          1  2  3  4  .
636 .  9 10 11 12 13  .    .  8  9 10 11 12  .    .  7  8  9 10 11  .    .  7  8  9 10 11  .
637 . 16 17 18 19 20  .    . 15 16 17 18 19  .    . 14 15 16 17 18  .    . 14 15 16 17 18  .
638 . 23 24 25 26 27  .    . 22 23 24 25 26  .    . 21 22 23 24 25  .    . 21 22 23 24 25  .
639 . 30 31                . 29 30                . 28 29 30             . 28 29 30 31$$$$$$
640$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$
641Legend:
642'1-31': Settleable business day         'X': Non-settleable business day
643   '.': Non-business weekend            '*': Non-business day
644"#;
645        let expected = raw_output.replace("$", " ");
646
647        let result_lines: Vec<&str> = result.lines().collect();
648        let expected_lines: Vec<&str> = expected.lines().collect();
649        for i in 0..result_lines.len() {
650            assert_eq!(expected_lines[i], result_lines[i]);
651        }
652    }
653}