1use crate::errors::TypeError;
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
64pub struct Date(i32);
65
66impl Date {
67 pub fn from_ymd(year: i32, month: u32, day: u32) -> Result<Self, TypeError> {
92 if !(1..=12).contains(&month) {
93 return Err(TypeError::InvalidDate { year, month, day });
94 }
95 if day == 0 || day > days_in_month(year, month) {
96 return Err(TypeError::InvalidDate { year, month, day });
97 }
98 Ok(Self(days_from_civil(year, month, day)))
99 }
100
101 #[must_use]
117 #[inline]
118 pub const fn from_serial(days: i32) -> Self {
119 Self(days)
120 }
121
122 #[must_use]
133 #[inline]
134 pub const fn serial(self) -> i32 {
135 self.0
136 }
137
138 #[must_use]
148 pub fn year(self) -> i32 {
149 civil_from_days(self.0).0
150 }
151
152 #[must_use]
162 pub fn month(self) -> u32 {
163 civil_from_days(self.0).1
164 }
165
166 #[must_use]
176 pub fn day(self) -> u32 {
177 civil_from_days(self.0).2
178 }
179
180 #[must_use]
192 #[inline]
193 pub const fn add_days(self, days: i32) -> Self {
194 Self(self.0.wrapping_add(days))
195 }
196
197 #[must_use]
211 #[inline]
212 pub const fn days_between(self, other: Self) -> i32 {
213 other.0.wrapping_sub(self.0)
214 }
215}
216
217#[inline]
220const fn is_leap_year(year: i32) -> bool {
221 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
222}
223
224#[inline]
226fn days_in_month(year: i32, month: u32) -> u32 {
227 match month {
228 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
229 4 | 6 | 9 | 11 => 30,
230 2 => {
231 if is_leap_year(year) {
232 29
233 } else {
234 28
235 }
236 }
237 _ => 0,
238 }
239}
240
241fn days_from_civil(y: i32, m: u32, d: u32) -> i32 {
246 let y = if m <= 2 { y - 1 } else { y };
247 let era = if y >= 0 { y } else { y - 399 } / 400;
248 let yoe = u32::try_from(y - era * 400).unwrap_or(0);
250 let mp = if m > 2 { m - 3 } else { m + 9 };
252 let doy = (153 * mp + 2) / 5 + d - 1;
253 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
255 let doe_i = i32::try_from(doe).unwrap_or(i32::MAX);
257 era * 146_097 + doe_i - 719_468
258}
259
260fn civil_from_days(z: i32) -> (i32, u32, u32) {
263 let z = z + 719_468;
264 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
265 let doe = u32::try_from(z - era * 146_097).unwrap_or(0);
267 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
269 let y_partial = i32::try_from(yoe).unwrap_or(i32::MAX) + era * 400;
270 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
272 let mp = (5 * doy + 2) / 153;
274 let d = doy - (153 * mp + 2) / 5 + 1;
275 let m = if mp < 10 { mp + 3 } else { mp - 9 };
276 let y = if m <= 2 { y_partial + 1 } else { y_partial };
277 (y, m, d)
278}
279
280#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
284pub enum TenorUnit {
285 Days,
287 Weeks,
289 Months,
291 Years,
293}
294
295#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
313pub struct Tenor {
314 pub count: i32,
316 pub unit: TenorUnit,
318}
319
320impl Tenor {
321 #[must_use]
332 #[inline]
333 pub const fn new(count: i32, unit: TenorUnit) -> Self {
334 Self { count, unit }
335 }
336
337 #[must_use]
357 pub fn add_to(self, start: Date) -> Date {
358 match self.unit {
359 TenorUnit::Days => start.add_days(self.count),
360 TenorUnit::Weeks => start.add_days(self.count.wrapping_mul(7)),
361 TenorUnit::Months => add_months(start, self.count),
362 TenorUnit::Years => add_months(start, self.count.wrapping_mul(12)),
363 }
364 }
365}
366
367fn add_months(start: Date, months: i32) -> Date {
369 let (y, m, d) = civil_from_days(start.serial());
370 let m0 = i32::try_from(m).unwrap_or(0).saturating_sub(1);
372 let total = m0.wrapping_add(months);
373 let dy = total.div_euclid(12);
375 let new_m0 = total.rem_euclid(12);
376 let new_y = y.wrapping_add(dy);
377 let new_m = u32::try_from(new_m0 + 1).unwrap_or(1);
378 let max_d = days_in_month(new_y, new_m);
379 let new_d = d.min(max_d);
380 Date(days_from_civil(new_y, new_m, new_d))
381}
382
383#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
395pub enum Daycount {
396 Act360,
400 Act365F,
404 Thirty360BondBasis,
406 Thirty360E,
408 ActActIsda,
412 ActActIcma {
421 coupons_per_year: u32,
423 },
424 Business252,
430}
431
432impl Daycount {
433 pub fn year_fraction(self, d1: Date, d2: Date) -> Result<f64, TypeError> {
467 let span = d1.days_between(d2);
468 if span < 0 {
469 return Err(TypeError::NonPositiveRange);
470 }
471 match self {
472 Self::Act360 => Ok(f64::from(span) / 360.0),
473 Self::Act365F => Ok(f64::from(span) / 365.0),
474 Self::Thirty360BondBasis => Ok(thirty_360_bond_basis(d1, d2)),
475 Self::Thirty360E => Ok(thirty_360_e(d1, d2)),
476 Self::ActActIsda => Ok(act_act_isda(d1, d2)),
477 Self::ActActIcma { coupons_per_year } => {
478 if coupons_per_year == 0 {
479 return Err(TypeError::InvalidTenor {
480 reason: "ActActIcma requires coupons_per_year > 0",
481 });
482 }
483 Ok(1.0 / f64::from(coupons_per_year))
484 }
485 Self::Business252 => Err(TypeError::InvalidTenor {
486 reason: "Business252 requires a calendar; supply already-computed year fractions",
487 }),
488 }
489 }
490}
491
492fn thirty_360_bond_basis(d1: Date, d2: Date) -> f64 {
506 let (y1, m1, day1) = civil_from_days(d1.serial());
507 let (y2, m2, day2) = civil_from_days(d2.serial());
508 let mut dd1 = day1;
509 let mut dd2 = day2;
510 if dd1 == 31 {
511 dd1 = 30;
512 }
513 if dd2 == 31 && dd1 == 30 {
514 dd2 = 30;
515 }
516 let dy = y2 - y1;
517 let dm = i32::try_from(m2).unwrap_or(0) - i32::try_from(m1).unwrap_or(0);
518 let dd = i32::try_from(dd2).unwrap_or(0) - i32::try_from(dd1).unwrap_or(0);
519 f64::from(360 * dy + 30 * dm + dd) / 360.0
520}
521
522fn thirty_360_e(d1: Date, d2: Date) -> f64 {
527 let (y1, m1, day1) = civil_from_days(d1.serial());
528 let (y2, m2, day2) = civil_from_days(d2.serial());
529 let dd1 = day1.min(30);
530 let dd2 = day2.min(30);
531 let dy = y2 - y1;
532 let dm = i32::try_from(m2).unwrap_or(0) - i32::try_from(m1).unwrap_or(0);
533 let dd = i32::try_from(dd2).unwrap_or(0) - i32::try_from(dd1).unwrap_or(0);
534 f64::from(360 * dy + 30 * dm + dd) / 360.0
535}
536
537fn act_act_isda(d1: Date, d2: Date) -> f64 {
543 let y1 = d1.year();
544 let y2 = d2.year();
545 if y1 == y2 {
546 let denom = if is_leap_year(y1) { 366.0 } else { 365.0 };
547 return f64::from(d1.days_between(d2)) / denom;
548 }
549 let next_y1 = Date(days_from_civil(y1 + 1, 1, 1));
551 let days_in_y1 = if is_leap_year(y1) { 366.0 } else { 365.0 };
552 let first = f64::from(d1.days_between(next_y1)) / days_in_y1;
553 let start_y2 = Date(days_from_civil(y2, 1, 1));
555 let days_in_y2 = if is_leap_year(y2) { 366.0 } else { 365.0 };
556 let last = f64::from(start_y2.days_between(d2)) / days_in_y2;
557 let middle = if y2 - y1 >= 2 {
559 f64::from(y2 - y1 - 1)
560 } else {
561 0.0
562 };
563 first + middle + last
564}
565
566#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
583pub enum Compounding {
584 Simple,
586 Continuous,
588 Periodic {
590 periods_per_year: u32,
593 },
594}
595
596impl Compounding {
597 pub fn discount_from_rate(self, rate: f64, t: f64) -> Result<f64, TypeError> {
615 if !rate.is_finite() {
616 return Err(TypeError::NonFinite { name: "rate" });
617 }
618 if !t.is_finite() {
619 return Err(TypeError::NonFinite { name: "t" });
620 }
621 if t < 0.0 {
622 return Err(TypeError::NonPositiveRange);
623 }
624 if t == 0.0 {
625 return Ok(1.0);
626 }
627 match self {
628 Self::Simple => Ok(1.0 / (1.0 + rate * t)),
629 Self::Continuous => Ok((-rate * t).exp()),
630 Self::Periodic { periods_per_year } => {
631 if periods_per_year == 0 {
632 return Err(TypeError::InvalidTenor {
633 reason: "Periodic compounding requires periods_per_year > 0",
634 });
635 }
636 let n = f64::from(periods_per_year);
637 Ok((1.0 + rate / n).powf(-n * t))
638 }
639 }
640 }
641
642 pub fn rate_from_discount(self, discount: f64, t: f64) -> Result<f64, TypeError> {
667 if !discount.is_finite() {
668 return Err(TypeError::NonFinite { name: "discount" });
669 }
670 if !t.is_finite() {
671 return Err(TypeError::NonFinite { name: "t" });
672 }
673 if t < 0.0 {
674 return Err(TypeError::NonPositiveRange);
675 }
676 if t == 0.0 {
677 if (discount - 1.0).abs() < f64::EPSILON {
678 return Ok(0.0);
679 }
680 return Err(TypeError::NonPositiveRange);
681 }
682 if discount <= 0.0 {
683 return Err(TypeError::InvalidTenor {
684 reason: "discount must be strictly positive",
685 });
686 }
687 match self {
688 Self::Simple => Ok((1.0 / discount - 1.0) / t),
689 Self::Continuous => Ok(-discount.ln() / t),
690 Self::Periodic { periods_per_year } => {
691 if periods_per_year == 0 {
692 return Err(TypeError::InvalidTenor {
693 reason: "Periodic compounding requires periods_per_year > 0",
694 });
695 }
696 let n = f64::from(periods_per_year);
697 Ok(n * (discount.powf(-1.0 / (n * t)) - 1.0))
698 }
699 }
700 }
701}
702
703#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
707pub enum Frequency {
708 Annual,
710 SemiAnnual,
712 Quarterly,
714 Monthly,
716 OnceAtMaturity,
718}
719
720impl Frequency {
721 #[must_use]
733 #[inline]
734 pub const fn periods_per_year(self) -> u32 {
735 match self {
736 Self::Annual => 1,
737 Self::SemiAnnual => 2,
738 Self::Quarterly => 4,
739 Self::Monthly => 12,
740 Self::OnceAtMaturity => 0,
741 }
742 }
743}
744
745#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
754pub enum BusinessDayConvention {
755 Unadjusted,
757 Following,
759 ModifiedFollowing,
762 Preceding,
764 ModifiedPreceding,
767}
768
769#[cfg(test)]
770mod tests {
771 use super::*;
772
773 #[test]
776 fn date_epoch_is_serial_zero() {
777 let d = Date::from_ymd(1970, 1, 1).unwrap();
778 assert_eq!(d.serial(), 0);
779 }
780
781 #[test]
782 fn date_from_serial_roundtrip() {
783 let d = Date::from_serial(0);
784 assert_eq!((d.year(), d.month(), d.day()), (1970, 1, 1));
785 }
786
787 #[test]
788 fn date_y2k() {
789 let d = Date::from_ymd(2000, 1, 1).unwrap();
790 assert_eq!(d.serial(), 10_957);
794 assert_eq!((d.year(), d.month(), d.day()), (2000, 1, 1));
795 }
796
797 #[test]
798 fn date_leap_year_feb_29_2000() {
799 let d = Date::from_ymd(2000, 2, 29).unwrap();
800 assert_eq!((d.year(), d.month(), d.day()), (2000, 2, 29));
801 }
802
803 #[test]
804 fn date_non_leap_feb_29_2100_rejected() {
805 let err = Date::from_ymd(2100, 2, 29).unwrap_err();
807 assert!(matches!(err, TypeError::InvalidDate { .. }));
808 }
809
810 #[test]
811 fn date_non_leap_feb_29_1900_rejected() {
812 let err = Date::from_ymd(1900, 2, 29).unwrap_err();
814 assert!(matches!(err, TypeError::InvalidDate { .. }));
815 }
816
817 #[test]
818 fn date_leap_year_2000_has_366_days() {
819 let jan = Date::from_ymd(2000, 1, 1).unwrap();
820 let dec = Date::from_ymd(2000, 12, 31).unwrap();
821 assert_eq!(jan.days_between(dec), 365);
822 }
823
824 #[test]
825 fn date_year_2100_has_365_days() {
826 let jan = Date::from_ymd(2100, 1, 1).unwrap();
827 let dec = Date::from_ymd(2100, 12, 31).unwrap();
828 assert_eq!(jan.days_between(dec), 364);
829 }
830
831 #[test]
832 fn date_year_1900_has_365_days() {
833 let jan = Date::from_ymd(1900, 1, 1).unwrap();
834 let dec = Date::from_ymd(1900, 12, 31).unwrap();
835 assert_eq!(jan.days_between(dec), 364);
836 }
837
838 #[test]
839 fn date_year_2400_is_leap() {
840 assert!(Date::from_ymd(2400, 2, 29).is_ok());
842 }
843
844 #[test]
845 fn date_invalid_month_rejected() {
846 assert!(Date::from_ymd(2024, 0, 15).is_err());
847 assert!(Date::from_ymd(2024, 13, 15).is_err());
848 }
849
850 #[test]
851 fn date_invalid_day_rejected() {
852 assert!(Date::from_ymd(2024, 1, 0).is_err());
853 assert!(Date::from_ymd(2024, 4, 31).is_err()); assert!(Date::from_ymd(2023, 2, 29).is_err()); }
856
857 #[test]
858 fn date_roundtrip_representative_set() {
859 let dates: [(i32, u32, u32); 32] = [
862 (1900, 1, 1),
863 (1900, 2, 28),
864 (1900, 12, 31),
865 (1904, 2, 29),
866 (1969, 12, 31),
867 (1970, 1, 1),
868 (1970, 1, 2),
869 (1972, 2, 29),
870 (1999, 12, 31),
871 (2000, 1, 1),
872 (2000, 2, 29),
873 (2000, 12, 31),
874 (2001, 1, 1),
875 (2004, 2, 29),
876 (2008, 2, 29),
877 (2012, 2, 29),
878 (2016, 2, 29),
879 (2019, 12, 31),
880 (2020, 1, 1),
881 (2020, 2, 29),
882 (2020, 12, 31),
883 (2023, 12, 31),
884 (2024, 1, 1),
885 (2024, 2, 29),
886 (2024, 7, 4),
887 (2024, 12, 31),
888 (2100, 1, 1),
889 (2100, 2, 28),
890 (2100, 12, 31),
891 (2200, 6, 15),
892 (2400, 2, 29),
893 (2500, 12, 31),
894 ];
895 for &(y, m, d) in &dates {
896 let date = Date::from_ymd(y, m, d).unwrap();
897 assert_eq!(date.year(), y, "year mismatch for {y}-{m}-{d}");
898 assert_eq!(date.month(), m, "month mismatch for {y}-{m}-{d}");
899 assert_eq!(date.day(), d, "day mismatch for {y}-{m}-{d}");
900 }
901 }
902
903 #[test]
904 fn date_roundtrip_serial_iter() {
905 let start = Date::from_ymd(2024, 1, 1).unwrap();
907 for offset in 0..400 {
908 let d = start.add_days(offset);
909 let recon = Date::from_ymd(d.year(), d.month(), d.day()).unwrap();
910 assert_eq!(d, recon);
911 }
912 }
913
914 #[test]
915 fn date_add_days_signed() {
916 let d = Date::from_ymd(2024, 3, 1).unwrap();
917 let prev = d.add_days(-1);
918 assert_eq!((prev.year(), prev.month(), prev.day()), (2024, 2, 29));
920 }
921
922 #[test]
923 fn date_days_between_signed() {
924 let a = Date::from_ymd(2024, 1, 1).unwrap();
925 let b = Date::from_ymd(2024, 1, 11).unwrap();
926 assert_eq!(a.days_between(b), 10);
927 assert_eq!(b.days_between(a), -10);
928 }
929
930 #[test]
931 fn date_ordering() {
932 let a = Date::from_ymd(2024, 1, 1).unwrap();
933 let b = Date::from_ymd(2024, 6, 1).unwrap();
934 assert!(a < b);
935 assert!(b > a);
936 }
937
938 #[test]
939 fn date_copy_eq_hash() {
940 let d = Date::from_ymd(2024, 1, 1).unwrap();
941 let copy = d;
942 assert_eq!(d, copy);
943 let mut set = std::collections::HashSet::new();
944 set.insert(d);
945 assert!(set.contains(©));
946 }
947
948 #[test]
951 fn tenor_days() {
952 let t = Tenor::new(7, TenorUnit::Days);
953 let start = Date::from_ymd(2024, 1, 1).unwrap();
954 let end = t.add_to(start);
955 assert_eq!((end.year(), end.month(), end.day()), (2024, 1, 8));
956 }
957
958 #[test]
959 fn tenor_weeks() {
960 let t = Tenor::new(2, TenorUnit::Weeks);
961 let start = Date::from_ymd(2024, 1, 1).unwrap();
962 let end = t.add_to(start);
963 assert_eq!((end.year(), end.month(), end.day()), (2024, 1, 15));
964 }
965
966 #[test]
967 fn tenor_months_end_of_month() {
968 let t = Tenor::new(1, TenorUnit::Months);
970 let start = Date::from_ymd(2024, 1, 31).unwrap();
971 let end = t.add_to(start);
972 assert_eq!((end.year(), end.month(), end.day()), (2024, 2, 29));
973 }
974
975 #[test]
976 fn tenor_months_non_leap() {
977 let t = Tenor::new(1, TenorUnit::Months);
978 let start = Date::from_ymd(2023, 1, 31).unwrap();
979 let end = t.add_to(start);
980 assert_eq!((end.year(), end.month(), end.day()), (2023, 2, 28));
982 }
983
984 #[test]
985 fn tenor_months_cross_year_back() {
986 let t = Tenor::new(-1, TenorUnit::Months);
987 let start = Date::from_ymd(2024, 1, 15).unwrap();
988 let end = t.add_to(start);
989 assert_eq!((end.year(), end.month(), end.day()), (2023, 12, 15));
990 }
991
992 #[test]
993 fn tenor_years() {
994 let t = Tenor::new(5, TenorUnit::Years);
995 let start = Date::from_ymd(2020, 6, 15).unwrap();
996 let end = t.add_to(start);
997 assert_eq!((end.year(), end.month(), end.day()), (2025, 6, 15));
998 }
999
1000 #[test]
1001 fn tenor_years_leap_day() {
1002 let t = Tenor::new(1, TenorUnit::Years);
1004 let start = Date::from_ymd(2024, 2, 29).unwrap();
1005 let end = t.add_to(start);
1006 assert_eq!((end.year(), end.month(), end.day()), (2025, 2, 28));
1007 }
1008
1009 #[test]
1010 fn tenor_constructor_fields() {
1011 let t = Tenor::new(3, TenorUnit::Months);
1012 assert_eq!(t.count, 3);
1013 assert_eq!(t.unit, TenorUnit::Months);
1014 }
1015
1016 #[test]
1017 fn tenor_unit_copy_eq() {
1018 let u = TenorUnit::Days;
1019 let copy = u;
1020 assert_eq!(u, copy);
1021 }
1022
1023 #[test]
1026 fn daycount_act360_isda_example() {
1027 let d1 = Date::from_ymd(2003, 11, 1).unwrap();
1030 let d2 = Date::from_ymd(2004, 5, 1).unwrap();
1031 let tau = Daycount::Act360.year_fraction(d1, d2).unwrap();
1032 assert!((tau - 182.0_f64 / 360.0).abs() < 1e-15);
1033 }
1034
1035 #[test]
1036 fn daycount_act365f_example() {
1037 let d1 = Date::from_ymd(2024, 1, 1).unwrap();
1038 let d2 = Date::from_ymd(2024, 7, 1).unwrap();
1039 let tau = Daycount::Act365F.year_fraction(d1, d2).unwrap();
1040 assert!((tau - 182.0_f64 / 365.0).abs() < 1e-15);
1041 }
1042
1043 #[test]
1044 fn daycount_act_act_isda_single_year() {
1045 let d1 = Date::from_ymd(2024, 1, 1).unwrap();
1047 let d2 = Date::from_ymd(2024, 7, 1).unwrap();
1048 let tau = Daycount::ActActIsda.year_fraction(d1, d2).unwrap();
1049 assert!((tau - 182.0_f64 / 366.0).abs() < 1e-15);
1050 }
1051
1052 #[test]
1053 fn daycount_act_act_isda_single_year_non_leap() {
1054 let d1 = Date::from_ymd(2023, 1, 1).unwrap();
1055 let d2 = Date::from_ymd(2023, 7, 1).unwrap();
1056 let tau = Daycount::ActActIsda.year_fraction(d1, d2).unwrap();
1057 assert!((tau - 181.0_f64 / 365.0).abs() < 1e-15);
1058 }
1059
1060 #[test]
1061 fn daycount_act_act_isda_cross_year() {
1062 let d1 = Date::from_ymd(2003, 11, 1).unwrap();
1066 let d2 = Date::from_ymd(2004, 5, 1).unwrap();
1067 let tau = Daycount::ActActIsda.year_fraction(d1, d2).unwrap();
1068 let expected = 61.0_f64 / 365.0 + 121.0_f64 / 366.0;
1069 assert!((tau - expected).abs() < 1e-15);
1070 }
1071
1072 #[test]
1073 fn daycount_act_act_isda_multi_year() {
1074 let d1 = Date::from_ymd(2003, 6, 15).unwrap();
1078 let d2 = Date::from_ymd(2007, 6, 15).unwrap();
1079 let tau = Daycount::ActActIsda.year_fraction(d1, d2).unwrap();
1080 let first = f64::from(d1.days_between(Date::from_ymd(2004, 1, 1).unwrap())) / 365.0;
1083 let last = f64::from(Date::from_ymd(2007, 1, 1).unwrap().days_between(d2)) / 365.0;
1084 let expected = first + 3.0 + last;
1085 assert!((tau - expected).abs() < 1e-14);
1086 }
1087
1088 #[test]
1089 fn daycount_thirty_360_e() {
1090 let d1 = Date::from_ymd(2003, 11, 1).unwrap();
1094 let d2 = Date::from_ymd(2004, 5, 1).unwrap();
1095 let tau = Daycount::Thirty360E.year_fraction(d1, d2).unwrap();
1096 assert!((tau - 180.0_f64 / 360.0).abs() < 1e-15);
1097 }
1098
1099 #[test]
1100 fn daycount_thirty_360_e_clip() {
1101 let d1 = Date::from_ymd(2024, 1, 31).unwrap();
1103 let d2 = Date::from_ymd(2024, 5, 31).unwrap();
1104 let tau = Daycount::Thirty360E.year_fraction(d1, d2).unwrap();
1105 assert!((tau - 120.0_f64 / 360.0).abs() < 1e-15);
1106 }
1107
1108 #[test]
1109 fn daycount_thirty_360_bb_isda_example() {
1110 let d1 = Date::from_ymd(2007, 2, 28).unwrap();
1116 let d2 = Date::from_ymd(2007, 8, 31).unwrap();
1117 let tau = Daycount::Thirty360BondBasis.year_fraction(d1, d2).unwrap();
1118 assert!((tau - 183.0_f64 / 360.0).abs() < 1e-15);
1119 }
1120
1121 #[test]
1122 fn daycount_thirty_360_bb_d1_is_31() {
1123 let d1 = Date::from_ymd(2024, 1, 31).unwrap();
1127 let d2 = Date::from_ymd(2024, 7, 31).unwrap();
1128 let tau = Daycount::Thirty360BondBasis.year_fraction(d1, d2).unwrap();
1129 assert!((tau - 0.5).abs() < 1e-15);
1130 }
1131
1132 #[test]
1133 fn daycount_thirty_360_bb_d2_is_31_d1_not_30() {
1134 let d1 = Date::from_ymd(2024, 1, 15).unwrap();
1136 let d2 = Date::from_ymd(2024, 7, 31).unwrap();
1137 let tau = Daycount::Thirty360BondBasis.year_fraction(d1, d2).unwrap();
1138 assert!((tau - 196.0_f64 / 360.0).abs() < 1e-15);
1139 }
1140
1141 #[test]
1142 fn daycount_act_act_icma_quarterly() {
1143 let d1 = Date::from_ymd(2024, 1, 1).unwrap();
1144 let d2 = Date::from_ymd(2024, 4, 1).unwrap();
1145 let tau = Daycount::ActActIcma {
1146 coupons_per_year: 4,
1147 }
1148 .year_fraction(d1, d2)
1149 .unwrap();
1150 assert!((tau - 0.25).abs() < 1e-15);
1151 }
1152
1153 #[test]
1154 fn daycount_act_act_icma_zero_freq_rejected() {
1155 let d1 = Date::from_ymd(2024, 1, 1).unwrap();
1156 let d2 = Date::from_ymd(2024, 4, 1).unwrap();
1157 let err = Daycount::ActActIcma {
1158 coupons_per_year: 0,
1159 }
1160 .year_fraction(d1, d2)
1161 .unwrap_err();
1162 assert!(matches!(err, TypeError::InvalidTenor { .. }));
1163 }
1164
1165 #[test]
1166 fn daycount_business252_rejected() {
1167 let d1 = Date::from_ymd(2024, 1, 1).unwrap();
1168 let d2 = Date::from_ymd(2024, 4, 1).unwrap();
1169 let err = Daycount::Business252.year_fraction(d1, d2).unwrap_err();
1170 match err {
1171 TypeError::InvalidTenor { reason } => {
1172 assert!(reason.contains("Business252"));
1173 }
1174 other => panic!("unexpected variant {other:?}"),
1175 }
1176 }
1177
1178 #[test]
1179 fn daycount_negative_range_rejected() {
1180 let d1 = Date::from_ymd(2024, 6, 1).unwrap();
1181 let d2 = Date::from_ymd(2024, 1, 1).unwrap();
1182 let err = Daycount::Act360.year_fraction(d1, d2).unwrap_err();
1183 assert!(matches!(err, TypeError::NonPositiveRange));
1184 }
1185
1186 #[test]
1187 fn daycount_zero_range_ok() {
1188 let d = Date::from_ymd(2024, 1, 1).unwrap();
1189 let tau = Daycount::Act360.year_fraction(d, d).unwrap();
1190 assert!((tau - 0.0).abs() < 1e-15);
1191 }
1192
1193 #[test]
1194 fn daycount_copy_eq() {
1195 let dc = Daycount::Act360;
1196 let copy = dc;
1197 assert_eq!(dc, copy);
1198 }
1199
1200 #[test]
1203 fn compounding_continuous_roundtrip() {
1204 let r = 0.05;
1205 let t = 2.0;
1206 let d = Compounding::Continuous.discount_from_rate(r, t).unwrap();
1207 assert!((d - (-r * t).exp()).abs() < 1e-15);
1208 let r_back = Compounding::Continuous.rate_from_discount(d, t).unwrap();
1209 assert!((r - r_back).abs() < 1e-12);
1210 }
1211
1212 #[test]
1213 fn compounding_simple_roundtrip() {
1214 let r = 0.03;
1215 let t = 0.5;
1216 let d = Compounding::Simple.discount_from_rate(r, t).unwrap();
1217 assert!((d - 1.0 / (1.0 + r * t)).abs() < 1e-15);
1218 let r_back = Compounding::Simple.rate_from_discount(d, t).unwrap();
1219 assert!((r - r_back).abs() < 1e-12);
1220 }
1221
1222 #[test]
1223 fn compounding_periodic_roundtrip() {
1224 let r = 0.06;
1225 let t = 3.0;
1226 let comp = Compounding::Periodic {
1227 periods_per_year: 2,
1228 };
1229 let d = comp.discount_from_rate(r, t).unwrap();
1230 assert!((d - (1.0_f64 + 0.03).powi(-6)).abs() < 1e-12);
1231 let r_back = comp.rate_from_discount(d, t).unwrap();
1232 assert!((r - r_back).abs() < 1e-12);
1233 }
1234
1235 #[test]
1236 fn compounding_zero_time_discount_is_one() {
1237 let d = Compounding::Continuous
1238 .discount_from_rate(0.05, 0.0)
1239 .unwrap();
1240 assert!((d - 1.0).abs() < 1e-15);
1241 }
1242
1243 #[test]
1244 fn compounding_zero_time_unit_discount_gives_zero_rate() {
1245 let r = Compounding::Continuous
1246 .rate_from_discount(1.0, 0.0)
1247 .unwrap();
1248 assert!((r - 0.0).abs() < 1e-15);
1249 }
1250
1251 #[test]
1252 fn compounding_rejects_non_finite_rate() {
1253 let err = Compounding::Continuous
1254 .discount_from_rate(f64::NAN, 1.0)
1255 .unwrap_err();
1256 assert!(matches!(err, TypeError::NonFinite { name: "rate" }));
1257 }
1258
1259 #[test]
1260 fn compounding_rejects_non_finite_t() {
1261 let err = Compounding::Continuous
1262 .discount_from_rate(0.05, f64::INFINITY)
1263 .unwrap_err();
1264 assert!(matches!(err, TypeError::NonFinite { name: "t" }));
1265 }
1266
1267 #[test]
1268 fn compounding_rejects_negative_t() {
1269 let err = Compounding::Continuous
1270 .discount_from_rate(0.05, -1.0)
1271 .unwrap_err();
1272 assert!(matches!(err, TypeError::NonPositiveRange));
1273 }
1274
1275 #[test]
1276 fn compounding_rejects_zero_periods() {
1277 let err = Compounding::Periodic {
1278 periods_per_year: 0,
1279 }
1280 .discount_from_rate(0.05, 1.0)
1281 .unwrap_err();
1282 assert!(matches!(err, TypeError::InvalidTenor { .. }));
1283 let err = Compounding::Periodic {
1284 periods_per_year: 0,
1285 }
1286 .rate_from_discount(0.95, 1.0)
1287 .unwrap_err();
1288 assert!(matches!(err, TypeError::InvalidTenor { .. }));
1289 }
1290
1291 #[test]
1292 fn compounding_rate_from_discount_rejects_non_positive_discount() {
1293 let err = Compounding::Continuous
1294 .rate_from_discount(0.0, 1.0)
1295 .unwrap_err();
1296 assert!(matches!(err, TypeError::InvalidTenor { .. }));
1297 let err = Compounding::Continuous
1298 .rate_from_discount(-0.5, 1.0)
1299 .unwrap_err();
1300 assert!(matches!(err, TypeError::InvalidTenor { .. }));
1301 }
1302
1303 #[test]
1304 fn compounding_rate_from_discount_rejects_non_finite() {
1305 let err = Compounding::Continuous
1306 .rate_from_discount(f64::NAN, 1.0)
1307 .unwrap_err();
1308 assert!(matches!(err, TypeError::NonFinite { name: "discount" }));
1309 let err = Compounding::Continuous
1310 .rate_from_discount(0.95, f64::NAN)
1311 .unwrap_err();
1312 assert!(matches!(err, TypeError::NonFinite { name: "t" }));
1313 }
1314
1315 #[test]
1316 fn compounding_rate_from_discount_rejects_negative_t() {
1317 let err = Compounding::Continuous
1318 .rate_from_discount(0.95, -1.0)
1319 .unwrap_err();
1320 assert!(matches!(err, TypeError::NonPositiveRange));
1321 }
1322
1323 #[test]
1324 fn compounding_rate_from_discount_rejects_zero_t_non_unit() {
1325 let err = Compounding::Continuous
1326 .rate_from_discount(0.95, 0.0)
1327 .unwrap_err();
1328 assert!(matches!(err, TypeError::NonPositiveRange));
1329 }
1330
1331 #[test]
1334 fn frequency_periods_per_year() {
1335 assert_eq!(Frequency::Annual.periods_per_year(), 1);
1336 assert_eq!(Frequency::SemiAnnual.periods_per_year(), 2);
1337 assert_eq!(Frequency::Quarterly.periods_per_year(), 4);
1338 assert_eq!(Frequency::Monthly.periods_per_year(), 12);
1339 assert_eq!(Frequency::OnceAtMaturity.periods_per_year(), 0);
1340 }
1341
1342 #[test]
1343 fn frequency_copy_eq() {
1344 let f = Frequency::Quarterly;
1345 let copy = f;
1346 assert_eq!(f, copy);
1347 }
1348
1349 #[test]
1352 fn business_day_convention_copy_eq() {
1353 let c = BusinessDayConvention::ModifiedFollowing;
1354 let copy = c;
1355 assert_eq!(c, copy);
1356 }
1357
1358 #[test]
1359 fn business_day_convention_debug_includes_variant() {
1360 let s = format!("{:?}", BusinessDayConvention::Following);
1361 assert!(s.contains("Following"));
1362 }
1363}