use core::{
fmt,
ops::{Mul, Neg},
};
use crate::TimeError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[repr(u16)]
pub enum Frequency {
Annual = 1,
Semiannual = 2,
EveryFourthMonth = 3,
Quarterly = 4,
Bimonthly = 6,
Monthly = 12,
EveryFourthWeek = 13,
Biweekly = 26,
Weekly = 52,
Daily = 365,
}
impl Frequency {
#[must_use]
pub const fn per_year(self) -> u16 {
self as u16
}
}
impl fmt::Display for Frequency {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let name = match self {
Self::Annual => "Annual",
Self::Semiannual => "Semiannual",
Self::EveryFourthMonth => "EveryFourthMonth",
Self::Quarterly => "Quarterly",
Self::Bimonthly => "Bimonthly",
Self::Monthly => "Monthly",
Self::EveryFourthWeek => "EveryFourthWeek",
Self::Biweekly => "Biweekly",
Self::Weekly => "Weekly",
Self::Daily => "Daily",
};
f.write_str(name)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Period {
Days(i32),
Weeks(i32),
Months(i32),
Years(i32),
}
impl Period {
pub const ZERO: Self = Self::Days(0);
#[must_use]
pub const fn length(self) -> i32 {
match self {
Self::Days(n) | Self::Weeks(n) | Self::Months(n) | Self::Years(n) => n,
}
}
#[must_use]
pub const fn is_zero(self) -> bool {
self.length() == 0
}
const fn with_length(self, length: i32) -> Self {
match self {
Self::Days(_) => Self::Days(length),
Self::Weeks(_) => Self::Weeks(length),
Self::Months(_) => Self::Months(length),
Self::Years(_) => Self::Years(length),
}
}
#[must_use]
pub const fn normalized(self) -> Self {
match self {
Self::Days(0) | Self::Weeks(0) | Self::Months(0) | Self::Years(0) => Self::ZERO,
Self::Months(n) if n % 12 == 0 => Self::Years(n / 12),
Self::Days(n) if n % 7 == 0 => Self::Weeks(n / 7),
other => other,
}
}
#[must_use]
pub const fn checked_neg(self) -> Option<Self> {
match self.length().checked_neg() {
Some(n) => Some(self.with_length(n)),
None => None,
}
}
#[must_use]
pub const fn checked_mul(self, n: i32) -> Option<Self> {
match self.length().checked_mul(n) {
Some(length) => Some(self.with_length(length)),
None => None,
}
}
}
impl From<Frequency> for Period {
fn from(frequency: Frequency) -> Self {
match frequency {
Frequency::Annual => Self::Months(12),
Frequency::Semiannual => Self::Months(6),
Frequency::EveryFourthMonth => Self::Months(4),
Frequency::Quarterly => Self::Months(3),
Frequency::Bimonthly => Self::Months(2),
Frequency::Monthly => Self::Months(1),
Frequency::EveryFourthWeek => Self::Weeks(4),
Frequency::Biweekly => Self::Weeks(2),
Frequency::Weekly => Self::Weeks(1),
Frequency::Daily => Self::Days(1),
}
}
}
impl TryFrom<Period> for Frequency {
type Error = TimeError;
fn try_from(period: Period) -> Result<Self, Self::Error> {
match period.normalized() {
Period::Years(1) => Ok(Self::Annual),
Period::Months(1) => Ok(Self::Monthly),
Period::Months(2) => Ok(Self::Bimonthly),
Period::Months(3) => Ok(Self::Quarterly),
Period::Months(4) => Ok(Self::EveryFourthMonth),
Period::Months(6) => Ok(Self::Semiannual),
Period::Weeks(1) => Ok(Self::Weekly),
Period::Weeks(2) => Ok(Self::Biweekly),
Period::Weeks(4) => Ok(Self::EveryFourthWeek),
Period::Days(1) => Ok(Self::Daily),
_ => Err(TimeError::NonCanonicalPeriod),
}
}
}
impl Neg for Period {
type Output = Self;
fn neg(self) -> Self::Output {
self.with_length(self.length().wrapping_neg())
}
}
impl Mul<i32> for Period {
type Output = Self;
fn mul(self, n: i32) -> Self::Output {
self.with_length(self.length().wrapping_mul(n))
}
}
impl Mul<Period> for i32 {
type Output = Period;
fn mul(self, period: Period) -> Self::Output {
period * self
}
}
impl fmt::Display for Period {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let (n, suffix) = match self {
Self::Days(n) => (n, 'D'),
Self::Weeks(n) => (n, 'W'),
Self::Months(n) => (n, 'M'),
Self::Years(n) => (n, 'Y'),
};
write!(f, "{n}{suffix}")
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
extern crate alloc;
use super::*;
use proptest::prelude::*;
#[test]
fn frequency_per_year_matches_quantlib_values() {
assert_eq!(Frequency::Annual.per_year(), 1);
assert_eq!(Frequency::Semiannual.per_year(), 2);
assert_eq!(Frequency::EveryFourthMonth.per_year(), 3);
assert_eq!(Frequency::Quarterly.per_year(), 4);
assert_eq!(Frequency::Bimonthly.per_year(), 6);
assert_eq!(Frequency::Monthly.per_year(), 12);
assert_eq!(Frequency::EveryFourthWeek.per_year(), 13);
assert_eq!(Frequency::Biweekly.per_year(), 26);
assert_eq!(Frequency::Weekly.per_year(), 52);
assert_eq!(Frequency::Daily.per_year(), 365);
}
#[test]
fn constructors_are_const() {
const ONE_YEAR: Period = Period::Years(1);
const THREE_MONTHS: Period = Period::Months(3);
const ZERO: Period = Period::ZERO;
assert_eq!(ONE_YEAR.length(), 1);
assert!(matches!(THREE_MONTHS, Period::Months(3)));
assert_eq!(ZERO, Period::Days(0));
}
#[test]
fn length_extracts_the_signed_count() {
assert_eq!(Period::Days(0).length(), 0);
assert_eq!(Period::Months(3).length(), 3);
assert_eq!(Period::Years(-1).length(), -1);
assert_eq!(Period::Weeks(i32::MAX).length(), i32::MAX);
}
#[test]
fn negation_flips_length() {
assert_eq!(-Period::Months(3), Period::Months(-3));
assert_eq!(-Period::Years(-1), Period::Years(1));
}
#[test]
fn neg_wraps_at_i32_min_documented_behavior() {
let edge = Period::Days(i32::MIN);
assert_eq!((-edge).length(), i32::MIN);
assert_eq!(edge.checked_neg(), None);
let safe = Period::Days(i32::MIN + 1);
assert_eq!(safe.checked_neg(), Some(Period::Days(i32::MAX)));
assert_eq!(-safe, Period::Days(i32::MAX));
}
#[test]
fn checked_mul_detects_overflow() {
assert_eq!(Period::Months(3).checked_mul(4), Some(Period::Months(12)));
assert_eq!(Period::Days(i32::MAX).checked_mul(2), None);
assert_eq!(Period::Days(i32::MIN).checked_mul(-1), None);
assert_eq!(
Period::Weeks(i32::MAX).checked_mul(0),
Some(Period::Weeks(0)),
);
}
#[test]
fn scalar_multiplication_scales_length() {
assert_eq!(Period::Months(3) * 4, Period::Months(12));
assert_eq!(4 * Period::Months(3), Period::Months(12));
#[allow(clippy::erasing_op)]
let zeroed = Period::Days(7) * 0;
assert_eq!(zeroed, Period::Days(0));
assert_eq!(Period::Weeks(2) * -1, Period::Weeks(-2));
}
#[test]
fn normalize_canonicalizes_months_and_days() {
assert_eq!(Period::Months(12).normalized(), Period::Years(1));
assert_eq!(Period::Months(24).normalized(), Period::Years(2));
assert_eq!(Period::Days(7).normalized(), Period::Weeks(1));
assert_eq!(Period::Days(14).normalized(), Period::Weeks(2));
}
#[test]
fn normalize_zero_collapses_to_days() {
assert_eq!(Period::Years(0).normalized(), Period::Days(0));
assert_eq!(Period::Months(0).normalized(), Period::Days(0));
assert_eq!(Period::Weeks(0).normalized(), Period::Days(0));
assert_eq!(Period::Days(0).normalized(), Period::Days(0));
}
#[test]
fn normalize_leaves_non_multiples_alone() {
assert_eq!(Period::Months(5).normalized(), Period::Months(5));
assert_eq!(Period::Months(13).normalized(), Period::Months(13));
assert_eq!(Period::Days(10).normalized(), Period::Days(10));
}
#[test]
fn period_display_matches_quantlib_shorthand() {
assert_eq!(alloc::format!("{}", Period::Days(14)), "14D");
assert_eq!(alloc::format!("{}", Period::Weeks(2)), "2W");
assert_eq!(alloc::format!("{}", Period::Months(3)), "3M");
assert_eq!(alloc::format!("{}", Period::Years(5)), "5Y");
assert_eq!(alloc::format!("{}", Period::Months(-1)), "-1M");
}
#[test]
fn period_to_frequency_canonical_values() {
assert_eq!(
Frequency::try_from(Period::Years(1)).unwrap(),
Frequency::Annual,
);
assert_eq!(
Frequency::try_from(Period::Months(12)).unwrap(),
Frequency::Annual,
);
assert_eq!(
Frequency::try_from(Period::Months(6)).unwrap(),
Frequency::Semiannual,
);
assert_eq!(
Frequency::try_from(Period::Months(4)).unwrap(),
Frequency::EveryFourthMonth,
);
assert_eq!(
Frequency::try_from(Period::Months(3)).unwrap(),
Frequency::Quarterly,
);
assert_eq!(
Frequency::try_from(Period::Months(2)).unwrap(),
Frequency::Bimonthly,
);
assert_eq!(
Frequency::try_from(Period::Months(1)).unwrap(),
Frequency::Monthly,
);
assert_eq!(
Frequency::try_from(Period::Weeks(4)).unwrap(),
Frequency::EveryFourthWeek,
);
assert_eq!(
Frequency::try_from(Period::Weeks(2)).unwrap(),
Frequency::Biweekly,
);
assert_eq!(
Frequency::try_from(Period::Weeks(1)).unwrap(),
Frequency::Weekly,
);
assert_eq!(
Frequency::try_from(Period::Days(1)).unwrap(),
Frequency::Daily,
);
}
#[test]
fn period_to_frequency_non_canonical_errors() {
for non_canonical in [
Period::Months(5),
Period::Days(3),
Period::Weeks(3),
Period::Years(2),
Period::Days(0),
Period::Months(0),
Period::Months(-3),
Period::Weeks(-1),
Period::Years(-1),
] {
assert_eq!(
Frequency::try_from(non_canonical),
Err(TimeError::NonCanonicalPeriod),
"expected error for {non_canonical:?}",
);
}
}
#[test]
fn frequency_to_period_is_total_and_round_trips() {
for f in [
Frequency::Annual,
Frequency::Semiannual,
Frequency::EveryFourthMonth,
Frequency::Quarterly,
Frequency::Bimonthly,
Frequency::Monthly,
Frequency::EveryFourthWeek,
Frequency::Biweekly,
Frequency::Weekly,
Frequency::Daily,
] {
let p = Period::from(f);
assert_eq!(
Frequency::try_from(p).unwrap(),
f,
"round-trip failed for {f:?} via {p:?}",
);
}
}
fn any_period(length_range: core::ops::RangeInclusive<i32>) -> impl Strategy<Value = Period> {
(length_range, 0u8..=3).prop_map(|(length, kind)| match kind {
0 => Period::Days(length),
1 => Period::Weeks(length),
2 => Period::Months(length),
_ => Period::Years(length),
})
}
proptest! {
#[test]
fn normalize_is_idempotent(p in any_period(-1000..=1000)) {
let once = p.normalized();
let twice = once.normalized();
prop_assert_eq!(once, twice);
}
#[test]
fn scalar_mul_commutes(length in -100i32..=100, n in -10i32..=10) {
let p = Period::Months(length);
prop_assert_eq!(p * n, n * p);
}
#[test]
fn double_negate_is_identity(length in -1000i32..=1000) {
let p = Period::Weeks(length);
prop_assert_eq!(-(-p), p);
}
#[test]
fn with_length_round_trips(p in any_period(i32::MIN..=i32::MAX)) {
prop_assert_eq!(p.with_length(p.length()), p);
}
}
}