use chrono::Datelike;
use chrono::NaiveDate;
use crate::traits::FloatExt;
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DayCountConvention {
Actual360,
#[default]
Actual365Fixed,
Thirty360,
Thirty360European,
Thirty360EuropeanISDA,
ActualActualISDA,
ActualActualAFB,
Business252,
Actual364,
NoLeap365,
}
impl std::fmt::Display for DayCountConvention {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Actual360 => write!(f, "ACT/360"),
Self::Actual365Fixed => write!(f, "ACT/365F"),
Self::Thirty360 => write!(f, "30/360"),
Self::Thirty360European => write!(f, "30E/360"),
Self::Thirty360EuropeanISDA => write!(f, "30E/360 ISDA"),
Self::ActualActualISDA => write!(f, "ACT/ACT ISDA"),
Self::ActualActualAFB => write!(f, "ACT/ACT AFB"),
Self::Business252 => write!(f, "BUS/252"),
Self::Actual364 => write!(f, "ACT/364"),
Self::NoLeap365 => write!(f, "NL/365"),
}
}
}
impl DayCountConvention {
pub fn year_fraction<T: FloatExt>(&self, d1: NaiveDate, d2: NaiveDate) -> T {
match self {
Self::Actual360 => {
let days = (d2 - d1).num_days() as f64;
T::from_f64_fast(days / 360.0)
}
Self::Actual365Fixed => {
let days = (d2 - d1).num_days() as f64;
T::from_f64_fast(days / 365.0)
}
Self::Thirty360 => {
let num = self.day_count(d1, d2) as f64;
T::from_f64_fast(num / 360.0)
}
Self::Thirty360European => {
let num = self.day_count(d1, d2) as f64;
T::from_f64_fast(num / 360.0)
}
Self::Thirty360EuropeanISDA => {
let num = self.day_count(d1, d2) as f64;
T::from_f64_fast(num / 360.0)
}
Self::ActualActualISDA => T::from_f64_fast(actual_actual_isda(d1, d2)),
Self::ActualActualAFB => T::from_f64_fast(actual_actual_afb(d1, d2)),
Self::Business252 => {
let bus = business_day_count_weekdays(d1, d2) as f64;
T::from_f64_fast(bus / 252.0)
}
Self::Actual364 => {
let days = (d2 - d1).num_days() as f64;
T::from_f64_fast(days / 364.0)
}
Self::NoLeap365 => {
let days = no_leap_day_count(d1, d2) as f64;
T::from_f64_fast(days / 365.0)
}
}
}
pub fn day_count(&self, d1: NaiveDate, d2: NaiveDate) -> i64 {
match self {
Self::Actual360
| Self::Actual365Fixed
| Self::ActualActualISDA
| Self::ActualActualAFB
| Self::Actual364 => (d2 - d1).num_days(),
Self::Thirty360 => thirty360_usa(d1, d2),
Self::Thirty360European => thirty360_european(d1, d2),
Self::Thirty360EuropeanISDA => thirty360_european_isda(d1, d2),
Self::Business252 => business_day_count_weekdays(d1, d2),
Self::NoLeap365 => no_leap_day_count(d1, d2),
}
}
}
fn thirty360_usa(d1: NaiveDate, d2: NaiveDate) -> i64 {
let (y1, m1) = (d1.year() as i64, d1.month() as i64);
let (y2, m2) = (d2.year() as i64, d2.month() as i64);
let mut dd1 = d1.day() as i64;
let mut dd2 = d2.day() as i64;
if dd1 == 31 {
dd1 = 30;
}
if dd2 == 31 && dd1 == 30 {
dd2 = 30;
}
360 * (y2 - y1) + 30 * (m2 - m1) + (dd2 - dd1)
}
fn thirty360_european(d1: NaiveDate, d2: NaiveDate) -> i64 {
let (y1, m1) = (d1.year() as i64, d1.month() as i64);
let (y2, m2) = (d2.year() as i64, d2.month() as i64);
let dd1 = (d1.day() as i64).min(30);
let dd2 = (d2.day() as i64).min(30);
360 * (y2 - y1) + 30 * (m2 - m1) + (dd2 - dd1)
}
fn thirty360_european_isda(d1: NaiveDate, d2: NaiveDate) -> i64 {
let (y1, m1) = (d1.year() as i64, d1.month() as i64);
let (y2, m2) = (d2.year() as i64, d2.month() as i64);
let mut dd1 = d1.day() as i64;
let mut dd2 = d2.day() as i64;
if dd1 == 31 {
dd1 = 30;
}
if dd2 == 31 {
dd2 = 30;
}
if d1.month() == 2 && (dd1 == 28 + i64::from(is_leap_year(d1.year()))) {
dd1 = 30;
}
360 * (y2 - y1) + 30 * (m2 - m1) + (dd2 - dd1)
}
fn actual_actual_afb(d1: NaiveDate, d2: NaiveDate) -> f64 {
if d1 == d2 {
return 0.0;
}
let (start, end, sign) = if d1 < d2 {
(d1, d2, 1.0)
} else {
(d2, d1, -1.0)
};
let days = (end - start).num_days() as f64;
let mut has_feb29 = false;
for y in start.year()..=end.year() {
if let Some(feb29) = NaiveDate::from_ymd_opt(y, 2, 29)
&& feb29 > start
&& feb29 <= end
{
has_feb29 = true;
break;
}
}
let denom = if has_feb29 { 366.0 } else { 365.0 };
sign * days / denom
}
fn business_day_count_weekdays(d1: NaiveDate, d2: NaiveDate) -> i64 {
use chrono::Weekday;
if d1 == d2 {
return 0;
}
let (start, end, sign) = if d1 < d2 { (d1, d2, 1) } else { (d2, d1, -1) };
let mut count: i64 = 0;
let mut day = start;
while day < end {
match day.weekday() {
Weekday::Sat | Weekday::Sun => {}
_ => count += 1,
}
day = day
.succ_opt()
.expect("date overflow in business_day_count_weekdays");
}
sign * count
}
fn no_leap_day_count(d1: NaiveDate, d2: NaiveDate) -> i64 {
if d1 == d2 {
return 0;
}
let (start, end, sign) = if d1 < d2 { (d1, d2, 1) } else { (d2, d1, -1) };
let mut days = (end - start).num_days();
for y in start.year()..=end.year() {
if let Some(feb29) = NaiveDate::from_ymd_opt(y, 2, 29)
&& feb29 > start
&& feb29 <= end
{
days -= 1;
}
}
sign * days
}
fn actual_actual_isda(d1: NaiveDate, d2: NaiveDate) -> f64 {
if d1 == d2 {
return 0.0;
}
let y1 = d1.year();
let y2 = d2.year();
if y1 == y2 {
let days = (d2 - d1).num_days() as f64;
let denom = if is_leap_year(y1) { 366.0 } else { 365.0 };
return days / denom;
}
let end_of_y1 = NaiveDate::from_ymd_opt(y1 + 1, 1, 1).unwrap();
let days_first = (end_of_y1 - d1).num_days() as f64;
let denom_first = if is_leap_year(y1) { 366.0 } else { 365.0 };
let start_of_y2 = NaiveDate::from_ymd_opt(y2, 1, 1).unwrap();
let days_last = (d2 - start_of_y2).num_days() as f64;
let denom_last = if is_leap_year(y2) { 366.0 } else { 365.0 };
let full_years = (y2 - y1 - 1) as f64;
days_first / denom_first + full_years + days_last / denom_last
}
pub fn is_leap_year(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
}
pub fn days_in_month(year: i32, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 => {
if is_leap_year(year) {
29
} else {
28
}
}
_ => unreachable!(),
}
}
pub fn add_months_clamped(date: chrono::NaiveDate, months: i32) -> chrono::NaiveDate {
use chrono::Datelike;
let total = date.year() * 12 + date.month0() as i32 + months;
let target_year = total.div_euclid(12);
let target_month = (total.rem_euclid(12) + 1) as u32;
let max_day = days_in_month(target_year, target_month);
let day = date.day().min(max_day);
chrono::NaiveDate::from_ymd_opt(target_year, target_month, day)
.expect("target schedule date must be valid")
}
#[cfg(test)]
mod tests {
use chrono::NaiveDate;
use super::*;
#[test]
fn act365_full_year() {
let d1 = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let d2 = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
let yf: f64 = DayCountConvention::Actual365Fixed.year_fraction(d1, d2);
assert!((yf - 366.0 / 365.0).abs() < 1e-12);
}
#[test]
fn act360_full_year() {
let d1 = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
let d2 = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let yf: f64 = DayCountConvention::Actual360.year_fraction(d1, d2);
assert!((yf - 365.0 / 360.0).abs() < 1e-12);
}
#[test]
fn thirty360_full_year() {
let d1 = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap();
let d2 = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
let yf: f64 = DayCountConvention::Thirty360.year_fraction(d1, d2);
assert!((yf - 1.0).abs() < 1e-12);
}
#[test]
fn leap_year_detection() {
assert!(is_leap_year(2024));
assert!(!is_leap_year(2023));
assert!(!is_leap_year(1900));
assert!(is_leap_year(2000));
}
#[test]
fn days_in_february_leap() {
assert_eq!(days_in_month(2024, 2), 29);
assert_eq!(days_in_month(2023, 2), 28);
}
}