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