use core::ops::Range;
use crate::{
Date, DateRange, Fraction, Frequency, Generation, Month, Period, Schedule, TimeError, Year,
};
pub trait DayCount {
fn name(&self) -> &'static str;
fn day_count(&self, start: Date, end: Date) -> i64 {
(start..end).days()
}
fn year_fraction(&self, start: Date, end: Date) -> Fraction;
}
#[derive(Debug, Clone, Copy)]
struct Basis(u64);
impl Basis {
const DAYS_360: Self = Self(360);
const DAYS_365: Self = Self(365);
const fn of(days: u64) -> Self {
Self(days)
}
fn fraction(self, days: i64) -> Fraction {
Fraction::new(days, self.0).unwrap_or_default()
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub struct Act360;
impl DayCount for Act360 {
fn name(&self) -> &'static str {
"Actual/360"
}
fn year_fraction(&self, start: Date, end: Date) -> Fraction {
Basis::DAYS_360.fraction(self.day_count(start, end))
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub struct Act365Fixed;
impl DayCount for Act365Fixed {
fn name(&self) -> &'static str {
"Actual/365 (Fixed)"
}
fn year_fraction(&self, start: Date, end: Date) -> Fraction {
Basis::DAYS_365.fraction(self.day_count(start, end))
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub struct ActActISDA;
impl ActActISDA {
fn ordered_year_fraction(span: &Range<Date>) -> Fraction {
if span.start == span.end {
return Fraction::ZERO;
}
let (y1, y2) = (span.start.year(), span.end.year());
if y1 == y2 {
return Basis::of(u64::from(y1.length())).fraction(span.days());
}
let n = i64::from(y2.get()) - i64::from(y1.get()) - 1;
let dib1 = i64::from(y1.length());
let dib2 = i64::from(y2.length());
let (Ok(next_year_start), Ok(this_year_start)) = (
Date::from_ymd(y1.get() + 1, Month::Jan, 1),
Date::from_ymd(y2.get(), Month::Jan, 1),
) else {
return Fraction::ZERO;
};
let a = (span.start..next_year_start).days();
let b = (this_year_start..span.end).days();
#[allow(clippy::cast_sign_loss)]
Basis::of((dib1 * dib2) as u64).fraction(n * dib1 * dib2 + a * dib2 + b * dib1)
}
}
impl DayCount for ActActISDA {
fn name(&self) -> &'static str {
"Actual/Actual (ISDA)"
}
fn year_fraction(&self, start: Date, end: Date) -> Fraction {
if start <= end {
Self::ordered_year_fraction(&(start..end))
} else {
Self::ordered_year_fraction(&(end..start))
.checked_neg()
.unwrap_or_default()
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ActActICMA {
frequency: Frequency,
}
impl ActActICMA {
#[must_use]
pub const fn new(frequency: Frequency) -> Self {
Self { frequency }
}
#[must_use]
pub const fn frequency(&self) -> Frequency {
self.frequency
}
fn lattice(self) -> Generation {
Generation {
tenor: Period::from(self.frequency),
end_of_month: true,
}
}
pub fn bind(self, schedule: &Schedule) -> Result<BoundActActICMA<'_>, TimeError> {
if schedule.len() < 2 {
return Err(TimeError::InvalidReferencePeriod);
}
let lattice = match schedule.generation() {
Some(generation) => {
if generation.tenor.normalized() != Period::from(self.frequency).normalized() {
return Err(TimeError::FrequencyMismatch);
}
generation
}
None => self.lattice(),
};
Ok(BoundActActICMA {
inner: self,
schedule,
lattice,
})
}
pub fn year_fraction_with_reference(
&self,
start: Date,
end: Date,
ref_start: Date,
ref_end: Date,
) -> Result<Fraction, TimeError> {
if ref_start >= ref_end {
return Err(TimeError::InvalidReferencePeriod);
}
if start == end {
return Ok(Fraction::ZERO);
}
let reference = ref_start..ref_end;
let lattice = self.lattice();
if start < end {
self.accrue(start..end, reference.clone(), lattice)
} else {
self.accrue(end..start, reference.clone(), lattice)?
.checked_neg()
.ok_or(TimeError::FractionOverflow)
}
}
fn coupon_share(
self,
chunk: &Range<Date>,
window: &Range<Date>,
) -> Result<Fraction, TimeError> {
let window_days =
u64::try_from(window.days()).map_err(|_| TimeError::InvalidReferencePeriod)?;
let denominator = u64::from(self.frequency.per_year())
.checked_mul(window_days)
.ok_or(TimeError::FractionOverflow)?;
Fraction::new(chunk.days(), denominator)
}
fn accrue(
self,
span: Range<Date>,
reference: Range<Date>,
lattice: Generation,
) -> Result<Fraction, TimeError> {
let mut i = 0;
while lattice.window(&reference, i)?.start > span.start {
i -= 1;
}
let mut total = Fraction::ZERO;
loop {
let window = lattice.window(&reference, i)?;
if let Some(chunk) = span.intersect(&window) {
total = total
.checked_add(self.coupon_share(&chunk, &window)?)
.ok_or(TimeError::FractionOverflow)?;
}
if window.end >= span.end {
return Ok(total);
}
i += 1;
}
}
}
impl DayCount for ActActICMA {
fn name(&self) -> &'static str {
"Actual/Actual (ICMA)"
}
fn year_fraction(&self, start: Date, end: Date) -> Fraction {
if start == end {
return Fraction::ZERO;
}
Basis::of(u64::from(self.frequency.per_year())).fraction(if start < end { 1 } else { -1 })
}
}
#[derive(Debug, Clone, Copy)]
pub struct BoundActActICMA<'s> {
inner: ActActICMA,
schedule: &'s Schedule,
lattice: Generation,
}
impl BoundActActICMA<'_> {
#[must_use]
pub const fn frequency(&self) -> Frequency {
self.inner.frequency()
}
#[must_use]
pub const fn schedule(&self) -> &Schedule {
self.schedule
}
fn ordered_year_fraction(&self, span: &Range<Date>) -> Fraction {
let (Some(&first), Some(&last)) = (self.schedule.first(), self.schedule.last()) else {
return Fraction::ZERO;
};
let Some(covered) = span.intersect(&(first..last)) else {
return Fraction::ZERO;
};
let mut total = Fraction::ZERO;
for (period, reference) in self
.schedule
.periods()
.zip(self.schedule.reference_periods())
{
if period.start >= covered.end {
break;
}
let Some(chunk) = covered.intersect(&period) else {
continue;
};
let accrued = self
.inner
.accrue(chunk, reference, self.lattice)
.unwrap_or_default();
total = total.checked_add(accrued).unwrap_or_default();
}
total
}
}
impl DayCount for BoundActActICMA<'_> {
fn name(&self) -> &'static str {
"Actual/Actual (ICMA)"
}
fn year_fraction(&self, start: Date, end: Date) -> Fraction {
if start == end {
return Fraction::ZERO;
}
if start < end {
self.ordered_year_fraction(&(start..end))
} else {
self.ordered_year_fraction(&(end..start))
.checked_neg()
.unwrap_or_default()
}
}
}
#[derive(Debug, Clone, Copy)]
struct Thirty360Date {
date: Date,
year: Year,
month: Month,
day: u8,
}
impl Thirty360Date {
fn of(date: Date) -> Self {
let (year, month, day) = date.to_ymd();
Self {
date,
year,
month,
day,
}
}
fn signed(start: Date, end: Date, rule: impl FnOnce(Self, Self) -> i64) -> i64 {
if start <= end {
rule(Self::of(start), Self::of(end))
} else {
-rule(Self::of(end), Self::of(start))
}
}
fn is_last_of_february(self) -> bool {
matches!(self.month, Month::Feb) && self.day == Month::Feb.length(self.year)
}
fn capped(self) -> Self {
self.with_day(if self.day == 31 { 30 } else { self.day })
}
fn with_day(self, day: u8) -> Self {
Self { day, ..self }
}
fn days_to(self, end: Self) -> i64 {
360 * (i64::from(end.year.get()) - i64::from(self.year.get()))
+ 30 * (i64::from(end.month.get()) - i64::from(self.month.get()))
+ (i64::from(end.day) - i64::from(self.day))
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub struct Thirty360Bond;
impl DayCount for Thirty360Bond {
fn name(&self) -> &'static str {
"30/360 (Bond Basis)"
}
fn day_count(&self, start: Date, end: Date) -> i64 {
Thirty360Date::signed(start, end, |start, end| {
let start = start.capped();
let end = if end.day == 31 && start.day == 30 {
end.with_day(30)
} else {
end
};
start.days_to(end)
})
}
fn year_fraction(&self, start: Date, end: Date) -> Fraction {
Basis::DAYS_360.fraction(self.day_count(start, end))
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub struct Thirty360US;
impl DayCount for Thirty360US {
fn name(&self) -> &'static str {
"30/360 (US)"
}
fn day_count(&self, start: Date, end: Date) -> i64 {
Thirty360Date::signed(start, end, |start, end| {
let (start, end) = if start.is_last_of_february() {
let end = if end.is_last_of_february() {
end.with_day(30)
} else {
end
};
(start.with_day(30), end)
} else {
(start, end)
};
let end = if end.day == 31 && start.day >= 30 {
end.with_day(30)
} else {
end
};
start.capped().days_to(end)
})
}
fn year_fraction(&self, start: Date, end: Date) -> Fraction {
Basis::DAYS_360.fraction(self.day_count(start, end))
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub struct Thirty360European;
impl DayCount for Thirty360European {
fn name(&self) -> &'static str {
"30E/360 (Eurobond Basis)"
}
fn day_count(&self, start: Date, end: Date) -> i64 {
Thirty360Date::signed(start, end, |start, end| {
start.capped().days_to(end.capped())
})
}
fn year_fraction(&self, start: Date, end: Date) -> Fraction {
Basis::DAYS_360.fraction(self.day_count(start, end))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Thirty360ISDA {
termination: Date,
}
impl Thirty360ISDA {
#[must_use]
pub const fn new(termination: Date) -> Self {
Self { termination }
}
#[must_use]
pub const fn termination(&self) -> Date {
self.termination
}
}
impl DayCount for Thirty360ISDA {
fn name(&self) -> &'static str {
"30E/360 (ISDA)"
}
fn day_count(&self, start: Date, end: Date) -> i64 {
Thirty360Date::signed(start, end, |start, end| {
let start = if start.is_last_of_february() {
start.with_day(30)
} else {
start.capped()
};
let end = if end.date != self.termination && end.is_last_of_february() {
end.with_day(30)
} else {
end.capped()
};
start.days_to(end)
})
}
fn year_fraction(&self, start: Date, end: Date) -> Fraction {
Basis::DAYS_360.fraction(self.day_count(start, end))
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
extern crate alloc;
use super::*;
use crate::Month;
use proptest::prelude::*;
fn ymd(y: u16, m: Month, d: u8) -> Date {
Date::from_ymd(y, m, d).unwrap()
}
#[test]
fn names_are_canonical() {
assert_eq!(Act360.name(), "Actual/360");
assert_eq!(Act365Fixed.name(), "Actual/365 (Fixed)");
}
#[test]
fn day_count_zero_for_same_date() {
let d = ymd(2025, Month::Jul, 4);
assert_eq!(Act360.day_count(d, d), 0);
assert_eq!(Act365Fixed.day_count(d, d), 0);
}
#[test]
fn day_count_signs_by_direction() {
let a = ymd(2025, Month::Jan, 1);
let b = ymd(2025, Month::Jan, 31);
assert_eq!(Act360.day_count(a, b), 30);
assert_eq!(Act360.day_count(b, a), -30);
}
#[test]
fn act360_zero_period_is_zero_fraction() {
let d = ymd(2025, Month::Jul, 4);
assert!(Act360.year_fraction(d, d).is_zero());
}
#[test]
fn act360_30_day_period() {
let start = ymd(2025, Month::Jan, 1);
let end = ymd(2025, Month::Jan, 31);
assert_eq!(Act360.year_fraction(start, end).parts(), (1, 12));
}
#[test]
fn act360_full_non_leap_year() {
let start = ymd(2025, Month::Jan, 1);
let end = ymd(2026, Month::Jan, 1);
assert_eq!(Act360.year_fraction(start, end).parts(), (73, 72));
}
#[test]
fn act360_full_leap_year() {
let start = ymd(2024, Month::Jan, 1);
let end = ymd(2025, Month::Jan, 1);
assert_eq!(Act360.year_fraction(start, end).parts(), (61, 60));
}
#[test]
fn act365f_zero_period_is_zero_fraction() {
let d = ymd(2025, Month::Jul, 4);
assert!(Act365Fixed.year_fraction(d, d).is_zero());
}
#[test]
fn act365f_full_non_leap_year_is_unity() {
let start = ymd(2025, Month::Jan, 1);
let end = ymd(2026, Month::Jan, 1);
assert_eq!(Act365Fixed.year_fraction(start, end).parts(), (1, 1));
}
#[test]
fn act365f_full_leap_year_exceeds_unity() {
let start = ymd(2024, Month::Jan, 1);
let end = ymd(2025, Month::Jan, 1);
assert_eq!(Act365Fixed.year_fraction(start, end).parts(), (366, 365));
}
#[test]
fn act365f_quarter_period() {
let start = ymd(2025, Month::Jan, 1);
let end = ymd(2025, Month::Apr, 1);
assert_eq!(Act365Fixed.year_fraction(start, end).parts(), (18, 73));
}
#[test]
fn thirty_360_bond_name() {
assert_eq!(Thirty360Bond.name(), "30/360 (Bond Basis)");
}
#[test]
fn thirty_360_bond_zero_period() {
let d = ymd(2025, Month::Jul, 4);
assert_eq!(Thirty360Bond.day_count(d, d), 0);
assert!(Thirty360Bond.year_fraction(d, d).is_zero());
}
#[test]
fn thirty_360_bond_six_months_is_half_year() {
let start = ymd(2003, Month::Aug, 28);
let end = ymd(2004, Month::Feb, 28);
assert_eq!(Thirty360Bond.day_count(start, end), 180);
assert_eq!(Thirty360Bond.year_fraction(start, end).parts(), (1, 2));
}
#[test]
fn thirty_360_bond_d1_31_adjusts_to_30() {
let start = ymd(2025, Month::Jan, 31);
let end = ymd(2025, Month::Feb, 28);
assert_eq!(Thirty360Bond.day_count(start, end), 28);
}
#[test]
fn thirty_360_bond_d2_31_does_not_adjust_when_d1_lt_30() {
let start = ymd(2025, Month::Feb, 28);
let end = ymd(2025, Month::Mar, 31);
assert_eq!(Thirty360Bond.day_count(start, end), 33);
}
#[test]
fn thirty_360_bond_both_31_adjust() {
let start = ymd(2025, Month::Jan, 31);
let end = ymd(2025, Month::Mar, 31);
assert_eq!(Thirty360Bond.day_count(start, end), 60);
assert_eq!(Thirty360Bond.year_fraction(start, end).parts(), (1, 6));
}
#[test]
fn thirty_360_bond_year_crossing_with_d2_31() {
let start = ymd(2023, Month::Aug, 28);
let end = ymd(2024, Month::Aug, 31);
assert_eq!(Thirty360Bond.day_count(start, end), 363);
}
#[test]
fn thirty_360_bond_is_not_additive_jan31_feb28_mar31() {
let a = ymd(2025, Month::Jan, 31);
let b = ymd(2025, Month::Feb, 28);
let c = ymd(2025, Month::Mar, 31);
let split = Thirty360Bond.day_count(a, b) + Thirty360Bond.day_count(b, c);
let direct = Thirty360Bond.day_count(a, c);
assert_eq!(split, 61);
assert_eq!(direct, 60);
assert_ne!(split, direct);
}
#[test]
fn thirty_360_variant_names() {
assert_eq!(Thirty360US.name(), "30/360 (US)");
assert_eq!(Thirty360European.name(), "30E/360 (Eurobond Basis)");
let isda = Thirty360ISDA::new(ymd(2030, Month::Jan, 1));
assert_eq!(isda.name(), "30E/360 (ISDA)");
}
#[test]
fn thirty_360_variants_disagree_on_lone_31_end() {
let start = ymd(2025, Month::Jan, 15);
let end = ymd(2025, Month::Mar, 31);
assert_eq!(Thirty360Bond.day_count(start, end), 76);
assert_eq!(Thirty360US.day_count(start, end), 76);
assert_eq!(Thirty360European.day_count(start, end), 75);
assert_eq!(
Thirty360ISDA::new(ymd(2030, Month::Jan, 1)).day_count(start, end),
75,
);
}
#[test]
fn thirty_360_us_february_rule() {
let start = ymd(2025, Month::Feb, 28);
let end = ymd(2025, Month::Mar, 31);
assert_eq!(Thirty360US.day_count(start, end), 30);
assert_eq!(Thirty360Bond.day_count(start, end), 33);
let leap_feb = ymd(2024, Month::Feb, 29);
let next_feb = ymd(2025, Month::Feb, 28);
assert_eq!(Thirty360US.day_count(leap_feb, next_feb), 360);
assert_eq!(Thirty360Bond.day_count(leap_feb, next_feb), 359);
let feb28_leap = ymd(2024, Month::Feb, 28);
assert_eq!(
Thirty360US.day_count(feb28_leap, ymd(2024, Month::Mar, 31)),
33,
);
}
#[test]
fn thirty_360_isda_termination_exception() {
let start = ymd(2025, Month::Aug, 31);
let feb_end = ymd(2026, Month::Feb, 28);
let interim = Thirty360ISDA::new(ymd(2030, Month::Jan, 1));
assert_eq!(interim.day_count(start, feb_end), 180);
let at_maturity = Thirty360ISDA::new(feb_end);
assert_eq!(at_maturity.day_count(start, feb_end), 178);
assert_eq!(
at_maturity.day_count(ymd(2025, Month::Feb, 28), ymd(2025, Month::Aug, 15)),
165, );
}
#[test]
fn act_act_icma_name_and_frequency() {
let dc = ActActICMA::new(Frequency::Semiannual);
assert_eq!(dc.name(), "Actual/Actual (ICMA)");
assert_eq!(dc.frequency(), Frequency::Semiannual);
}
#[test]
fn act_act_icma_without_reference_is_one_over_frequency() {
let dc = ActActICMA::new(Frequency::Quarterly);
let a = ymd(2025, Month::Jan, 15);
let b = ymd(2025, Month::Apr, 15);
assert_eq!(dc.year_fraction(a, b).parts(), (1, 4));
assert_eq!(dc.year_fraction(b, a).parts(), (-1, 4));
assert!(dc.year_fraction(a, a).is_zero());
assert_eq!(
dc.year_fraction(a, ymd(2025, Month::Jan, 16)).parts(),
(1, 4),
);
}
#[test]
fn act_act_icma_regular_period() {
let dc = ActActICMA::new(Frequency::Semiannual);
let start = ymd(2003, Month::Nov, 1);
let end = ymd(2004, Month::May, 1);
assert_eq!(
dc.year_fraction_with_reference(start, end, start, end)
.unwrap()
.parts(),
(1, 2),
);
}
#[test]
fn act_act_icma_short_front_stub() {
let dc = ActActICMA::new(Frequency::Annual);
let yf = dc
.year_fraction_with_reference(
ymd(1999, Month::Feb, 1),
ymd(1999, Month::Jul, 1),
ymd(1998, Month::Jul, 1),
ymd(1999, Month::Jul, 1),
)
.unwrap();
assert_eq!(yf.parts(), (30, 73)); }
#[test]
fn act_act_icma_long_front_stub() {
let dc = ActActICMA::new(Frequency::Semiannual);
let yf = dc
.year_fraction_with_reference(
ymd(2002, Month::Aug, 15),
ymd(2003, Month::Jul, 15),
ymd(2003, Month::Jan, 15),
ymd(2003, Month::Jul, 15),
)
.unwrap();
assert_eq!(yf.parts(), (337, 368));
}
#[test]
fn act_act_icma_short_back_stub() {
let dc = ActActICMA::new(Frequency::Semiannual);
let regular = dc
.year_fraction_with_reference(
ymd(1999, Month::Jul, 30),
ymd(2000, Month::Jan, 30),
ymd(1999, Month::Jul, 30),
ymd(2000, Month::Jan, 30),
)
.unwrap();
assert_eq!(regular.parts(), (1, 2));
let stub = dc
.year_fraction_with_reference(
ymd(2000, Month::Jan, 30),
ymd(2000, Month::Jun, 30),
ymd(2000, Month::Jan, 30),
ymd(2000, Month::Jul, 30),
)
.unwrap();
assert_eq!(stub.parts(), (38, 91)); }
#[test]
fn act_act_icma_forward_notional_walk() {
let dc = ActActICMA::new(Frequency::Semiannual);
let yf = dc
.year_fraction_with_reference(
ymd(2003, Month::Jan, 15),
ymd(2004, Month::Jul, 30),
ymd(2003, Month::Jan, 15),
ymd(2003, Month::Jul, 15),
)
.unwrap();
assert_eq!(yf.parts(), (567, 368));
}
#[test]
fn act_act_icma_rejects_degenerate_reference_period() {
let dc = ActActICMA::new(Frequency::Semiannual);
let d = ymd(2025, Month::Jan, 15);
let later = ymd(2025, Month::Jul, 15);
assert_eq!(
dc.year_fraction_with_reference(d, later, d, d),
Err(TimeError::InvalidReferencePeriod),
);
assert_eq!(
dc.year_fraction_with_reference(d, later, later, d),
Err(TimeError::InvalidReferencePeriod),
);
}
fn unadjusted_schedule(
effective: Date,
termination: Date,
rule: crate::DateGenerationRule,
) -> Schedule {
crate::ScheduleBuilder::new(
effective,
termination,
Period::Months(6),
crate::calendars::NULL_CALENDAR,
)
.with_rule(rule)
.with_convention(crate::BusinessDayConvention::Unadjusted)
.with_termination_convention(crate::BusinessDayConvention::Unadjusted)
.build()
.unwrap()
}
#[test]
fn bound_icma_front_stub_schedule() {
let schedule = unadjusted_schedule(
ymd(2002, Month::Aug, 15),
ymd(2004, Month::Jan, 15),
crate::DateGenerationRule::Backward,
);
assert_eq!(
schedule.dates(),
&[
ymd(2002, Month::Aug, 15),
ymd(2003, Month::Jan, 15),
ymd(2003, Month::Jul, 15),
ymd(2004, Month::Jan, 15),
],
);
let dc = ActActICMA::new(Frequency::Semiannual)
.bind(&schedule)
.unwrap();
assert_eq!(dc.name(), "Actual/Actual (ICMA)");
let stub = dc.year_fraction(ymd(2002, Month::Aug, 15), ymd(2003, Month::Jan, 15));
assert_eq!(stub.parts(), (153, 368));
let regular = dc.year_fraction(ymd(2003, Month::Jan, 15), ymd(2003, Month::Jul, 15));
assert_eq!(regular.parts(), (1, 2));
let whole = dc.year_fraction(ymd(2002, Month::Aug, 15), ymd(2004, Month::Jan, 15));
assert_eq!(whole.parts(), (521, 368)); let partial = dc.year_fraction(ymd(2003, Month::Jan, 15), ymd(2003, Month::Apr, 15));
assert_eq!(partial.parts(), (45, 181)); }
#[test]
fn bound_icma_back_stub_schedule() {
let schedule = unadjusted_schedule(
ymd(2003, Month::Jan, 15),
ymd(2004, Month::Jun, 30),
crate::DateGenerationRule::Forward,
);
assert_eq!(
schedule.dates(),
&[
ymd(2003, Month::Jan, 15),
ymd(2003, Month::Jul, 15),
ymd(2004, Month::Jan, 15),
ymd(2004, Month::Jun, 30),
],
);
let dc = ActActICMA::new(Frequency::Semiannual)
.bind(&schedule)
.unwrap();
let stub = dc.year_fraction(ymd(2004, Month::Jan, 15), ymd(2004, Month::Jun, 30));
assert_eq!(stub.parts(), (167, 364));
let regular = dc.year_fraction(ymd(2003, Month::Jan, 15), ymd(2003, Month::Jul, 15));
assert_eq!(regular.parts(), (1, 2));
}
#[test]
fn bound_icma_long_stub_spans_several_notional_periods() {
let schedule = crate::ScheduleBuilder::new(
ymd(2002, Month::Jan, 10),
ymd(2004, Month::Jan, 15),
Period::Months(6),
crate::calendars::NULL_CALENDAR,
)
.backwards()
.with_first_date(ymd(2003, Month::Jan, 15))
.with_convention(crate::BusinessDayConvention::Unadjusted)
.with_termination_convention(crate::BusinessDayConvention::Unadjusted)
.build()
.unwrap();
assert_eq!(schedule.dates()[0], ymd(2002, Month::Jan, 10));
assert_eq!(schedule.dates()[1], ymd(2003, Month::Jan, 15));
assert_eq!(
schedule.reference_periods().next().unwrap(),
ymd(2002, Month::Jul, 15)..ymd(2003, Month::Jan, 15),
);
let dc = ActActICMA::new(Frequency::Semiannual)
.bind(&schedule)
.unwrap();
let stub = dc.year_fraction(ymd(2002, Month::Jan, 10), ymd(2003, Month::Jan, 15));
let expected = Fraction::new(1, 2)
.unwrap()
.checked_add(Fraction::new(1, 2).unwrap())
.unwrap()
.checked_add(Fraction::new(5, 2 * 184).unwrap())
.unwrap();
assert_eq!(stub, expected);
assert_eq!(stub.parts(), (373, 368));
assert!(stub > Fraction::new(1, 1).unwrap());
}
#[test]
fn date_ranges_are_half_open() {
let range = ymd(2025, Month::Jan, 1)..ymd(2025, Month::Apr, 1);
assert_eq!(range.days(), 90);
assert!(range.contains(&ymd(2025, Month::Jan, 1))); assert!(!range.contains(&ymd(2025, Month::Apr, 1))); assert_eq!(
range.intersect(&(ymd(2025, Month::Mar, 1)..ymd(2025, Month::Jun, 1))),
Some(ymd(2025, Month::Mar, 1)..ymd(2025, Month::Apr, 1)),
);
assert_eq!(
range.intersect(&(ymd(2025, Month::Jun, 1)..ymd(2025, Month::Jul, 1))),
None,
);
assert_eq!(
range.intersect(&(ymd(2025, Month::Apr, 1)..ymd(2025, Month::May, 1))),
None,
);
assert_eq!(
(ymd(2025, Month::Apr, 1)..ymd(2025, Month::Jan, 1)).days(),
-90
);
}
#[test]
fn bound_icma_clamps_outside_schedule_span() {
let schedule = unadjusted_schedule(
ymd(2003, Month::Jan, 15),
ymd(2004, Month::Jan, 15),
crate::DateGenerationRule::Backward,
);
let dc = ActActICMA::new(Frequency::Semiannual)
.bind(&schedule)
.unwrap();
assert!(
dc.year_fraction(ymd(2002, Month::Jan, 1), ymd(2003, Month::Jan, 14))
.is_zero()
);
assert!(
dc.year_fraction(ymd(2004, Month::Feb, 1), ymd(2005, Month::Jan, 1))
.is_zero()
);
assert_eq!(
dc.year_fraction(ymd(2002, Month::Nov, 1), ymd(2003, Month::Jul, 15))
.parts(),
(1, 2),
);
}
#[test]
fn bound_icma_reversal_negates() {
let schedule = unadjusted_schedule(
ymd(2002, Month::Aug, 15),
ymd(2004, Month::Jan, 15),
crate::DateGenerationRule::Backward,
);
let dc = ActActICMA::new(Frequency::Semiannual)
.bind(&schedule)
.unwrap();
let a = ymd(2002, Month::Sep, 1);
let b = ymd(2003, Month::Oct, 1);
let sum = dc
.year_fraction(a, b)
.checked_add(dc.year_fraction(b, a))
.unwrap();
assert_eq!(sum, Fraction::ZERO);
assert!(dc.year_fraction(a, a).is_zero());
}
#[test]
fn bound_icma_additive_across_periods() {
let schedule = unadjusted_schedule(
ymd(2002, Month::Aug, 15),
ymd(2004, Month::Jan, 15),
crate::DateGenerationRule::Backward,
);
let dc = ActActICMA::new(Frequency::Semiannual)
.bind(&schedule)
.unwrap();
let a = ymd(2002, Month::Sep, 1);
let b = ymd(2003, Month::Mar, 1);
let c = ymd(2003, Month::Dec, 1);
let split = dc
.year_fraction(a, b)
.checked_add(dc.year_fraction(b, c))
.unwrap();
assert_eq!(split, dc.year_fraction(a, c));
}
#[test]
fn bound_icma_rejects_too_short_schedules() {
let single = Schedule::try_from(alloc::vec![ymd(2003, Month::Jan, 15)]).unwrap();
assert!(matches!(
ActActICMA::new(Frequency::Semiannual).bind(&single),
Err(TimeError::InvalidReferencePeriod),
));
}
#[test]
fn act_act_isda_name() {
assert_eq!(ActActISDA.name(), "Actual/Actual (ISDA)");
}
#[test]
fn act_act_isda_zero_period() {
let d = ymd(2025, Month::Jul, 4);
assert!(ActActISDA.year_fraction(d, d).is_zero());
}
#[test]
fn act_act_isda_same_non_leap_year() {
let start = ymd(2025, Month::Jan, 1);
let end = ymd(2025, Month::Jul, 1);
assert_eq!(ActActISDA.year_fraction(start, end).parts(), (181, 365));
}
#[test]
fn act_act_isda_same_leap_year() {
let start = ymd(2024, Month::Jan, 1);
let end = ymd(2024, Month::Jul, 1);
assert_eq!(ActActISDA.year_fraction(start, end).parts(), (91, 183));
}
#[test]
fn act_act_isda_full_non_leap_year_is_one() {
let start = ymd(2025, Month::Jan, 1);
let end = ymd(2026, Month::Jan, 1);
assert_eq!(ActActISDA.year_fraction(start, end).parts(), (1, 1));
}
#[test]
fn act_act_isda_full_leap_year_is_one() {
let start = ymd(2024, Month::Jan, 1);
let end = ymd(2025, Month::Jan, 1);
assert_eq!(ActActISDA.year_fraction(start, end).parts(), (1, 1));
}
#[test]
fn act_act_isda_isda_paper_nov_2003_to_may_2004() {
let start = ymd(2003, Month::Nov, 1);
let end = ymd(2004, Month::May, 1);
assert_eq!(
ActActISDA.year_fraction(start, end).parts(),
(66491, 133_590),
);
}
#[test]
fn act_act_isda_multi_year_with_full_middle_years() {
let start = ymd(2000, Month::Mar, 1);
let end = ymd(2005, Month::Mar, 1);
assert_eq!(
ActActISDA.year_fraction(start, end).parts(),
(111_274, 22_265),
);
}
#[test]
fn act_act_isda_reversed_is_negation() {
let a = ymd(2003, Month::Nov, 1);
let b = ymd(2004, Month::May, 1);
let forward = ActActISDA.year_fraction(a, b);
let reverse = ActActISDA.year_fraction(b, a);
assert_eq!(reverse, forward.checked_neg().unwrap());
}
#[test]
fn act_act_isda_additive_across_known_split() {
let a = ymd(2003, Month::Nov, 1);
let b = ymd(2004, Month::Jan, 1);
let c = ymd(2004, Month::Jul, 15);
let split = ActActISDA
.year_fraction(a, b)
.checked_add(ActActISDA.year_fraction(b, c))
.unwrap();
let direct = ActActISDA.year_fraction(a, c);
assert_eq!(split, direct);
}
#[test]
fn thirty_360_bond_reversed_is_negation() {
let a = ymd(2025, Month::Jan, 31);
let b = ymd(2025, Month::Feb, 15);
let forward = Thirty360Bond.day_count(a, b);
let reverse = Thirty360Bond.day_count(b, a);
assert_eq!(forward, -reverse);
}
#[test]
fn act360_reversed_period_is_negated_fraction() {
let a = ymd(2025, Month::Jan, 1);
let b = ymd(2025, Month::Jan, 31);
assert_eq!(Act360.year_fraction(a, b).parts(), (1, 12));
assert_eq!(Act360.year_fraction(b, a).parts(), (-1, 12));
}
#[test]
fn act365f_reversed_period_is_negated_fraction() {
let a = ymd(2025, Month::Jan, 1);
let b = ymd(2025, Month::Apr, 1);
assert_eq!(Act365Fixed.year_fraction(a, b).parts(), (18, 73));
assert_eq!(Act365Fixed.year_fraction(b, a).parts(), (-18, 73));
}
#[test]
fn forward_plus_reverse_cancels() {
let a = ymd(2025, Month::Jan, 1);
let b = ymd(2025, Month::Aug, 14);
let sum = Act360
.year_fraction(a, b)
.checked_add(Act360.year_fraction(b, a))
.unwrap();
assert_eq!(sum, Fraction::ZERO);
}
#[test]
fn act360_additive_across_a_known_split() {
let a = ymd(2025, Month::Jan, 1);
let b = ymd(2025, Month::Apr, 1);
let c = ymd(2025, Month::Oct, 1);
let lhs = Act360
.year_fraction(a, b)
.checked_add(Act360.year_fraction(b, c))
.unwrap();
let rhs = Act360.year_fraction(a, c);
assert_eq!(lhs, rhs);
}
#[test]
fn act365f_additive_across_year_boundary() {
let a = ymd(2023, Month::Nov, 1);
let b = ymd(2024, Month::Mar, 1);
let c = ymd(2024, Month::Aug, 1);
let lhs = Act365Fixed
.year_fraction(a, b)
.checked_add(Act365Fixed.year_fraction(b, c))
.unwrap();
let rhs = Act365Fixed.year_fraction(a, c);
assert_eq!(lhs, rhs);
}
fn three_ordered_dates() -> impl Strategy<Value = (Date, Date, Date)> {
(
10u32..(Date::MAX.serial() - 10),
10u32..(Date::MAX.serial() - 10),
10u32..(Date::MAX.serial() - 10),
)
.prop_map(|(x, y, z)| {
let mut s = [x, y, z];
s.sort_unstable();
(
Date::from_serial(s[0]).unwrap(),
Date::from_serial(s[1]).unwrap(),
Date::from_serial(s[2]).unwrap(),
)
})
}
proptest! {
#[test]
fn act_yf_zero_period(serial in 0u32..=Date::MAX.serial()) {
let d = Date::from_serial(serial).unwrap();
prop_assert!(Act360.year_fraction(d, d).is_zero());
prop_assert!(Act365Fixed.year_fraction(d, d).is_zero());
prop_assert!(ActActISDA.year_fraction(d, d).is_zero());
}
#[test]
fn act360_additive((a, b, c) in three_ordered_dates()) {
let lhs = Act360
.year_fraction(a, b)
.checked_add(Act360.year_fraction(b, c))
.expect("ACT/360 numerators stay well within u64");
let rhs = Act360.year_fraction(a, c);
prop_assert_eq!(lhs, rhs);
}
#[test]
fn act365f_additive((a, b, c) in three_ordered_dates()) {
let lhs = Act365Fixed
.year_fraction(a, b)
.checked_add(Act365Fixed.year_fraction(b, c))
.expect("ACT/365F numerators stay well within u64");
let rhs = Act365Fixed.year_fraction(a, c);
prop_assert_eq!(lhs, rhs);
}
#[test]
fn act_act_isda_additive((a, b, c) in three_ordered_dates()) {
let lhs = ActActISDA
.year_fraction(a, b)
.checked_add(ActActISDA.year_fraction(b, c))
.expect("ACT/ACT ISDA numerators stay within i64");
let rhs = ActActISDA.year_fraction(a, c);
prop_assert_eq!(lhs, rhs);
}
#[test]
fn day_count_matches_days_since(
x in 0u32..=Date::MAX.serial(),
y in 0u32..=Date::MAX.serial(),
) {
let a = Date::from_serial(x).unwrap();
let b = Date::from_serial(y).unwrap();
prop_assert_eq!(Act360.day_count(a, b), i64::from(b.days_since(a)));
prop_assert_eq!(Act365Fixed.day_count(a, b), i64::from(b.days_since(a)));
}
#[test]
fn act360_denominator_is_360_after_no_reduction(
x in 0u32..(Date::MAX.serial()),
offset in 1u32..=1_000,
) {
let a = Date::from_serial(x).unwrap();
let b_serial = x.saturating_add(offset).min(Date::MAX.serial());
let b = Date::from_serial(b_serial).unwrap();
let yf = Act360.year_fraction(a, b);
let days = i64::from(b.days_since(a));
let raw = Fraction::new(days, 360).unwrap();
prop_assert_eq!(yf, raw);
}
#[test]
fn yf_reverses_to_negation(
x in 0u32..=Date::MAX.serial(),
y in 0u32..=Date::MAX.serial(),
) {
let a = Date::from_serial(x).unwrap();
let b = Date::from_serial(y).unwrap();
let icma = ActActICMA::new(Frequency::Semiannual);
let isda_30e = Thirty360ISDA::new(Date::MAX);
for dc in [
&Act360 as &dyn DayCount,
&Act365Fixed,
&Thirty360Bond,
&Thirty360US,
&Thirty360European,
&isda_30e,
&ActActISDA,
&icma,
] {
let sum = dc.year_fraction(a, b)
.checked_add(dc.year_fraction(b, a))
.expect("denominators are constants");
prop_assert_eq!(sum, Fraction::ZERO);
}
}
#[test]
fn day_count_is_signed_by_direction(
x in 0u32..=Date::MAX.serial(),
y in 0u32..=Date::MAX.serial(),
) {
let a = Date::from_serial(x).unwrap();
let b = Date::from_serial(y).unwrap();
let icma = ActActICMA::new(Frequency::Quarterly);
let isda_30e = Thirty360ISDA::new(Date::MAX);
for dc in [
&Act360 as &dyn DayCount,
&Act365Fixed,
&Thirty360Bond,
&Thirty360US,
&Thirty360European,
&isda_30e,
&ActActISDA,
&icma,
] {
prop_assert_eq!(dc.day_count(a, b), -dc.day_count(b, a));
}
}
#[test]
fn act_act_icma_reference_period_is_one_over_frequency(
serial in 400u32..(Date::MAX.serial() - 400),
len in 1u32..=370,
) {
let r1 = Date::from_serial(serial).unwrap();
let r2 = Date::from_serial(serial + len).unwrap();
for freq in [
Frequency::Annual,
Frequency::Semiannual,
Frequency::Quarterly,
Frequency::Monthly,
Frequency::Weekly,
] {
let dc = ActActICMA::new(freq);
let yf = dc.year_fraction_with_reference(r1, r2, r1, r2).unwrap();
prop_assert_eq!(yf.parts(), (1, u64::from(freq.per_year())));
}
}
#[test]
fn act_act_icma_additive_within_reference(
serial in 400u32..(Date::MAX.serial() - 400),
o1 in 0u32..=300,
o2 in 0u32..=300,
o3 in 0u32..=300,
) {
let mut offsets = [o1, o2, o3];
offsets.sort_unstable();
let r1 = Date::from_serial(serial).unwrap();
let r2 = Date::from_serial(serial + 301).unwrap();
let a = Date::from_serial(serial + offsets[0]).unwrap();
let b = Date::from_serial(serial + offsets[1]).unwrap();
let c = Date::from_serial(serial + offsets[2]).unwrap();
let dc = ActActICMA::new(Frequency::Semiannual);
let split = dc
.year_fraction_with_reference(a, b, r1, r2).unwrap()
.checked_add(dc.year_fraction_with_reference(b, c, r1, r2).unwrap())
.expect("shared denominator");
let direct = dc.year_fraction_with_reference(a, c, r1, r2).unwrap();
prop_assert_eq!(split, direct);
}
}
}