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#[pyclass(module = "rateslib.rs", eq, eq_int, hash, frozen)]
14#[derive(Debug, Hash, Copy, Clone, Serialize, Deserialize, PartialEq)]
15pub enum Convention {
16 Act365F = 0,
18 Act360 = 1,
20 Thirty360 = 2,
25 ThirtyU360 = 3,
33 ThirtyE360 = 4,
38 ThirtyE360ISDA = 5,
45 YearsAct365F = 6,
47 YearsAct360 = 7,
49 YearsMonths = 8,
51 One = 9,
53 ActActISDA = 10,
55 ActActICMA = 11,
61 Bus252 = 12,
66 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
197fn 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
202fn 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
217fn 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
228fn 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 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 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
281fn 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 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 1.0 / 252.0
451 } else if end_bd < *end {
452 1.0 / 252.0
454 } else {
455 0.0
459 }
460 } else if start_bd > end_bd {
461 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}