rateslib/scheduling/frequency/
rollday.rs

1use chrono::prelude::*;
2use indexmap::IndexSet;
3use pyo3::exceptions::PyValueError;
4use pyo3::prelude::*;
5use serde::{Deserialize, Serialize};
6use std::cmp::{Eq, PartialEq};
7
8use crate::scheduling::{Adjuster, Adjustment, Calendar, Imm};
9
10/// A roll-day used with a [`Frequency::Months`](crate::scheduling::Frequency) variant.
11#[pyclass(module = "rateslib.rs", eq)]
12#[derive(Debug, Copy, Hash, Clone, PartialEq, Eq, Deserialize, Serialize)]
13pub enum RollDay {
14    /// A day of the month in [1, 31].
15    Day(u32),
16    /// The third Wednesday of any month (equivalent to [Imm::Wed3](crate::scheduling::Imm))
17    IMM(),
18}
19
20impl RollDay {
21    /// Get all possible [`RollDay`] variants implied from one or more unadjusted dates.
22    ///
23    /// # Notes
24    /// Each date is analysed in turn. The order of [`RollDay`] construction for each date is:
25    ///
26    /// - Get the integer roll-day of the date.
27    /// - Get additional end-of-month related integer roll-days for short calendar months if necessary.
28    /// - Get non-numeric roll-days if date aligns with those, ordered by the underlying enum order.
29    ///
30    /// When multiple dates are checked the results for a subsequent date is added to the prior
31    /// results under the [`IndexSet.intersection`] ordering rules.
32    ///
33    /// Any date will always return at least one [RollDay] and the first one will always be
34    /// equivalent to an integer variant whose day equals the calendar day of the first date.
35    ///
36    /// # Examples
37    /// ```rust
38    /// # use rateslib::scheduling::{RollDay, ndt};
39    /// let result = RollDay::vec_from(&vec![ndt(2024, 2, 29), ndt(2024, 3, 20), ndt(2024, 3, 31)]);
40    /// assert_eq!(result, vec![
41    ///     RollDay::Day(29),
42    ///     RollDay::Day(30),
43    ///     RollDay::Day(31),
44    ///     RollDay::Day(20),
45    ///     RollDay::IMM(),
46    /// ]);
47    /// ```
48    pub fn vec_from(udates: &Vec<NaiveDateTime>) -> Vec<Self> {
49        let mut set: IndexSet<RollDay> = IndexSet::new();
50
51        for udate in udates {
52            // numeric first
53            let mut v: Vec<Self> = vec![RollDay::Day(udate.day())];
54            // EoM check
55            if Imm::Eom.validate(udate) {
56                let mut day = udate.day() + 1;
57                while day < 32 {
58                    v.push(RollDay::Day(day));
59                    day = day + 1;
60                }
61            }
62            // IMM check
63            if Imm::Wed3.validate(udate) {
64                v.push(RollDay::IMM())
65            }
66            // Intersect existing results
67            set.append(&mut IndexSet::<RollDay>::from_iter(v));
68        }
69        set.into_iter().collect()
70    }
71
72    /// Validate whether an unadjusted date is an allowed value under the [`RollDay`] definition.
73    ///
74    /// # Examples
75    /// ```rust
76    /// # use rateslib::scheduling::{RollDay, ndt};
77    /// let date = RollDay::Day(31).try_udate(&ndt(2024, 2, 29));
78    /// assert!(date.is_ok());
79    ///
80    /// let date = RollDay::IMM().try_udate(&ndt(2024, 1, 1));
81    /// assert!(date.is_err());
82    /// ```
83    pub fn try_udate(&self, udate: &NaiveDateTime) -> Result<NaiveDateTime, PyErr> {
84        let msg = "`udate` does not align with given `RollDay`.".to_string();
85        match self {
86            RollDay::Day(31) => {
87                if Imm::Eom.validate(udate) {
88                    Ok(*udate)
89                } else {
90                    Err(PyValueError::new_err(msg))
91                }
92            }
93            RollDay::Day(30) => {
94                if (Imm::Eom.validate(udate) && udate.day() < 30) || udate.day() == 30 {
95                    Ok(*udate)
96                } else {
97                    Err(PyValueError::new_err(msg))
98                }
99            }
100            RollDay::Day(29) => {
101                if (Imm::Eom.validate(udate) && udate.day() < 29) || udate.day() == 29 {
102                    Ok(*udate)
103                } else {
104                    Err(PyValueError::new_err(msg))
105                }
106            }
107            RollDay::IMM() => {
108                if Imm::Wed3.validate(udate) {
109                    Ok(*udate)
110                } else {
111                    Err(PyValueError::new_err(msg))
112                }
113            }
114            RollDay::Day(value) => {
115                if udate.day() == *value {
116                    Ok(*udate)
117                } else {
118                    Err(PyValueError::new_err(msg))
119                }
120            }
121        }
122    }
123
124    /// Add a given number of months to an unadjusted date under the [RollDay] definition.
125    ///
126    /// # Notes
127    /// This method will also check the given `udate` using [RollDay::try_udate].
128    ///
129    /// # Examples
130    /// ```rust
131    /// # use rateslib::scheduling::{RollDay, ndt};
132    /// let date = RollDay::IMM().try_uadd(&ndt(2024, 3, 20), 3);
133    /// assert_eq!(ndt(2024, 6, 19), date.unwrap());
134    ///
135    /// let date = RollDay::Day(31).try_uadd(&ndt(2024, 3, 15), 3);
136    /// assert!(date.is_err());
137    /// ```
138    pub fn try_uadd(&self, udate: &NaiveDateTime, months: i32) -> Result<NaiveDateTime, PyErr> {
139        let _ = self.try_udate(udate)?;
140        Ok(self.uadd(udate, months))
141    }
142
143    /// Add a given number of months to an unadjusted date under the [RollDay] definition.
144    ///
145    /// # Examples
146    /// ```rust
147    /// # use rateslib::scheduling::{RollDay, ndt};
148    /// let date = RollDay::Day(31).uadd(&ndt(2024, 3, 15), 3);
149    /// assert_eq!(date, ndt(2024, 6, 30));
150    /// ```
151    pub fn uadd(&self, udate: &NaiveDateTime, months: i32) -> NaiveDateTime {
152        // convert months to a set of years and remainder months
153        let mut yr_roll = (months.abs() / 12) * months.signum();
154        let rem_months = months - yr_roll * 12;
155
156        // determine the new month
157        let mut new_month = i32::try_from(udate.month()).unwrap() + rem_months;
158        if new_month <= 0 {
159            yr_roll -= 1;
160            new_month = new_month.rem_euclid(12);
161        } else if new_month >= 13 {
162            yr_roll += 1;
163            new_month = new_month.rem_euclid(12);
164        }
165        if new_month == 0 {
166            new_month = 12;
167        }
168
169        // perform the date roll
170        self.try_from_ym(udate.year() + yr_roll, new_month.try_into().unwrap())
171            .unwrap()
172    }
173
174    /// Return a specific date given the `month`, `year` that aligns with the [RollDay].
175    ///     
176    /// # Examples
177    /// ```rust
178    /// # use rateslib::scheduling::{RollDay, ndt};
179    /// let date = RollDay::Day(31).try_from_ym(2024, 2);
180    /// # let date = date.unwrap();
181    /// assert_eq!(date, ndt(2024, 2, 29));
182    /// ```
183    pub fn try_from_ym(&self, year: i32, month: u32) -> Result<NaiveDateTime, PyErr> {
184        match self {
185            RollDay::Day(value) => Ok(get_roll_by_day(year, month, *value)),
186            RollDay::IMM {} => Imm::Wed3.from_ym_opt(year, month),
187        }
188    }
189}
190
191/// Get unadjusted date alternatives for an associated adjusted date.
192///
193/// Note this only handles simple date rolling operations, and does not generalise to any
194/// possible adjuster.
195pub(crate) fn get_unadjusteds(
196    date: &NaiveDateTime,
197    adjuster: &Adjuster,
198    calendar: &Calendar,
199) -> Vec<NaiveDateTime> {
200    let mut udates: Vec<NaiveDateTime> = vec![];
201
202    // always return at least `date`
203    udates.push(*date);
204
205    // get the vector of reversals and filter out date
206    let reversals: Vec<NaiveDateTime> = adjuster
207        .reverse(date, calendar)
208        .into_iter()
209        .filter(|v| v != date)
210        .collect();
211    udates.extend(reversals);
212    udates
213}
214
215/// Return a specific roll date given the `month`, `year` and `roll`.
216fn get_roll_by_day(year: i32, month: u32, day: u32) -> NaiveDateTime {
217    let d = NaiveDate::from_ymd_opt(year, month, day);
218    match d {
219        Some(date) => NaiveDateTime::new(date, NaiveTime::from_hms_opt(0, 0, 0).unwrap()),
220        None => {
221            if day > 28 {
222                get_roll_by_day(year, month, day - 1)
223            } else {
224                panic!("Unexpected error in `get_roll_by_day`")
225            }
226        }
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use crate::scheduling::{ndt, Cal};
234
235    fn fixture_bus_cal() -> Calendar {
236        Cal::try_from_name("bus").unwrap().into()
237    }
238
239    #[test]
240    fn test_rollday_equality() {
241        let rd1 = RollDay::IMM();
242        let rd2 = RollDay::IMM();
243        assert_eq!(rd1, rd2);
244
245        let rd1 = RollDay::IMM();
246        let rd2 = RollDay::Day(21);
247        assert_ne!(rd1, rd2);
248
249        let rd1 = RollDay::Day(20);
250        let rd2 = RollDay::Day(20);
251        assert_eq!(rd1, rd2);
252
253        let rd1 = RollDay::Day(21);
254        let rd2 = RollDay::Day(9);
255        assert_ne!(rd1, rd2);
256    }
257
258    #[test]
259    fn test_rollday_try_udate() {
260        let options: Vec<(RollDay, NaiveDateTime)> = vec![
261            (RollDay::Day(15), ndt(2000, 3, 15)),
262            (RollDay::Day(31), ndt(2000, 3, 31)),
263            (RollDay::Day(31), ndt(2022, 2, 28)),
264            (RollDay::Day(30), ndt(2024, 2, 29)),
265            (RollDay::Day(31), ndt(2024, 2, 29)),
266        ];
267        for option in options {
268            assert_eq!(false, option.0.try_udate(&option.1).is_err());
269        }
270    }
271
272    #[test]
273    fn test_get_unadjusteds() {
274        let options: Vec<(NaiveDateTime, Vec<NaiveDateTime>)> = vec![
275            (ndt(2000, 2, 29), vec![ndt(2000, 2, 29)]),
276            (
277                ndt(2025, 11, 28),
278                vec![ndt(2025, 11, 28), ndt(2025, 11, 29), ndt(2025, 11, 30)],
279            ),
280            (
281                ndt(2025, 2, 3),
282                vec![ndt(2025, 2, 3), ndt(2025, 2, 2), ndt(2025, 2, 1)],
283            ),
284        ];
285
286        for option in options {
287            let result = get_unadjusteds(
288                &option.0,
289                &Adjuster::ModifiedFollowing {},
290                &fixture_bus_cal(),
291            );
292
293            assert_eq!(result, option.1);
294        }
295    }
296
297    #[test]
298    fn test_vec_from() {
299        let options: Vec<(Vec<NaiveDateTime>, Vec<RollDay>)> = vec![
300            (
301                vec![ndt(2000, 2, 29)],
302                vec![RollDay::Day(29), RollDay::Day(30), RollDay::Day(31)],
303            ),
304            (vec![ndt(2025, 11, 28)], vec![RollDay::Day(28)]),
305            (
306                vec![ndt(2025, 3, 19)],
307                vec![RollDay::Day(19), RollDay::IMM {}],
308            ),
309            (vec![ndt(2025, 9, 15)], vec![RollDay::Day(15)]),
310        ];
311
312        for option in options {
313            let result = RollDay::vec_from(&option.0);
314            assert_eq!(result, option.1);
315        }
316    }
317
318    #[test]
319    fn test_vec_from_multiple() {
320        let options: Vec<(Vec<NaiveDateTime>, Vec<RollDay>)> = vec![
321            (
322                vec![ndt(2000, 2, 29)],
323                vec![RollDay::Day(29), RollDay::Day(30), RollDay::Day(31)],
324            ),
325            (
326                vec![ndt(2025, 11, 28), ndt(2025, 11, 29), ndt(2025, 11, 30)],
327                vec![
328                    RollDay::Day(28),
329                    RollDay::Day(29),
330                    RollDay::Day(30),
331                    RollDay::Day(31),
332                ],
333            ),
334            (
335                vec![ndt(2025, 3, 19)],
336                vec![RollDay::Day(19), RollDay::IMM()],
337            ),
338            (
339                vec![ndt(2025, 9, 15), ndt(2025, 9, 14), ndt(2025, 9, 13)],
340                vec![RollDay::Day(15), RollDay::Day(14), RollDay::Day(13)],
341            ),
342        ];
343
344        for option in options {
345            let result = RollDay::vec_from(&option.0);
346            assert_eq!(result, option.1);
347        }
348    }
349}