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*: if the given `date` is a non-business date adding or subtracting 1 business
123    /// day is equivalent to the rolling forwards or backwards, respectively.
124    fn lag_bus_days(&self, date: &NaiveDateTime, days: i32, settlement: bool) -> NaiveDateTime {
125        if self.is_bus_day(date) {
126            return self.add_bus_days(date, days, settlement).unwrap();
127        }
128        match days.cmp(&0_i32) {
129            Ordering::Equal => self.roll_forward_bus_day(date),
130            Ordering::Less => self
131                .add_bus_days(&self.roll_backward_bus_day(date), days + 1, settlement)
132                .unwrap(),
133            Ordering::Greater => self
134                .add_bus_days(&self.roll_forward_bus_day(date), days - 1, settlement)
135                .unwrap(),
136        }
137    }
138
139    /// Add a given number of calendar days to a `date` with the result adjusted to a business day that may or may not
140    /// allow `settlement`.
141    fn add_cal_days(&self, date: &NaiveDateTime, days: i32, adjuster: &Adjuster) -> NaiveDateTime
142    where
143        Self: Sized,
144    {
145        let new_date = if days < 0 {
146            *date - Days::new(u64::try_from(-days).unwrap())
147        } else {
148            *date + Days::new(u64::try_from(days).unwrap())
149        };
150        adjuster.adjust(&new_date, self)
151    }
152
153    /// Add a given number of business days to a `date` with the result adjusted to a business day that may or may
154    /// not allow `settlement`.
155    ///
156    /// *Note*: When adding a positive number of business days the only sensible modifier is
157    /// `Modifier::F` and when subtracting business days it is `Modifier::P`.
158    fn add_bus_days(
159        &self,
160        date: &NaiveDateTime,
161        days: i32,
162        settlement: bool,
163    ) -> Result<NaiveDateTime, PyErr> {
164        if self.is_non_bus_day(date) {
165            return Err(PyValueError::new_err(
166                "Cannot add business days to an input `date` that is not a business day.",
167            ));
168        }
169        let mut new_date = *date;
170        let mut counter: i32 = 0;
171        if days < 0 {
172            // then we subtract business days
173            while counter > days {
174                new_date = self.roll_backward_bus_day(&(new_date - Days::new(1)));
175                counter -= 1;
176            }
177        } else {
178            // add business days
179            while counter < days {
180                new_date = self.roll_forward_bus_day(&(new_date + Days::new(1)));
181                counter += 1;
182            }
183        }
184
185        if !settlement {
186            Ok(new_date)
187        } else if days < 0 {
188            Ok(self.roll_backward_settled_bus_day(&new_date))
189        } else {
190            Ok(self.roll_forward_settled_bus_day(&new_date))
191        }
192    }
193
194    /// Return a vector of business dates between a start and end, inclusive.
195    fn bus_date_range(
196        &self,
197        start: &NaiveDateTime,
198        end: &NaiveDateTime,
199    ) -> Result<Vec<NaiveDateTime>, PyErr> {
200        if self.is_non_bus_day(start) || self.is_non_bus_day(end) {
201            return Err(PyValueError::new_err("`start` and `end` for a calendar `bus_date_range` must both be valid business days"));
202        }
203        let mut vec = Vec::new();
204        let mut sample_date = *start;
205        while sample_date <= *end {
206            vec.push(sample_date);
207            sample_date = self.add_bus_days(&sample_date, 1, false)?;
208        }
209        Ok(vec)
210    }
211
212    /// Return a vector of calendar dates between a start and end, inclusive
213    fn cal_date_range(
214        &self,
215        start: &NaiveDateTime,
216        end: &NaiveDateTime,
217    ) -> Result<Vec<NaiveDateTime>, PyErr> {
218        let mut vec = Vec::new();
219        let mut sample_date = *start;
220        while sample_date <= *end {
221            vec.push(sample_date);
222            sample_date = sample_date + Days::new(1);
223        }
224        Ok(vec)
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use crate::scheduling::{ndt, Cal, CalendarAdjustment, UnionCal};
232
233    fn fixture_hol_cal() -> Cal {
234        let hols = vec![ndt(2015, 9, 5), ndt(2015, 9, 7)]; // Saturday and Monday
235        Cal::new(hols, vec![5, 6])
236    }
237
238    #[test]
239    fn test_roll_forward_bus_day() {
240        let cal = fixture_hol_cal();
241        let hol =
242            NaiveDateTime::parse_from_str("2015-09-07 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
243        let next = cal.roll_forward_bus_day(&hol);
244        assert_eq!(
245            next,
246            NaiveDateTime::parse_from_str("2015-09-08 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap()
247        );
248
249        let sat =
250            NaiveDateTime::parse_from_str("2015-09-05 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
251        let next = cal.roll_forward_bus_day(&sat);
252        assert_eq!(
253            next,
254            NaiveDateTime::parse_from_str("2015-09-08 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap()
255        );
256
257        let fri =
258            NaiveDateTime::parse_from_str("2015-09-04 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
259        let next = cal.roll_forward_bus_day(&fri);
260        assert_eq!(
261            next,
262            NaiveDateTime::parse_from_str("2015-09-04 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap()
263        )
264    }
265
266    #[test]
267    fn test_roll_backward_bus_day() {
268        let cal = fixture_hol_cal();
269        let hol =
270            NaiveDateTime::parse_from_str("2015-09-07 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
271        let prev = cal.roll_backward_bus_day(&hol);
272        assert_eq!(
273            prev,
274            NaiveDateTime::parse_from_str("2015-09-04 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap()
275        );
276
277        let fri =
278            NaiveDateTime::parse_from_str("2015-09-04 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
279        let next = cal.roll_backward_bus_day(&fri);
280        assert_eq!(
281            next,
282            NaiveDateTime::parse_from_str("2015-09-04 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap()
283        )
284    }
285
286    #[test]
287    fn test_is_business_day() {
288        let cal = fixture_hol_cal();
289        let hol =
290            NaiveDateTime::parse_from_str("2015-09-07 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
291        let no_hol =
292            NaiveDateTime::parse_from_str("2015-09-10 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
293        let saturday =
294            NaiveDateTime::parse_from_str("2024-01-06 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
295        assert!(!cal.is_bus_day(&hol)); // Monday in Hol list
296        assert!(cal.is_bus_day(&no_hol)); //Thursday
297        assert!(!cal.is_bus_day(&saturday)); // Saturday
298    }
299
300    #[test]
301    fn test_is_non_business_day() {
302        let cal = fixture_hol_cal();
303        let hol =
304            NaiveDateTime::parse_from_str("2015-09-07 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
305        let no_hol =
306            NaiveDateTime::parse_from_str("2015-09-10 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
307        let saturday =
308            NaiveDateTime::parse_from_str("2024-01-06 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
309        assert!(cal.is_non_bus_day(&hol)); // Monday in Hol list
310        assert!(!cal.is_non_bus_day(&no_hol)); //Thursday
311        assert!(cal.is_non_bus_day(&saturday)); // Saturday
312    }
313
314    #[test]
315    fn test_lag_bus_days() {
316        let cal = fixture_hol_cal();
317        let result = cal.lag_bus_days(&ndt(2015, 9, 7), 1, true);
318        assert_eq!(result, ndt(2015, 9, 8));
319
320        let result = cal.lag_bus_days(&ndt(2025, 2, 15), -1, true);
321        assert_eq!(result, ndt(2025, 2, 14));
322
323        let result = cal.lag_bus_days(&ndt(2015, 9, 7), 0, true);
324        assert_eq!(result, ndt(2015, 9, 8))
325    }
326
327    #[test]
328    fn test_add_days() {
329        let hols = vec![
330            NaiveDateTime::parse_from_str("2015-09-08 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(),
331            NaiveDateTime::parse_from_str("2015-09-10 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(),
332        ];
333        let settle =
334            vec![
335                NaiveDateTime::parse_from_str("2015-09-11 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(),
336            ];
337        let hcal = Cal::new(hols, vec![5, 6]);
338        let scal = Cal::new(settle, vec![5, 6]);
339        let cal = UnionCal::new(vec![hcal], vec![scal].into());
340
341        // without settlement constraint 11th is a valid forward roll date
342        let tue =
343            NaiveDateTime::parse_from_str("2015-09-08 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
344        let next = cal.add_cal_days(&tue, 2, &Adjuster::Following {});
345        assert_eq!(
346            next,
347            NaiveDateTime::parse_from_str("2015-09-11 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap()
348        );
349
350        // with settlement constraint 11th is invalid. Pushed to 14th over weekend.-
351        let tue =
352            NaiveDateTime::parse_from_str("2015-09-08 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
353        let next = cal.add_cal_days(&tue, 2, &Adjuster::FollowingSettle {});
354        assert_eq!(
355            next,
356            NaiveDateTime::parse_from_str("2015-09-14 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap()
357        );
358
359        // without settlement constraint 11th is a valid previous roll date
360        let tue =
361            NaiveDateTime::parse_from_str("2015-09-15 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
362        let prev = cal.add_cal_days(&tue, -2, &Adjuster::Previous {});
363        assert_eq!(
364            prev,
365            NaiveDateTime::parse_from_str("2015-09-11 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap()
366        );
367
368        // with settlement constraint 11th is invalid. Pushed to 9th over holiday.
369        let tue =
370            NaiveDateTime::parse_from_str("2015-09-15 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
371        let prev = cal.add_cal_days(&tue, -2, &Adjuster::PreviousSettle {});
372        assert_eq!(
373            prev,
374            NaiveDateTime::parse_from_str("2015-09-09 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap()
375        );
376    }
377
378    #[test]
379    fn test_add_bus_days() {
380        let hols = vec![ndt(2015, 9, 8), ndt(2015, 9, 10)];
381        let settle = vec![ndt(2015, 9, 11)];
382
383        let hcal = Cal::new(hols, vec![5, 6]);
384        let scal = Cal::new(settle, vec![5, 6]);
385        let cal = UnionCal::new(vec![hcal], vec![scal].into());
386
387        // without settlement constraint 11th is a valid forward roll date
388        let mon = ndt(2015, 9, 7);
389        let next = cal.add_bus_days(&mon, 2, false).unwrap();
390        assert_eq!(next, ndt(2015, 9, 11));
391
392        // with settlement constraint 11th is invalid. Pushed to 14th over weekend.-
393        let next = cal.add_bus_days(&mon, 2, true).unwrap();
394        assert_eq!(next, ndt(2015, 9, 14));
395
396        // without settlement constraint 11th is a valid previous roll date
397        let tue = ndt(2015, 9, 15);
398        let prev = cal.add_bus_days(&tue, -2, false).unwrap();
399        assert_eq!(prev, ndt(2015, 9, 11));
400
401        // with settlement constraint 11th is invalid. Pushed to 9th over holiday.
402        let prev = cal.add_bus_days(&tue, -2, true).unwrap();
403        assert_eq!(prev, ndt(2015, 9, 9));
404    }
405
406    #[test]
407    fn test_add_bus_days_error() {
408        let cal = fixture_hol_cal();
409        match cal.add_bus_days(&ndt(2015, 9, 7), 3, true) {
410            Ok(_) => assert!(false),
411            Err(_) => assert!(true),
412        }
413    }
414
415    #[test]
416    fn test_add_bus_days_with_settlement() {
417        let cal = Cal::new(vec![ndt(2024, 6, 5)], vec![5, 6]);
418        let settle = Cal::new(vec![ndt(2024, 6, 4), ndt(2024, 6, 6)], vec![5, 6]);
419        let union = UnionCal::new(vec![cal], Some(vec![settle]));
420
421        let result = union.add_bus_days(&ndt(2024, 6, 4), 1, false).unwrap();
422        assert_eq!(result, ndt(2024, 6, 6)); //
423        let result = union.add_bus_days(&ndt(2024, 6, 4), 1, true).unwrap();
424        assert_eq!(result, ndt(2024, 6, 7)); //
425
426        let result = union.add_bus_days(&ndt(2024, 6, 6), -1, false).unwrap();
427        assert_eq!(result, ndt(2024, 6, 4)); //
428        let result = union.add_bus_days(&ndt(2024, 6, 6), -1, true).unwrap();
429        assert_eq!(result, ndt(2024, 6, 3)); //
430    }
431
432    #[test]
433    fn test_rolls() {
434        let cal = fixture_hol_cal();
435        let udates = vec![
436            ndt(2015, 9, 4),
437            ndt(2015, 9, 5),
438            ndt(2015, 9, 6),
439            ndt(2015, 9, 7),
440        ];
441        let result = cal.adjusts(&udates, &Adjuster::Following {});
442        assert_eq!(
443            result,
444            vec![
445                ndt(2015, 9, 4),
446                ndt(2015, 9, 8),
447                ndt(2015, 9, 8),
448                ndt(2015, 9, 8)
449            ]
450        );
451    }
452}