rateslib/scheduling/
convention.rs

1use chrono::prelude::*;
2use chrono::Months;
3use pyo3::exceptions::PyValueError;
4use pyo3::prelude::*;
5use serde::{Deserialize, Serialize};
6use std::cmp::PartialEq;
7
8use crate::scheduling::{
9    ndt, Adjuster, Adjustment, Calendar, DateRoll, Frequency, Imm, RollDay, Scheduling,
10};
11
12/// Specifier for day count conventions
13#[pyclass(module = "rateslib.rs", eq, eq_int, hash, frozen)]
14#[derive(Debug, Hash, Copy, Clone, Serialize, Deserialize, PartialEq)]
15pub enum Convention {
16    /// Actual days in period divided by 365.
17    Act365F = 0,
18    /// Actual days in period divided by 360.
19    Act360 = 1,
20    /// 30 days in month and 360 days in year with month end modification rules.
21    ///
22    /// - Start day is *min(30, start day)*.
23    /// - End day is *min(30, end day)* if start day is 30.
24    Thirty360 = 2,
25    /// 30 days in month and 360 days in year with month end modification rules.
26    ///
27    /// - Start day is *min(30, start day)* or 30 if start day is EoM February and roll is EoM.
28    /// - End day is *min(30, end day)* if start day is 30, or 30 if start and end are EoM February
29    ///   and roll is EoM.
30    ///
31    /// For [dcf][Convention::dcf]: requires ``frequency`` with a [RollDay] for February EoM adjustment.
32    ThirtyU360 = 3,
33    /// 30 days in month and 360 days in year with month end modification rules.
34    ///
35    /// - Start day is *min(30, start day)*.
36    /// - End day is *min(30, end day)*.
37    ThirtyE360 = 4,
38    /// 30 days in month and 360 days in year with month end modification rules.
39    ///
40    /// - Start day is *min(30, start day)* or 30 if start day is February EoM.
41    /// - End day is *min(30, end day)* or 30 if end day is February EoM and not *Leg* termination.
42    ///
43    /// For [dcf][Convention::dcf]: requires ``termination`` for February EoM adjustments.
44    ThirtyE360ISDA = 5,
45    /// Number of whole years plus fractional end period according to 'Act365F'.
46    YearsAct365F = 6,
47    /// Number of whole years plus fractional end period according to 'Act360'.
48    YearsAct360 = 7,
49    /// Number of whole years plus fractional counting months difference divided by 12.
50    YearsMonths = 8,
51    /// Return 1.0 for any period.
52    One = 9,
53    /// Actual days divided by actual days with leap year modification rules.
54    ActActISDA = 10,
55    /// Day count based on [Frequency] definition.
56    ///
57    /// For [dcf][Convention::dcf]: requires ``frequency`` and ``stub`` in all cases.
58    /// If a stub period further requires ``termination``, ``calendar`` and ``adjuster`` to
59    /// accurately evaluate the fractional part of a period.
60    ActActICMA = 11,
61    /// Number of business days in period divided by 252.
62    ///
63    /// For [dcf][Convention::dcf]: a ``calendar`` is required. If not given, a [Calendar] will
64    /// attempt to be sourced from the ``frequency`` if given a *BusDays* variant.
65    Bus252 = 12,
66    /// ActActICMA falling back to Act365F in stub periods.
67    ///
68    /// For [dcf][Convention::dcf]: requires the same arguments as ``ActActICMA`` variant.
69    ActActICMAStubAct365F = 13,
70}
71
72impl Convention {
73    pub fn dcf(
74        &self,
75        start: &NaiveDateTime,
76        end: &NaiveDateTime,
77        termination: Option<&NaiveDateTime>,
78        frequency: Option<&Frequency>,
79        stub: Option<bool>,
80        calendar: Option<&Calendar>,
81        adjuster: Option<&Adjuster>,
82    ) -> Result<f64, PyErr> {
83        match self {
84            Convention::Act360 => Ok(dcf_act_numeric(360.0, start, end)),
85            Convention::Act365F => Ok(dcf_act_numeric(365.0, start, end)),
86            Convention::YearsAct365F => Ok(dcf_years_and_act_numeric(365.0, start, end)),
87            Convention::YearsAct360 => Ok(dcf_years_and_act_numeric(360.0, start, end)),
88            Convention::YearsMonths => Ok(dcf_years_and_months(start, end)),
89            Convention::Thirty360 => Ok(dcf_30360(start, end)),
90            Convention::ThirtyU360 => dcf_30u360(start, end, frequency),
91            Convention::ThirtyE360 => Ok(dcf_30e360(start, end)),
92            Convention::ThirtyE360ISDA => dcf_30e360_isda(start, end, termination),
93            Convention::One => Ok(1.0),
94            Convention::ActActISDA => Ok(dcf_act_isda(start, end)),
95            Convention::ActActICMA => {
96                if frequency.is_none() {
97                    Err(PyValueError::new_err(
98                        "`frequency` must be supplied for 'ActActICMA' type convention.",
99                    ))
100                } else if stub.is_none() {
101                    Err(PyValueError::new_err(
102                        "`stub` must be supplied for 'ActActICMA' type convention.",
103                    ))
104                } else {
105                    dcf_act_icma(
106                        start,
107                        end,
108                        termination,
109                        frequency.unwrap(),
110                        stub.unwrap(),
111                        calendar,
112                        adjuster,
113                    )
114                }
115            }
116            Convention::Bus252 => {
117                let calendar_: &Calendar;
118                if calendar.is_none() {
119                    match frequency {
120                        Some(Frequency::BusDays {
121                            number: _,
122                            calendar: c,
123                        }) => calendar_ = c,
124                        _ => {
125                            return Err(PyValueError::new_err(
126                                "`calendar` must be supplied for 'Bus252' type convention.",
127                            ));
128                        }
129                    }
130                } else {
131                    calendar_ = calendar.unwrap();
132                }
133                Ok(dcf_bus252(start, end, calendar_))
134            }
135            Convention::ActActICMAStubAct365F => {
136                if frequency.is_none() {
137                    Err(PyValueError::new_err(
138                        "`frequency` must be supplied for 'ActActICMA' type convention.",
139                    ))
140                } else if stub.is_none() {
141                    Err(PyValueError::new_err(
142                        "`stub` must be supplied for 'ActActICMA' type convention.",
143                    ))
144                } else {
145                    dcf_act_icma_stub_365f(
146                        start,
147                        end,
148                        termination,
149                        frequency.unwrap(),
150                        stub.unwrap(),
151                        calendar,
152                        adjuster,
153                    )
154                }
155            }
156        }
157    }
158}
159
160fn dcf_act_numeric(denominator: f64, start: &NaiveDateTime, end: &NaiveDateTime) -> f64 {
161    (*end - *start).num_days() as f64 / denominator
162}
163
164fn dcf_years_and_act_numeric(denominator: f64, start: &NaiveDateTime, end: &NaiveDateTime) -> f64 {
165    if *end <= (*start + Months::new(12)) {
166        dcf_act_numeric(denominator, start, end)
167    } else {
168        let intermediate = RollDay::Day(start.day())
169            .try_from_ym(end.year(), start.month())
170            .expect("Dates are out of bounds");
171        if intermediate <= *end {
172            let years: f64 = (end.year() - start.year()) as f64;
173            years + dcf_act_numeric(denominator, &intermediate, end)
174        } else {
175            let years: f64 = (end.year() - start.year()) as f64 - 1.0;
176            years + dcf_act_numeric(denominator, &(intermediate - Months::new(12)), end)
177        }
178    }
179}
180
181fn dcf_years_and_months(start: &NaiveDateTime, end: &NaiveDateTime) -> f64 {
182    let start_ = ndt(start.year(), start.month(), 1);
183    let end_ = ndt(end.year(), end.month(), 1);
184    let mut count_date = ndt(end.year(), start.month(), 1);
185    if count_date > end_ {
186        count_date = count_date - Months::new(12)
187    };
188    let years = count_date.year() - start_.year();
189    let mut counter = 0;
190    while count_date < end_ {
191        count_date = count_date + Months::new(1);
192        counter += 1;
193    }
194    years as f64 + counter as f64 / 12.0
195}
196
197/// Normal 30360 without any adjustments
198fn dcf_30360_unadjusted(ys: i32, ms: u32, ds: u32, ye: i32, me: u32, de: u32) -> f64 {
199    (ye - ys) as f64 + (me as f64 - ms as f64) / 12.0 + (de as f64 - ds as f64) / 360.0
200}
201
202/// Return DCF under 30360 convention.
203///
204/// - start.day is adjusted to min(30, start.day)
205/// - end.day is adjusted to min(30, end.day) only if start.day is 30.
206/// - calculation proceeds as normal
207fn dcf_30360(start: &NaiveDateTime, end: &NaiveDateTime) -> f64 {
208    let ds = u32::min(30_u32, start.day());
209    let de = if ds == 30 {
210        u32::min(30_u32, end.day())
211    } else {
212        end.day()
213    };
214    dcf_30360_unadjusted(start.year(), start.month(), ds, end.year(), end.month(), de)
215}
216
217/// Return DCF under 30e360 convention.
218///
219/// - start.day is adjusted to min(30, start.day)
220/// - end.day is adjusted to min(30, end.day)
221/// - calculation proceeds as normal
222fn dcf_30e360(start: &NaiveDateTime, end: &NaiveDateTime) -> f64 {
223    let ds = u32::min(30_u32, start.day());
224    let de = u32::min(30_u32, end.day());
225    dcf_30360_unadjusted(start.year(), start.month(), ds, end.year(), end.month(), de)
226}
227
228/// Return DCF under 30u360 convention.
229///
230/// - start.day is 30 if roll is EoM and start is last day in February.
231/// - end.day is 30 if roll is EoM and start and end are both last days of February.
232/// - start.day is 30 if start.day is 31.
233/// - end.day is 30 if end.day is 31 and start.day is 30.
234///
235/// # Notes
236/// `frequency` is only evaluated to determine a [RollDay] if start is end of February.
237fn dcf_30u360(
238    start: &NaiveDateTime,
239    end: &NaiveDateTime,
240    frequency: Option<&Frequency>,
241) -> Result<f64, PyErr> {
242    let mut ds = start.day();
243    let mut de = end.day();
244
245    // handle February EoM rolls adjustment
246    if Imm::Eom.validate(start) && start.month() == 2 {
247        let roll: RollDay = match frequency {
248            Some(Frequency::Months {
249                number: _,
250                roll: Some(r),
251            }) => *r,
252            _ => {
253                return Err(PyValueError::new_err(
254                    "`frequency` must be provided or has no `roll`. A roll-day must be supplied for '30u360' convention to detect February EoM rolls.\n`start` is detected as end of February, otherwise use '30360' which will leave this date unadjusted.",
255                ));
256            }
257        };
258        if roll == RollDay::Day(31) {
259            ds = 30;
260            if Imm::Eom.validate(end) && end.month() == 2 {
261                de = 30;
262            }
263        }
264    }
265
266    // perform regular 30360 adjustments
267    ds = u32::min(30_u32, ds);
268    if de == 31 && ds == 30 {
269        de = 30;
270    }
271    Ok(dcf_30360_unadjusted(
272        start.year(),
273        start.month(),
274        ds,
275        end.year(),
276        end.month(),
277        de,
278    ))
279}
280
281/// Return DCF under 30e360ISDA convention.
282///
283/// - start.day is 30 if start.day is 31 or start.day is end of February.
284/// - end.day is 30 if end.day is 31 or end.day is end of February and not the termination date.
285fn dcf_30e360_isda(
286    start: &NaiveDateTime,
287    end: &NaiveDateTime,
288    termination: Option<&NaiveDateTime>,
289) -> Result<f64, PyErr> {
290    let mut ds = u32::min(30_u32, start.day());
291
292    //handle February EoM adjustments
293    if Imm::Eom.validate(start) && start.month() == 2 {
294        ds = 30;
295    }
296    let mut de = u32::min(30_u32, end.day());
297    if Imm::Eom.validate(end) && end.month() == 2 {
298        if termination.is_none() {
299            return Err(PyValueError::new_err(
300                "`termination` must be provided for '30e360ISDA' convention to detect end of February.\n`end` is detected as end of February, otherwise use '30e360' which will leave this date unadjusted.",
301            ));
302        } else if *end != *(termination.unwrap()) {
303            de = 30;
304        }
305    }
306
307    Ok(dcf_30360_unadjusted(
308        start.year(),
309        start.month(),
310        ds,
311        end.year(),
312        end.month(),
313        de,
314    ))
315}
316
317fn dcf_act_isda(start: &NaiveDateTime, end: &NaiveDateTime) -> f64 {
318    if start == end {
319        return 0.0;
320    };
321
322    let is_start_leap = NaiveDate::from_ymd_opt(start.year(), 2, 29).is_some();
323    let is_end_leap = NaiveDate::from_ymd_opt(end.year(), 2, 29).is_some();
324
325    let year_1_diff = if is_start_leap { 366.0 } else { 365.0 };
326    let year_2_diff = if is_end_leap { 366.0 } else { 365.0 };
327
328    let mut total_sum: f64 = (end.year() - start.year()) as f64 - 1.0;
329    total_sum += (ndt(start.year() + 1, 1, 1) - *start).num_days() as f64 / year_1_diff;
330    total_sum += (*end - ndt(end.year(), 1, 1)).num_days() as f64 / year_2_diff;
331    total_sum
332}
333
334fn dcf_act_icma(
335    start: &NaiveDateTime,
336    end: &NaiveDateTime,
337    termination: Option<&NaiveDateTime>,
338    frequency: &Frequency,
339    stub: bool,
340    calendar: Option<&Calendar>,
341    adjuster: Option<&Adjuster>,
342) -> Result<f64, PyErr> {
343    let freq = actacticma_frequency_conversion(frequency);
344    let ppa = freq.periods_per_annum();
345
346    if !stub {
347        Ok(1.0 / ppa)
348    } else {
349        if termination.is_none() || adjuster.is_none() || calendar.is_none() {
350            return Err(PyValueError::new_err(
351                "Stub periods under ActActICMA require `termination`, `adjuster` and `calendar` arguments to determine appropriate fractions."
352            ));
353        }
354        let is_back_stub = end == termination.unwrap();
355        let mut fraction = -1.0;
356        if is_back_stub {
357            let mut qe0 = *start;
358            let mut qe1 = *start;
359            while *end > qe1 {
360                fraction += 1.0;
361                qe0 = qe1;
362                qe1 = (*(adjuster.unwrap())).adjust(&freq.next(&qe0), calendar.unwrap());
363            }
364            fraction =
365                fraction + ((*end - qe0).num_days() as f64) / ((qe1 - qe0).num_days() as f64);
366            Ok(fraction / ppa)
367        } else {
368            let mut qs0 = *end;
369            let mut qs1 = *end;
370            while *start < qs1 {
371                fraction += 1.0;
372                qs0 = qs1;
373                qs1 = (*(adjuster.unwrap())).adjust(&freq.previous(&qs0), calendar.unwrap());
374            }
375            fraction =
376                fraction + ((qs0 - *start).num_days() as f64) / ((qs0 - qs1).num_days() as f64);
377            Ok(fraction / ppa)
378        }
379    }
380}
381
382fn dcf_act_icma_stub_365f(
383    start: &NaiveDateTime,
384    end: &NaiveDateTime,
385    termination: Option<&NaiveDateTime>,
386    frequency: &Frequency,
387    stub: bool,
388    calendar: Option<&Calendar>,
389    adjuster: Option<&Adjuster>,
390) -> Result<f64, PyErr> {
391    let freq = actacticma_frequency_conversion(frequency);
392    let ppa = freq.periods_per_annum();
393
394    if !stub {
395        Ok(1.0 / ppa)
396    } else {
397        if termination.is_none() || adjuster.is_none() || calendar.is_none() {
398            return Err(PyValueError::new_err(
399                "Stub periods under ActActICMA require `termination`, `adjuster` and `calendar` arguments to determine appropriate fractions."
400            ));
401        }
402        let is_back_stub = end == termination.unwrap();
403        let mut fraction = -1.0;
404        if is_back_stub {
405            let mut qe0 = *start;
406            let mut qe1 = *start;
407            while *end > qe1 {
408                fraction += 1.0;
409                qe0 = qe1;
410                qe1 = (*(adjuster.unwrap())).adjust(&freq.next(&qe0), calendar.unwrap());
411            }
412            fraction = fraction + ppa * (*end - qe0).num_days() as f64 / 365.0;
413            Ok(fraction / ppa)
414        } else {
415            let mut qs0 = *end;
416            let mut qs1 = *end;
417            while *start < qs1 {
418                fraction += 1.0;
419                qs0 = qs1;
420                qs1 = (*(adjuster.unwrap())).adjust(&freq.previous(&qs0), calendar.unwrap());
421            }
422            fraction = fraction + ppa * (qs0 - *start).num_days() as f64 / 365.0;
423            Ok(fraction / ppa)
424        }
425    }
426}
427
428fn actacticma_frequency_conversion(frequency: &Frequency) -> Frequency {
429    match frequency {
430        Frequency::Zero {} => Frequency::Months {
431            number: 12,
432            roll: None,
433        },
434        _ => frequency.clone(),
435    }
436}
437
438fn dcf_bus252(start: &NaiveDateTime, end: &NaiveDateTime, calendar: &Calendar) -> f64 {
439    if end < start {
440        panic!("Given end is greater than start");
441    } else if start == end {
442        return 0.0;
443    }
444    let start_bd = Adjuster::Following {}.adjust(start, calendar);
445    let end_bd = Adjuster::Previous {}.adjust(end, calendar);
446    let subtract = if end_bd == *end { -1.0 } else { 0.0 };
447    if start_bd == end_bd {
448        if start_bd > *start && end_bd < *end {
449            //then logically there is one b.d. between the non-business start and non-business end
450            1.0 / 252.0
451        } else if end_bd < *end {
452            // then the business start is permitted to the calculation until the non-business end
453            1.0 / 252.0
454        } else {
455            // start_bd > start
456            // then the business end is not permitted to have occurred and non-business start
457            // does not count
458            0.0
459        }
460    } else if start_bd > end_bd {
461        // there are no business days in between start and end
462        0.0
463    } else {
464        (calendar.bus_date_range(&start_bd, &end_bd).unwrap().len() as f64 + subtract) / 252.0
465    }
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471    use crate::scheduling::{ndt, Cal};
472
473    #[test]
474    fn test_act_numeric() {
475        let result = dcf_act_numeric(10.0, &ndt(2000, 1, 1), &ndt(2000, 1, 21));
476        assert_eq!(result, 2.0)
477    }
478
479    #[test]
480    fn test_act_plus() {
481        let options: Vec<(NaiveDateTime, NaiveDateTime, f64)> = vec![
482            (ndt(2000, 1, 1), ndt(2002, 1, 21), 2.0 + 20.0 / 365.0),
483            (ndt(2000, 12, 31), ndt(2002, 1, 1), 1.0 + 1.0 / 365.0),
484            (ndt(2000, 12, 31), ndt(2002, 12, 31), 2.0),
485            (ndt(2024, 2, 29), ndt(2025, 2, 28), 1.0),
486            (ndt(2000, 12, 15), ndt(2003, 1, 15), 2.0 + 31.0 / 365.0),
487        ];
488        for option in options {
489            let result = dcf_years_and_act_numeric(365.0, &option.0, &option.1);
490            assert_eq!(result, option.2)
491        }
492    }
493
494    #[test]
495    fn test_30360() {
496        let options: Vec<(NaiveDateTime, NaiveDateTime, f64)> = vec![
497            (ndt(2000, 1, 1), ndt(2000, 1, 21), 20.0 / 360.0),
498            (
499                ndt(2000, 1, 1),
500                ndt(2001, 3, 21),
501                1.0 + 2.0 / 12.0 + 20.0 / 360.0,
502            ),
503        ];
504        for option in options {
505            let result = dcf_30360(&option.0, &option.1);
506            assert_eq!(result, option.2)
507        }
508    }
509
510    #[test]
511    fn test_30u360() {
512        let options: Vec<(NaiveDateTime, NaiveDateTime, Frequency, f64)> = vec![
513            (
514                ndt(2000, 1, 1),
515                ndt(2000, 1, 21),
516                Frequency::Months {
517                    number: 1,
518                    roll: Some(RollDay::Day(1)),
519                },
520                20.0 / 360.0,
521            ),
522            (
523                ndt(2000, 1, 1),
524                ndt(2001, 3, 21),
525                Frequency::CalDays { number: 20 },
526                1.0 + 2.0 / 12.0 + 20.0 / 360.0,
527            ),
528            (
529                ndt(2024, 2, 29),
530                ndt(2025, 2, 28),
531                Frequency::Months {
532                    number: 12,
533                    roll: Some(RollDay::Day(29)),
534                },
535                1.0 - 1.0 / 360.0,
536            ),
537            (
538                ndt(2024, 2, 29),
539                ndt(2025, 2, 28),
540                Frequency::Months {
541                    number: 12,
542                    roll: Some(RollDay::Day(31)),
543                },
544                1.0,
545            ),
546        ];
547        for option in options {
548            let result = dcf_30u360(&option.0, &option.1, Some(&option.2)).unwrap();
549            assert_eq!(result, option.3);
550        }
551    }
552
553    #[test]
554    fn test_years_and_months() {
555        let options: Vec<(NaiveDateTime, NaiveDateTime, f64)> = vec![
556            (ndt(2000, 1, 1), ndt(2000, 1, 21), 0.0),
557            (ndt(2000, 1, 1), ndt(2001, 3, 21), 1.0 + 2.0 / 12.0),
558            (ndt(2024, 2, 29), ndt(2025, 2, 28), 1.0),
559            (ndt(2024, 2, 29), ndt(2025, 2, 28), 1.0),
560            (ndt(2000, 12, 29), ndt(2025, 1, 12), 24.0 + 1.0 / 12.0),
561        ];
562        for option in options {
563            let result = dcf_years_and_months(&option.0, &option.1);
564            assert_eq!(result, option.2)
565        }
566    }
567
568    #[test]
569    fn test_actacticma() {
570        let options: Vec<(NaiveDateTime, NaiveDateTime, Frequency, f64)> = vec![
571            (
572                ndt(1999, 2, 1),
573                ndt(1999, 7, 1),
574                Frequency::Months {
575                    number: 12,
576                    roll: None,
577                },
578                150.0 / 365.0,
579            ),
580            (
581                ndt(2002, 8, 15),
582                ndt(2003, 7, 15),
583                Frequency::Months {
584                    number: 6,
585                    roll: None,
586                },
587                0.5 + 153.0 / 368.0,
588            ),
589        ];
590        for option in options {
591            let result = dcf_act_icma(
592                &option.0,
593                &option.1,
594                Some(&ndt(2099, 1, 1)),
595                &option.2,
596                true,
597                Some(&Cal::new(vec![], vec![]).into()),
598                Some(&Adjuster::Actual {}),
599            )
600            .unwrap();
601            assert_eq!(result, option.3)
602        }
603    }
604}