use std::fmt;
use crate::date::{is_leap, Date};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum DayCount {
Act360,
Act365Fixed,
ActActIsda,
Thirty360Us,
ThirtyE360,
}
impl DayCount {
#[must_use]
pub fn year_fraction(self, start: Date, end: Date) -> f64 {
if start == end {
return 0.0;
}
if end < start {
return -self.year_fraction(end, start);
}
match self {
Self::Act360 => f64::from(end - start) / 360.0,
Self::Act365Fixed => f64::from(end - start) / 365.0,
Self::ActActIsda => act_act_isda(start, end),
Self::Thirty360Us => f64::from(days_30360_us(start, end)) / 360.0,
Self::ThirtyE360 => f64::from(days_30e360(start, end)) / 360.0,
}
}
#[must_use]
pub fn day_count(self, start: Date, end: Date) -> i32 {
match self {
Self::Act360 | Self::Act365Fixed | Self::ActActIsda => end - start,
Self::Thirty360Us => days_30360_us(start, end),
Self::ThirtyE360 => days_30e360(start, end),
}
}
#[must_use]
pub fn name(self) -> &'static str {
match self {
Self::Act360 => "act/360",
Self::Act365Fixed => "act/365f",
Self::ActActIsda => "act/act-isda",
Self::Thirty360Us => "30/360-us",
Self::ThirtyE360 => "30e/360",
}
}
}
impl fmt::Display for DayCount {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.name())
}
}
fn act_act_isda(start: Date, end: Date) -> f64 {
let y1 = start.year();
let y2 = end.year();
let denom = |y: i32| if is_leap(y) { 366.0 } else { 365.0 };
if y1 == y2 {
return f64::from(end - start) / denom(y1);
}
let next_year_start = Date::new(y1 + 1, 1, 1).expect("Jan 1 is valid");
let first = f64::from(next_year_start - start) / denom(y1);
let end_year_start = Date::new(y2, 1, 1).expect("Jan 1 is valid");
let last = f64::from(end - end_year_start) / denom(y2);
let whole = f64::from(y2 - y1 - 1);
first + whole + last
}
fn days_30360_us(start: Date, end: Date) -> i32 {
let (y1, m1, mut d1) = (start.year(), start.month() as i32, start.day() as i32);
let (y2, m2, mut d2) = (end.year(), end.month() as i32, end.day() as i32);
if d1 == 31 {
d1 = 30;
}
if d2 == 31 && d1 > 29 {
d2 = 30;
}
360 * (y2 - y1) + 30 * (m2 - m1) + (d2 - d1)
}
fn days_30e360(start: Date, end: Date) -> i32 {
let (y1, m1, mut d1) = (start.year(), start.month() as i32, start.day() as i32);
let (y2, m2, mut d2) = (end.year(), end.month() as i32, end.day() as i32);
if d1 == 31 {
d1 = 30;
}
if d2 == 31 {
d2 = 30;
}
360 * (y2 - y1) + 30 * (m2 - m1) + (d2 - d1)
}
#[cfg(test)]
mod tests {
use super::*;
fn d(y: i32, m: u32, day: u32) -> Date {
Date::new(y, m, day).unwrap()
}
fn approx(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9
}
#[test]
fn act_360_basic() {
let yf = DayCount::Act360.year_fraction(d(2025, 1, 1), d(2025, 7, 1));
assert!(approx(yf, 181.0 / 360.0));
}
#[test]
fn act_365_fixed_basic() {
let yf = DayCount::Act365Fixed.year_fraction(d(2025, 1, 1), d(2025, 7, 1));
assert!(approx(yf, 181.0 / 365.0));
}
#[test]
fn act_365_fixed_ignores_leap() {
let yf = DayCount::Act365Fixed.year_fraction(d(2024, 1, 1), d(2025, 1, 1));
assert!(approx(yf, 366.0 / 365.0));
}
#[test]
fn act_act_isda_same_year_non_leap() {
let yf = DayCount::ActActIsda.year_fraction(d(2025, 1, 1), d(2025, 7, 1));
assert!(approx(yf, 181.0 / 365.0));
}
#[test]
fn act_act_isda_full_leap_year_is_one() {
let yf = DayCount::ActActIsda.year_fraction(d(2024, 1, 1), d(2025, 1, 1));
assert!(approx(yf, 1.0));
}
#[test]
fn act_act_isda_isda_reference_example() {
let yf = DayCount::ActActIsda.year_fraction(d(2003, 11, 1), d(2004, 5, 1));
let expected = 61.0 / 365.0 + 121.0 / 366.0;
assert!(approx(yf, expected));
assert!(approx(yf, 0.497_724_380_165_289));
}
#[test]
fn thirty_360_us_half_year() {
let yf = DayCount::Thirty360Us.year_fraction(d(2025, 1, 1), d(2025, 7, 1));
assert!(approx(yf, 0.5));
}
#[test]
fn thirty_360_us_adjusts_d1_31() {
let n = DayCount::Thirty360Us.day_count(d(2025, 1, 31), d(2025, 2, 28));
assert_eq!(n, 28);
}
#[test]
fn thirty_360_us_vs_30e_differ_on_d2_31() {
let us = DayCount::Thirty360Us.day_count(d(2025, 1, 15), d(2025, 3, 31));
let e = DayCount::ThirtyE360.day_count(d(2025, 1, 15), d(2025, 3, 31));
assert_eq!(us, 76);
assert_eq!(e, 75);
}
#[test]
fn thirty_e_360_adjusts_both_31() {
let n = DayCount::ThirtyE360.day_count(d(2025, 1, 31), d(2025, 3, 31));
assert_eq!(n, 60);
}
#[test]
fn same_date_is_zero() {
for dc in [
DayCount::Act360,
DayCount::Act365Fixed,
DayCount::ActActIsda,
DayCount::Thirty360Us,
DayCount::ThirtyE360,
] {
assert_eq!(dc.year_fraction(d(2025, 3, 15), d(2025, 3, 15)), 0.0);
}
}
#[test]
fn reversed_is_negated() {
let a = d(2024, 2, 10);
let b = d(2025, 8, 20);
for dc in [
DayCount::Act360,
DayCount::Act365Fixed,
DayCount::ActActIsda,
DayCount::Thirty360Us,
DayCount::ThirtyE360,
] {
assert!(approx(dc.year_fraction(a, b), -dc.year_fraction(b, a)));
}
}
#[test]
fn act_act_isda_multi_year_span() {
let yf = DayCount::ActActIsda.year_fraction(d(2022, 7, 1), d(2025, 7, 1));
let expected = 184.0 / 365.0 + 2.0 + 181.0 / 365.0;
assert!(approx(yf, expected));
}
#[test]
fn name_and_display() {
assert_eq!(DayCount::Act360.name(), "act/360");
assert_eq!(DayCount::ThirtyE360.to_string(), "30e/360");
}
}