use crate::errors::TypeError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Date(i32);
impl Date {
pub fn from_ymd(year: i32, month: u32, day: u32) -> Result<Self, TypeError> {
if !(1..=12).contains(&month) {
return Err(TypeError::InvalidDate { year, month, day });
}
if day == 0 || day > days_in_month(year, month) {
return Err(TypeError::InvalidDate { year, month, day });
}
Ok(Self(days_from_civil(year, month, day)))
}
#[must_use]
#[inline]
pub const fn from_serial(days: i32) -> Self {
Self(days)
}
#[must_use]
#[inline]
pub const fn serial(self) -> i32 {
self.0
}
#[must_use]
pub fn year(self) -> i32 {
civil_from_days(self.0).0
}
#[must_use]
pub fn month(self) -> u32 {
civil_from_days(self.0).1
}
#[must_use]
pub fn day(self) -> u32 {
civil_from_days(self.0).2
}
#[must_use]
#[inline]
pub const fn add_days(self, days: i32) -> Self {
Self(self.0.wrapping_add(days))
}
#[must_use]
#[inline]
pub const fn days_between(self, other: Self) -> i32 {
other.0.wrapping_sub(self.0)
}
}
#[inline]
const fn is_leap_year(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
#[inline]
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
}
}
_ => 0,
}
}
fn days_from_civil(y: i32, m: u32, d: u32) -> i32 {
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = u32::try_from(y - era * 400).unwrap_or(0);
let mp = if m > 2 { m - 3 } else { m + 9 };
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let doe_i = i32::try_from(doe).unwrap_or(i32::MAX);
era * 146_097 + doe_i - 719_468
}
fn civil_from_days(z: i32) -> (i32, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = u32::try_from(z - era * 146_097).unwrap_or(0);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y_partial = i32::try_from(yoe).unwrap_or(i32::MAX) + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y_partial + 1 } else { y_partial };
(y, m, d)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TenorUnit {
Days,
Weeks,
Months,
Years,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Tenor {
pub count: i32,
pub unit: TenorUnit,
}
impl Tenor {
#[must_use]
#[inline]
pub const fn new(count: i32, unit: TenorUnit) -> Self {
Self { count, unit }
}
#[must_use]
pub fn add_to(self, start: Date) -> Date {
match self.unit {
TenorUnit::Days => start.add_days(self.count),
TenorUnit::Weeks => start.add_days(self.count.wrapping_mul(7)),
TenorUnit::Months => add_months(start, self.count),
TenorUnit::Years => add_months(start, self.count.wrapping_mul(12)),
}
}
}
fn add_months(start: Date, months: i32) -> Date {
let (y, m, d) = civil_from_days(start.serial());
let m0 = i32::try_from(m).unwrap_or(0).saturating_sub(1);
let total = m0.wrapping_add(months);
let dy = total.div_euclid(12);
let new_m0 = total.rem_euclid(12);
let new_y = y.wrapping_add(dy);
let new_m = u32::try_from(new_m0 + 1).unwrap_or(1);
let max_d = days_in_month(new_y, new_m);
let new_d = d.min(max_d);
Date(days_from_civil(new_y, new_m, new_d))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Daycount {
Act360,
Act365F,
Thirty360BondBasis,
Thirty360E,
ActActIsda,
ActActIcma {
coupons_per_year: u32,
},
Business252,
}
impl Daycount {
pub fn year_fraction(self, d1: Date, d2: Date) -> Result<f64, TypeError> {
let span = d1.days_between(d2);
if span < 0 {
return Err(TypeError::NonPositiveRange);
}
match self {
Self::Act360 => Ok(f64::from(span) / 360.0),
Self::Act365F => Ok(f64::from(span) / 365.0),
Self::Thirty360BondBasis => Ok(thirty_360_bond_basis(d1, d2)),
Self::Thirty360E => Ok(thirty_360_e(d1, d2)),
Self::ActActIsda => Ok(act_act_isda(d1, d2)),
Self::ActActIcma { coupons_per_year } => {
if coupons_per_year == 0 {
return Err(TypeError::InvalidTenor {
reason: "ActActIcma requires coupons_per_year > 0",
});
}
Ok(1.0 / f64::from(coupons_per_year))
}
Self::Business252 => Err(TypeError::InvalidTenor {
reason: "Business252 requires a calendar; supply already-computed year fractions",
}),
}
}
}
fn thirty_360_bond_basis(d1: Date, d2: Date) -> f64 {
let (y1, m1, day1) = civil_from_days(d1.serial());
let (y2, m2, day2) = civil_from_days(d2.serial());
let mut dd1 = day1;
let mut dd2 = day2;
if dd1 == 31 {
dd1 = 30;
}
if dd2 == 31 && dd1 == 30 {
dd2 = 30;
}
let dy = y2 - y1;
let dm = i32::try_from(m2).unwrap_or(0) - i32::try_from(m1).unwrap_or(0);
let dd = i32::try_from(dd2).unwrap_or(0) - i32::try_from(dd1).unwrap_or(0);
f64::from(360 * dy + 30 * dm + dd) / 360.0
}
fn thirty_360_e(d1: Date, d2: Date) -> f64 {
let (y1, m1, day1) = civil_from_days(d1.serial());
let (y2, m2, day2) = civil_from_days(d2.serial());
let dd1 = day1.min(30);
let dd2 = day2.min(30);
let dy = y2 - y1;
let dm = i32::try_from(m2).unwrap_or(0) - i32::try_from(m1).unwrap_or(0);
let dd = i32::try_from(dd2).unwrap_or(0) - i32::try_from(dd1).unwrap_or(0);
f64::from(360 * dy + 30 * dm + dd) / 360.0
}
fn act_act_isda(d1: Date, d2: Date) -> f64 {
let y1 = d1.year();
let y2 = d2.year();
if y1 == y2 {
let denom = if is_leap_year(y1) { 366.0 } else { 365.0 };
return f64::from(d1.days_between(d2)) / denom;
}
let next_y1 = Date(days_from_civil(y1 + 1, 1, 1));
let days_in_y1 = if is_leap_year(y1) { 366.0 } else { 365.0 };
let first = f64::from(d1.days_between(next_y1)) / days_in_y1;
let start_y2 = Date(days_from_civil(y2, 1, 1));
let days_in_y2 = if is_leap_year(y2) { 366.0 } else { 365.0 };
let last = f64::from(start_y2.days_between(d2)) / days_in_y2;
let middle = if y2 - y1 >= 2 {
f64::from(y2 - y1 - 1)
} else {
0.0
};
first + middle + last
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Compounding {
Simple,
Continuous,
Periodic {
periods_per_year: u32,
},
}
impl Compounding {
pub fn discount_from_rate(self, rate: f64, t: f64) -> Result<f64, TypeError> {
if !rate.is_finite() {
return Err(TypeError::NonFinite { name: "rate" });
}
if !t.is_finite() {
return Err(TypeError::NonFinite { name: "t" });
}
if t < 0.0 {
return Err(TypeError::NonPositiveRange);
}
if t == 0.0 {
return Ok(1.0);
}
match self {
Self::Simple => Ok(1.0 / (1.0 + rate * t)),
Self::Continuous => Ok((-rate * t).exp()),
Self::Periodic { periods_per_year } => {
if periods_per_year == 0 {
return Err(TypeError::InvalidTenor {
reason: "Periodic compounding requires periods_per_year > 0",
});
}
let n = f64::from(periods_per_year);
Ok((1.0 + rate / n).powf(-n * t))
}
}
}
pub fn rate_from_discount(self, discount: f64, t: f64) -> Result<f64, TypeError> {
if !discount.is_finite() {
return Err(TypeError::NonFinite { name: "discount" });
}
if !t.is_finite() {
return Err(TypeError::NonFinite { name: "t" });
}
if t < 0.0 {
return Err(TypeError::NonPositiveRange);
}
if t == 0.0 {
if (discount - 1.0).abs() < f64::EPSILON {
return Ok(0.0);
}
return Err(TypeError::NonPositiveRange);
}
if discount <= 0.0 {
return Err(TypeError::InvalidTenor {
reason: "discount must be strictly positive",
});
}
match self {
Self::Simple => Ok((1.0 / discount - 1.0) / t),
Self::Continuous => Ok(-discount.ln() / t),
Self::Periodic { periods_per_year } => {
if periods_per_year == 0 {
return Err(TypeError::InvalidTenor {
reason: "Periodic compounding requires periods_per_year > 0",
});
}
let n = f64::from(periods_per_year);
Ok(n * (discount.powf(-1.0 / (n * t)) - 1.0))
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Frequency {
Annual,
SemiAnnual,
Quarterly,
Monthly,
OnceAtMaturity,
}
impl Frequency {
#[must_use]
#[inline]
pub const fn periods_per_year(self) -> u32 {
match self {
Self::Annual => 1,
Self::SemiAnnual => 2,
Self::Quarterly => 4,
Self::Monthly => 12,
Self::OnceAtMaturity => 0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum BusinessDayConvention {
Unadjusted,
Following,
ModifiedFollowing,
Preceding,
ModifiedPreceding,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn date_epoch_is_serial_zero() {
let d = Date::from_ymd(1970, 1, 1).unwrap();
assert_eq!(d.serial(), 0);
}
#[test]
fn date_from_serial_roundtrip() {
let d = Date::from_serial(0);
assert_eq!((d.year(), d.month(), d.day()), (1970, 1, 1));
}
#[test]
fn date_y2k() {
let d = Date::from_ymd(2000, 1, 1).unwrap();
assert_eq!(d.serial(), 10_957);
assert_eq!((d.year(), d.month(), d.day()), (2000, 1, 1));
}
#[test]
fn date_leap_year_feb_29_2000() {
let d = Date::from_ymd(2000, 2, 29).unwrap();
assert_eq!((d.year(), d.month(), d.day()), (2000, 2, 29));
}
#[test]
fn date_non_leap_feb_29_2100_rejected() {
let err = Date::from_ymd(2100, 2, 29).unwrap_err();
assert!(matches!(err, TypeError::InvalidDate { .. }));
}
#[test]
fn date_non_leap_feb_29_1900_rejected() {
let err = Date::from_ymd(1900, 2, 29).unwrap_err();
assert!(matches!(err, TypeError::InvalidDate { .. }));
}
#[test]
fn date_leap_year_2000_has_366_days() {
let jan = Date::from_ymd(2000, 1, 1).unwrap();
let dec = Date::from_ymd(2000, 12, 31).unwrap();
assert_eq!(jan.days_between(dec), 365);
}
#[test]
fn date_year_2100_has_365_days() {
let jan = Date::from_ymd(2100, 1, 1).unwrap();
let dec = Date::from_ymd(2100, 12, 31).unwrap();
assert_eq!(jan.days_between(dec), 364);
}
#[test]
fn date_year_1900_has_365_days() {
let jan = Date::from_ymd(1900, 1, 1).unwrap();
let dec = Date::from_ymd(1900, 12, 31).unwrap();
assert_eq!(jan.days_between(dec), 364);
}
#[test]
fn date_year_2400_is_leap() {
assert!(Date::from_ymd(2400, 2, 29).is_ok());
}
#[test]
fn date_invalid_month_rejected() {
assert!(Date::from_ymd(2024, 0, 15).is_err());
assert!(Date::from_ymd(2024, 13, 15).is_err());
}
#[test]
fn date_invalid_day_rejected() {
assert!(Date::from_ymd(2024, 1, 0).is_err());
assert!(Date::from_ymd(2024, 4, 31).is_err()); assert!(Date::from_ymd(2023, 2, 29).is_err()); }
#[test]
fn date_roundtrip_representative_set() {
let dates: [(i32, u32, u32); 32] = [
(1900, 1, 1),
(1900, 2, 28),
(1900, 12, 31),
(1904, 2, 29),
(1969, 12, 31),
(1970, 1, 1),
(1970, 1, 2),
(1972, 2, 29),
(1999, 12, 31),
(2000, 1, 1),
(2000, 2, 29),
(2000, 12, 31),
(2001, 1, 1),
(2004, 2, 29),
(2008, 2, 29),
(2012, 2, 29),
(2016, 2, 29),
(2019, 12, 31),
(2020, 1, 1),
(2020, 2, 29),
(2020, 12, 31),
(2023, 12, 31),
(2024, 1, 1),
(2024, 2, 29),
(2024, 7, 4),
(2024, 12, 31),
(2100, 1, 1),
(2100, 2, 28),
(2100, 12, 31),
(2200, 6, 15),
(2400, 2, 29),
(2500, 12, 31),
];
for &(y, m, d) in &dates {
let date = Date::from_ymd(y, m, d).unwrap();
assert_eq!(date.year(), y, "year mismatch for {y}-{m}-{d}");
assert_eq!(date.month(), m, "month mismatch for {y}-{m}-{d}");
assert_eq!(date.day(), d, "day mismatch for {y}-{m}-{d}");
}
}
#[test]
fn date_roundtrip_serial_iter() {
let start = Date::from_ymd(2024, 1, 1).unwrap();
for offset in 0..400 {
let d = start.add_days(offset);
let recon = Date::from_ymd(d.year(), d.month(), d.day()).unwrap();
assert_eq!(d, recon);
}
}
#[test]
fn date_add_days_signed() {
let d = Date::from_ymd(2024, 3, 1).unwrap();
let prev = d.add_days(-1);
assert_eq!((prev.year(), prev.month(), prev.day()), (2024, 2, 29));
}
#[test]
fn date_days_between_signed() {
let a = Date::from_ymd(2024, 1, 1).unwrap();
let b = Date::from_ymd(2024, 1, 11).unwrap();
assert_eq!(a.days_between(b), 10);
assert_eq!(b.days_between(a), -10);
}
#[test]
fn date_ordering() {
let a = Date::from_ymd(2024, 1, 1).unwrap();
let b = Date::from_ymd(2024, 6, 1).unwrap();
assert!(a < b);
assert!(b > a);
}
#[test]
fn date_copy_eq_hash() {
let d = Date::from_ymd(2024, 1, 1).unwrap();
let copy = d;
assert_eq!(d, copy);
let mut set = std::collections::HashSet::new();
set.insert(d);
assert!(set.contains(©));
}
#[test]
fn tenor_days() {
let t = Tenor::new(7, TenorUnit::Days);
let start = Date::from_ymd(2024, 1, 1).unwrap();
let end = t.add_to(start);
assert_eq!((end.year(), end.month(), end.day()), (2024, 1, 8));
}
#[test]
fn tenor_weeks() {
let t = Tenor::new(2, TenorUnit::Weeks);
let start = Date::from_ymd(2024, 1, 1).unwrap();
let end = t.add_to(start);
assert_eq!((end.year(), end.month(), end.day()), (2024, 1, 15));
}
#[test]
fn tenor_months_end_of_month() {
let t = Tenor::new(1, TenorUnit::Months);
let start = Date::from_ymd(2024, 1, 31).unwrap();
let end = t.add_to(start);
assert_eq!((end.year(), end.month(), end.day()), (2024, 2, 29));
}
#[test]
fn tenor_months_non_leap() {
let t = Tenor::new(1, TenorUnit::Months);
let start = Date::from_ymd(2023, 1, 31).unwrap();
let end = t.add_to(start);
assert_eq!((end.year(), end.month(), end.day()), (2023, 2, 28));
}
#[test]
fn tenor_months_cross_year_back() {
let t = Tenor::new(-1, TenorUnit::Months);
let start = Date::from_ymd(2024, 1, 15).unwrap();
let end = t.add_to(start);
assert_eq!((end.year(), end.month(), end.day()), (2023, 12, 15));
}
#[test]
fn tenor_years() {
let t = Tenor::new(5, TenorUnit::Years);
let start = Date::from_ymd(2020, 6, 15).unwrap();
let end = t.add_to(start);
assert_eq!((end.year(), end.month(), end.day()), (2025, 6, 15));
}
#[test]
fn tenor_years_leap_day() {
let t = Tenor::new(1, TenorUnit::Years);
let start = Date::from_ymd(2024, 2, 29).unwrap();
let end = t.add_to(start);
assert_eq!((end.year(), end.month(), end.day()), (2025, 2, 28));
}
#[test]
fn tenor_constructor_fields() {
let t = Tenor::new(3, TenorUnit::Months);
assert_eq!(t.count, 3);
assert_eq!(t.unit, TenorUnit::Months);
}
#[test]
fn tenor_unit_copy_eq() {
let u = TenorUnit::Days;
let copy = u;
assert_eq!(u, copy);
}
#[test]
fn daycount_act360_isda_example() {
let d1 = Date::from_ymd(2003, 11, 1).unwrap();
let d2 = Date::from_ymd(2004, 5, 1).unwrap();
let tau = Daycount::Act360.year_fraction(d1, d2).unwrap();
assert!((tau - 182.0_f64 / 360.0).abs() < 1e-15);
}
#[test]
fn daycount_act365f_example() {
let d1 = Date::from_ymd(2024, 1, 1).unwrap();
let d2 = Date::from_ymd(2024, 7, 1).unwrap();
let tau = Daycount::Act365F.year_fraction(d1, d2).unwrap();
assert!((tau - 182.0_f64 / 365.0).abs() < 1e-15);
}
#[test]
fn daycount_act_act_isda_single_year() {
let d1 = Date::from_ymd(2024, 1, 1).unwrap();
let d2 = Date::from_ymd(2024, 7, 1).unwrap();
let tau = Daycount::ActActIsda.year_fraction(d1, d2).unwrap();
assert!((tau - 182.0_f64 / 366.0).abs() < 1e-15);
}
#[test]
fn daycount_act_act_isda_single_year_non_leap() {
let d1 = Date::from_ymd(2023, 1, 1).unwrap();
let d2 = Date::from_ymd(2023, 7, 1).unwrap();
let tau = Daycount::ActActIsda.year_fraction(d1, d2).unwrap();
assert!((tau - 181.0_f64 / 365.0).abs() < 1e-15);
}
#[test]
fn daycount_act_act_isda_cross_year() {
let d1 = Date::from_ymd(2003, 11, 1).unwrap();
let d2 = Date::from_ymd(2004, 5, 1).unwrap();
let tau = Daycount::ActActIsda.year_fraction(d1, d2).unwrap();
let expected = 61.0_f64 / 365.0 + 121.0_f64 / 366.0;
assert!((tau - expected).abs() < 1e-15);
}
#[test]
fn daycount_act_act_isda_multi_year() {
let d1 = Date::from_ymd(2003, 6, 15).unwrap();
let d2 = Date::from_ymd(2007, 6, 15).unwrap();
let tau = Daycount::ActActIsda.year_fraction(d1, d2).unwrap();
let first = f64::from(d1.days_between(Date::from_ymd(2004, 1, 1).unwrap())) / 365.0;
let last = f64::from(Date::from_ymd(2007, 1, 1).unwrap().days_between(d2)) / 365.0;
let expected = first + 3.0 + last;
assert!((tau - expected).abs() < 1e-14);
}
#[test]
fn daycount_thirty_360_e() {
let d1 = Date::from_ymd(2003, 11, 1).unwrap();
let d2 = Date::from_ymd(2004, 5, 1).unwrap();
let tau = Daycount::Thirty360E.year_fraction(d1, d2).unwrap();
assert!((tau - 180.0_f64 / 360.0).abs() < 1e-15);
}
#[test]
fn daycount_thirty_360_e_clip() {
let d1 = Date::from_ymd(2024, 1, 31).unwrap();
let d2 = Date::from_ymd(2024, 5, 31).unwrap();
let tau = Daycount::Thirty360E.year_fraction(d1, d2).unwrap();
assert!((tau - 120.0_f64 / 360.0).abs() < 1e-15);
}
#[test]
fn daycount_thirty_360_bb_isda_example() {
let d1 = Date::from_ymd(2007, 2, 28).unwrap();
let d2 = Date::from_ymd(2007, 8, 31).unwrap();
let tau = Daycount::Thirty360BondBasis.year_fraction(d1, d2).unwrap();
assert!((tau - 183.0_f64 / 360.0).abs() < 1e-15);
}
#[test]
fn daycount_thirty_360_bb_d1_is_31() {
let d1 = Date::from_ymd(2024, 1, 31).unwrap();
let d2 = Date::from_ymd(2024, 7, 31).unwrap();
let tau = Daycount::Thirty360BondBasis.year_fraction(d1, d2).unwrap();
assert!((tau - 0.5).abs() < 1e-15);
}
#[test]
fn daycount_thirty_360_bb_d2_is_31_d1_not_30() {
let d1 = Date::from_ymd(2024, 1, 15).unwrap();
let d2 = Date::from_ymd(2024, 7, 31).unwrap();
let tau = Daycount::Thirty360BondBasis.year_fraction(d1, d2).unwrap();
assert!((tau - 196.0_f64 / 360.0).abs() < 1e-15);
}
#[test]
fn daycount_act_act_icma_quarterly() {
let d1 = Date::from_ymd(2024, 1, 1).unwrap();
let d2 = Date::from_ymd(2024, 4, 1).unwrap();
let tau = Daycount::ActActIcma {
coupons_per_year: 4,
}
.year_fraction(d1, d2)
.unwrap();
assert!((tau - 0.25).abs() < 1e-15);
}
#[test]
fn daycount_act_act_icma_zero_freq_rejected() {
let d1 = Date::from_ymd(2024, 1, 1).unwrap();
let d2 = Date::from_ymd(2024, 4, 1).unwrap();
let err = Daycount::ActActIcma {
coupons_per_year: 0,
}
.year_fraction(d1, d2)
.unwrap_err();
assert!(matches!(err, TypeError::InvalidTenor { .. }));
}
#[test]
fn daycount_business252_rejected() {
let d1 = Date::from_ymd(2024, 1, 1).unwrap();
let d2 = Date::from_ymd(2024, 4, 1).unwrap();
let err = Daycount::Business252.year_fraction(d1, d2).unwrap_err();
match err {
TypeError::InvalidTenor { reason } => {
assert!(reason.contains("Business252"));
}
other => panic!("unexpected variant {other:?}"),
}
}
#[test]
fn daycount_negative_range_rejected() {
let d1 = Date::from_ymd(2024, 6, 1).unwrap();
let d2 = Date::from_ymd(2024, 1, 1).unwrap();
let err = Daycount::Act360.year_fraction(d1, d2).unwrap_err();
assert!(matches!(err, TypeError::NonPositiveRange));
}
#[test]
fn daycount_zero_range_ok() {
let d = Date::from_ymd(2024, 1, 1).unwrap();
let tau = Daycount::Act360.year_fraction(d, d).unwrap();
assert!((tau - 0.0).abs() < 1e-15);
}
#[test]
fn daycount_copy_eq() {
let dc = Daycount::Act360;
let copy = dc;
assert_eq!(dc, copy);
}
#[test]
fn compounding_continuous_roundtrip() {
let r = 0.05;
let t = 2.0;
let d = Compounding::Continuous.discount_from_rate(r, t).unwrap();
assert!((d - (-r * t).exp()).abs() < 1e-15);
let r_back = Compounding::Continuous.rate_from_discount(d, t).unwrap();
assert!((r - r_back).abs() < 1e-12);
}
#[test]
fn compounding_simple_roundtrip() {
let r = 0.03;
let t = 0.5;
let d = Compounding::Simple.discount_from_rate(r, t).unwrap();
assert!((d - 1.0 / (1.0 + r * t)).abs() < 1e-15);
let r_back = Compounding::Simple.rate_from_discount(d, t).unwrap();
assert!((r - r_back).abs() < 1e-12);
}
#[test]
fn compounding_periodic_roundtrip() {
let r = 0.06;
let t = 3.0;
let comp = Compounding::Periodic {
periods_per_year: 2,
};
let d = comp.discount_from_rate(r, t).unwrap();
assert!((d - (1.0_f64 + 0.03).powi(-6)).abs() < 1e-12);
let r_back = comp.rate_from_discount(d, t).unwrap();
assert!((r - r_back).abs() < 1e-12);
}
#[test]
fn compounding_zero_time_discount_is_one() {
let d = Compounding::Continuous
.discount_from_rate(0.05, 0.0)
.unwrap();
assert!((d - 1.0).abs() < 1e-15);
}
#[test]
fn compounding_zero_time_unit_discount_gives_zero_rate() {
let r = Compounding::Continuous
.rate_from_discount(1.0, 0.0)
.unwrap();
assert!((r - 0.0).abs() < 1e-15);
}
#[test]
fn compounding_rejects_non_finite_rate() {
let err = Compounding::Continuous
.discount_from_rate(f64::NAN, 1.0)
.unwrap_err();
assert!(matches!(err, TypeError::NonFinite { name: "rate" }));
}
#[test]
fn compounding_rejects_non_finite_t() {
let err = Compounding::Continuous
.discount_from_rate(0.05, f64::INFINITY)
.unwrap_err();
assert!(matches!(err, TypeError::NonFinite { name: "t" }));
}
#[test]
fn compounding_rejects_negative_t() {
let err = Compounding::Continuous
.discount_from_rate(0.05, -1.0)
.unwrap_err();
assert!(matches!(err, TypeError::NonPositiveRange));
}
#[test]
fn compounding_rejects_zero_periods() {
let err = Compounding::Periodic {
periods_per_year: 0,
}
.discount_from_rate(0.05, 1.0)
.unwrap_err();
assert!(matches!(err, TypeError::InvalidTenor { .. }));
let err = Compounding::Periodic {
periods_per_year: 0,
}
.rate_from_discount(0.95, 1.0)
.unwrap_err();
assert!(matches!(err, TypeError::InvalidTenor { .. }));
}
#[test]
fn compounding_rate_from_discount_rejects_non_positive_discount() {
let err = Compounding::Continuous
.rate_from_discount(0.0, 1.0)
.unwrap_err();
assert!(matches!(err, TypeError::InvalidTenor { .. }));
let err = Compounding::Continuous
.rate_from_discount(-0.5, 1.0)
.unwrap_err();
assert!(matches!(err, TypeError::InvalidTenor { .. }));
}
#[test]
fn compounding_rate_from_discount_rejects_non_finite() {
let err = Compounding::Continuous
.rate_from_discount(f64::NAN, 1.0)
.unwrap_err();
assert!(matches!(err, TypeError::NonFinite { name: "discount" }));
let err = Compounding::Continuous
.rate_from_discount(0.95, f64::NAN)
.unwrap_err();
assert!(matches!(err, TypeError::NonFinite { name: "t" }));
}
#[test]
fn compounding_rate_from_discount_rejects_negative_t() {
let err = Compounding::Continuous
.rate_from_discount(0.95, -1.0)
.unwrap_err();
assert!(matches!(err, TypeError::NonPositiveRange));
}
#[test]
fn compounding_rate_from_discount_rejects_zero_t_non_unit() {
let err = Compounding::Continuous
.rate_from_discount(0.95, 0.0)
.unwrap_err();
assert!(matches!(err, TypeError::NonPositiveRange));
}
#[test]
fn frequency_periods_per_year() {
assert_eq!(Frequency::Annual.periods_per_year(), 1);
assert_eq!(Frequency::SemiAnnual.periods_per_year(), 2);
assert_eq!(Frequency::Quarterly.periods_per_year(), 4);
assert_eq!(Frequency::Monthly.periods_per_year(), 12);
assert_eq!(Frequency::OnceAtMaturity.periods_per_year(), 0);
}
#[test]
fn frequency_copy_eq() {
let f = Frequency::Quarterly;
let copy = f;
assert_eq!(f, copy);
}
#[test]
fn business_day_convention_copy_eq() {
let c = BusinessDayConvention::ModifiedFollowing;
let copy = c;
assert_eq!(c, copy);
}
#[test]
fn business_day_convention_debug_includes_variant() {
let s = format!("{:?}", BusinessDayConvention::Following);
assert!(s.contains("Following"));
}
}