use crate::constants::*;
use chrono::{Duration, Local, NaiveTime, Utc};
use expiration_date::error::ExpirationDateError;
use positive::{Positive, PositiveError};
use rust_decimal_macros::dec;
use serde::{Deserialize, Serialize};
use std::fmt;
#[cfg(test)]
use positive::pos_or_panic;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, PartialOrd)]
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
pub enum TimeFrame {
Microsecond,
Millisecond,
Second,
Minute,
Hour,
Day,
Week,
Month,
Quarter,
Year,
Custom(Positive),
}
impl TimeFrame {
#[must_use]
pub fn periods_per_year(&self) -> Positive {
match self {
TimeFrame::Microsecond => pos_lit(dec!(5896800000000.0000)),
TimeFrame::Millisecond => pos_lit(dec!(5896800000.000)),
TimeFrame::Second => pos_lit(dec!(5896800.000)),
TimeFrame::Minute => pos_lit(dec!(98280.00)),
TimeFrame::Hour => pos_lit(dec!(1638.00)),
TimeFrame::Day => *TRADING_DAYS, TimeFrame::Week => *WEEKS_PER_YEAR, TimeFrame::Month => *MONTHS_PER_YEAR, TimeFrame::Quarter => QUARTERS_PER_YEAR, TimeFrame::Year => Positive::ONE, TimeFrame::Custom(periods) => *periods, }
}
}
impl fmt::Display for TimeFrame {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TimeFrame::Microsecond => write!(f, "microsecond"),
TimeFrame::Millisecond => write!(f, "millisecond"),
TimeFrame::Second => write!(f, "second"),
TimeFrame::Minute => write!(f, "minute"),
TimeFrame::Hour => write!(f, "hour"),
TimeFrame::Day => write!(f, "day"),
TimeFrame::Week => write!(f, "week"),
TimeFrame::Month => write!(f, "month"),
TimeFrame::Quarter => write!(f, "quarter"),
TimeFrame::Year => write!(f, "year"),
TimeFrame::Custom(periods) => write!(f, "custom ({periods})"),
}
}
}
fn pos_lit(d: rust_decimal::Decimal) -> Positive {
Positive::new_decimal(d).unwrap_or(Positive::ZERO)
}
#[must_use]
pub fn units_per_year(time_frame: &TimeFrame) -> Positive {
match time_frame {
TimeFrame::Microsecond => pos_lit(dec!(31536000000000.0)), TimeFrame::Millisecond => pos_lit(dec!(31536000000.0)), TimeFrame::Second => pos_lit(dec!(31536000.0)), TimeFrame::Minute => pos_lit(dec!(525600.0)), TimeFrame::Hour => pos_lit(dec!(8760.0)), TimeFrame::Day => pos_lit(dec!(365.0)), TimeFrame::Week => pos_lit(dec!(52.142857142857142857142857143)),
TimeFrame::Month => pos_lit(dec!(12.0)), TimeFrame::Quarter => pos_lit(dec!(4.0)), TimeFrame::Year => Positive::ONE, TimeFrame::Custom(periods) => *periods, }
}
pub fn convert_time_frame(
value: Positive,
from_time_frame: &TimeFrame,
to_time_frame: &TimeFrame,
) -> Result<Positive, PositiveError> {
if from_time_frame == to_time_frame {
return Ok(value);
}
if value.is_zero() {
return Ok(Positive::ZERO);
}
let from_units_per_year = units_per_year(from_time_frame);
let to_units_per_year = units_per_year(to_time_frame);
let conversion_factor = to_units_per_year.checked_div(&from_units_per_year)?;
value.checked_mul(&conversion_factor)
}
pub fn get_tomorrow_formatted() -> Result<String, ExpirationDateError> {
get_x_days_formatted(1)
}
pub fn get_x_days_formatted(days: i64) -> Result<String, ExpirationDateError> {
let target = Duration::try_days(days)
.and_then(|delta| Local::now().date_naive().checked_add_signed(delta))
.ok_or_else(|| {
ExpirationDateError::ArithmeticOverflow(format!(
"a date {days} days from today is outside the representable calendar range"
))
})?;
Ok(target.format("%d-%b-%Y").to_string().to_lowercase())
}
pub fn get_x_days_formatted_pos(days: Positive) -> Result<String, ExpirationDateError> {
let ceiling = days
.checked_ceiling()
.ok()
.and_then(|whole| whole.to_i64_checked())
.ok_or_else(|| unrepresentable_day_offset(days))?;
let today = Local::now().date_naive();
let target = Duration::try_days(ceiling)
.and_then(|delta| today.checked_add_signed(delta))
.ok_or_else(|| unrepresentable_day_offset(days))?;
Ok(target.format("%d-%b-%Y").to_string().to_lowercase())
}
#[cold]
#[inline(never)]
fn unrepresentable_day_offset(days: Positive) -> ExpirationDateError {
ExpirationDateError::ArithmeticOverflow(format!(
"a date {days} days from today is outside the representable calendar range"
))
}
#[must_use]
pub fn get_today_formatted() -> String {
let today = Local::now().date_naive();
today.format("%d-%b-%Y").to_string().to_lowercase()
}
const TODAY_CUTOFF: NaiveTime = match NaiveTime::from_hms_opt(18, 30, 0) {
Some(t) => t,
None => unreachable!(), };
#[must_use]
pub fn get_today_or_tomorrow_formatted() -> String {
let cutoff_time = TODAY_CUTOFF;
let now = Utc::now();
let target_date = if now.time() > cutoff_time {
now.date_naive()
.succ_opt()
.unwrap_or_else(|| now.date_naive()) } else {
now.date_naive()
};
target_date.format("%d-%b-%Y").to_string().to_lowercase()
}
#[cfg(test)]
mod tests_timeframe {
use super::*;
use positive::assert_pos_relative_eq;
#[test]
fn test_microsecond_periods() {
let expected =
*TRADING_DAYS * *TRADING_HOURS * *SECONDS_PER_HOUR * *MICROSECONDS_PER_SECOND;
assert_eq!(TimeFrame::Microsecond.periods_per_year(), expected);
}
#[test]
fn test_millisecond_periods() {
let expected = *TRADING_DAYS * *TRADING_HOURS * *SECONDS_PER_HOUR * MILLISECONDS_PER_SECOND;
assert_eq!(TimeFrame::Millisecond.periods_per_year(), expected);
}
#[test]
fn test_second_periods() {
let expected = *TRADING_DAYS * *TRADING_HOURS * *SECONDS_PER_HOUR;
assert_eq!(TimeFrame::Second.periods_per_year(), expected);
}
#[test]
fn test_minute_periods() {
let expected = *TRADING_DAYS * *TRADING_HOURS * MINUTES_PER_HOUR;
assert_eq!(TimeFrame::Minute.periods_per_year(), expected);
}
#[test]
fn test_hour_periods() {
let expected = *TRADING_DAYS * *TRADING_HOURS;
assert_eq!(TimeFrame::Hour.periods_per_year(), expected);
}
#[test]
fn test_day_periods() {
assert_eq!(TimeFrame::Day.periods_per_year(), *TRADING_DAYS);
}
#[test]
fn test_periods_per_year_matches_products() {
let hours = *TRADING_DAYS * *TRADING_HOURS;
let seconds = hours * *SECONDS_PER_HOUR;
let cases = [
(TimeFrame::Microsecond, seconds * *MICROSECONDS_PER_SECOND),
(TimeFrame::Millisecond, seconds * MILLISECONDS_PER_SECOND),
(TimeFrame::Second, seconds),
(TimeFrame::Minute, hours * MINUTES_PER_HOUR),
(TimeFrame::Hour, hours),
];
for (frame, product) in cases {
assert_eq!(
frame.periods_per_year().to_dec().to_string(),
product.to_dec().to_string(),
"{frame}"
);
}
}
#[test]
fn test_units_per_year_week_is_365_over_7() {
assert_eq!(
units_per_year(&TimeFrame::Week).to_dec().to_string(),
(dec!(365.0) / dec!(7.0)).to_string()
);
}
#[test]
fn test_week_periods() {
assert_eq!(TimeFrame::Week.periods_per_year(), 52.0);
}
#[test]
fn test_month_periods() {
assert_eq!(TimeFrame::Month.periods_per_year(), 12.0);
}
#[test]
fn test_quarter_periods() {
assert_eq!(TimeFrame::Quarter.periods_per_year(), 4.0);
}
#[test]
fn test_year_periods() {
assert_eq!(TimeFrame::Year.periods_per_year(), 1.0);
}
#[test]
fn test_custom_periods() {
let custom_periods = pos_or_panic!(123.45);
assert_eq!(
TimeFrame::Custom(custom_periods).periods_per_year(),
custom_periods
);
}
#[test]
fn test_relative_period_relationships() {
assert!(
TimeFrame::Microsecond.periods_per_year() > TimeFrame::Millisecond.periods_per_year()
);
assert!(TimeFrame::Millisecond.periods_per_year() > TimeFrame::Second.periods_per_year());
assert!(TimeFrame::Second.periods_per_year() > TimeFrame::Minute.periods_per_year());
assert!(TimeFrame::Minute.periods_per_year() > TimeFrame::Hour.periods_per_year());
assert!(TimeFrame::Hour.periods_per_year() > TimeFrame::Day.periods_per_year());
assert!(TimeFrame::Day.periods_per_year() > TimeFrame::Week.periods_per_year());
assert!(TimeFrame::Week.periods_per_year() > TimeFrame::Month.periods_per_year());
assert!(TimeFrame::Month.periods_per_year() > TimeFrame::Quarter.periods_per_year());
assert!(TimeFrame::Quarter.periods_per_year() > TimeFrame::Year.periods_per_year());
}
#[test]
fn test_specific_conversion_ratios() {
assert_pos_relative_eq!(
TimeFrame::Hour.periods_per_year() / TimeFrame::Day.periods_per_year(),
*TRADING_HOURS,
pos_or_panic!(1e-10)
);
assert_pos_relative_eq!(
TimeFrame::Minute.periods_per_year() / TimeFrame::Hour.periods_per_year(),
MINUTES_PER_HOUR,
pos_or_panic!(1e-10)
);
assert_pos_relative_eq!(
TimeFrame::Second.periods_per_year() / TimeFrame::Minute.periods_per_year(),
MINUTES_PER_HOUR,
pos_or_panic!(1e-10)
);
}
#[test]
fn test_trading_days_relationship() {
assert_pos_relative_eq!(
TimeFrame::Day.periods_per_year(),
*TRADING_DAYS,
pos_or_panic!(1e-10)
);
assert_pos_relative_eq!(
TimeFrame::Hour.periods_per_year() / *TRADING_HOURS,
*TRADING_DAYS,
pos_or_panic!(1e-10)
);
}
#[test]
fn test_custom_edge_cases() {
assert_eq!(TimeFrame::Custom(Positive::ZERO).periods_per_year(), 0.0);
assert_eq!(
TimeFrame::Custom(Positive::MAX).periods_per_year(),
Positive::MAX
);
}
#[test]
fn test_timeframe_debug() {
assert_eq!(format!("{:?}", TimeFrame::Day), "Day");
assert_eq!(
format!("{:?}", TimeFrame::Custom(pos_or_panic!(1.5))),
"Custom(1.5)"
);
}
#[test]
fn test_timeframe_clone() {
let tf = TimeFrame::Day;
let cloned = tf;
assert_eq!(tf.periods_per_year(), cloned.periods_per_year());
}
#[test]
fn test_timeframe_copy() {
let tf = TimeFrame::Day;
let copied = tf;
assert_eq!(tf.periods_per_year(), copied.periods_per_year());
}
}
#[cfg(test)]
mod tests_timeframe_convert {
use super::*;
use positive::assert_pos_relative_eq;
#[test]
fn test_convert_seconds_to_minutes() {
let result =
convert_time_frame(pos_or_panic!(60.0), &TimeFrame::Second, &TimeFrame::Minute)
.unwrap();
assert_pos_relative_eq!(result, Positive::ONE, pos_or_panic!(1e-10));
}
#[test]
fn test_convert_hours_to_days() {
let result =
convert_time_frame(pos_or_panic!(12.0), &TimeFrame::Hour, &TimeFrame::Day).unwrap();
assert_pos_relative_eq!(result, pos_or_panic!(0.5), pos_or_panic!(1e-10));
}
#[test]
fn test_convert_days_to_weeks() {
let result =
convert_time_frame(pos_or_panic!(7.0), &TimeFrame::Day, &TimeFrame::Week).unwrap();
assert_pos_relative_eq!(result, Positive::ONE, pos_or_panic!(1e-10));
}
#[test]
fn test_convert_weeks_to_days() {
let result = convert_time_frame(Positive::TWO, &TimeFrame::Week, &TimeFrame::Day).unwrap();
assert_pos_relative_eq!(result, pos_or_panic!(14.0), pos_or_panic!(1e-10));
}
#[test]
fn test_convert_months_to_quarters() {
let result =
convert_time_frame(pos_or_panic!(3.0), &TimeFrame::Month, &TimeFrame::Quarter).unwrap();
assert_pos_relative_eq!(result, Positive::ONE, pos_or_panic!(1e-10));
}
#[test]
fn test_convert_minutes_to_hours() {
let result =
convert_time_frame(pos_or_panic!(120.0), &TimeFrame::Minute, &TimeFrame::Hour).unwrap();
assert_pos_relative_eq!(result, Positive::TWO, pos_or_panic!(1e-10));
}
#[test]
fn test_convert_custom_to_day() {
let result = convert_time_frame(
pos_or_panic!(10.0),
&TimeFrame::Custom(pos_or_panic!(365.0)),
&TimeFrame::Day,
)
.unwrap();
assert_pos_relative_eq!(result, pos_or_panic!(10.0), pos_or_panic!(1e-10));
}
#[test]
fn test_convert_day_to_custom() {
let result = convert_time_frame(
Positive::TWO,
&TimeFrame::Day,
&TimeFrame::Custom(pos_or_panic!(365.0)),
)
.unwrap();
assert_pos_relative_eq!(result, Positive::TWO, pos_or_panic!(1e-10));
}
#[test]
fn test_convert_same_timeframe() {
let result =
convert_time_frame(pos_or_panic!(42.0), &TimeFrame::Hour, &TimeFrame::Hour).unwrap();
assert_pos_relative_eq!(result, pos_or_panic!(42.0), pos_or_panic!(1e-10));
}
#[test]
fn test_convert_weeks_to_months() {
let result =
convert_time_frame(pos_or_panic!(4.0), &TimeFrame::Week, &TimeFrame::Month).unwrap();
assert_pos_relative_eq!(
result,
pos_or_panic!(0.920_547_945_255_920_4),
pos_or_panic!(1e-10)
);
}
#[test]
fn test_convert_milliseconds_to_seconds() {
let result = convert_time_frame(
pos_or_panic!(1000.0),
&TimeFrame::Millisecond,
&TimeFrame::Second,
)
.unwrap();
assert_pos_relative_eq!(result, Positive::ONE, pos_or_panic!(1e-10));
}
#[test]
fn test_zero() {
let result =
convert_time_frame(Positive::ZERO, &TimeFrame::Millisecond, &TimeFrame::Second)
.unwrap();
assert_pos_relative_eq!(result, Positive::ZERO, pos_or_panic!(1e-10));
}
}
#[cfg(test)]
mod tests_x_days_formatted_pos {
use super::*;
#[test]
fn test_get_x_days_formatted_pos_rounds_up_and_matches_the_integer_form() {
let date = get_x_days_formatted_pos(pos_or_panic!(6.2)).expect("seven days ahead");
assert_eq!(Some(date), get_x_days_formatted(7).ok());
}
#[test]
fn test_get_x_days_formatted_pos_zero_days_is_today() {
let date = get_x_days_formatted_pos(Positive::ZERO).expect("today");
assert_eq!(date, get_today_formatted());
}
#[test]
fn test_get_x_days_formatted_pos_beyond_the_calendar_reports_overflow() {
let result = get_x_days_formatted_pos(pos_or_panic!(1_000_000_000.0));
assert!(matches!(
result,
Err(ExpirationDateError::ArithmeticOverflow(_))
));
}
#[test]
fn test_get_x_days_formatted_pos_beyond_i64_reports_overflow() {
let result = get_x_days_formatted_pos(Positive::MAX);
assert!(matches!(
result,
Err(ExpirationDateError::ArithmeticOverflow(_))
));
}
}
#[cfg(test)]
mod tests_panic_paths {
use super::*;
use chrono::{Duration, Local};
#[test]
fn test_convert_time_frame_from_custom_zero_is_error() {
let result = convert_time_frame(
Positive::ONE,
&TimeFrame::Custom(Positive::ZERO),
&TimeFrame::Day,
);
assert!(result.is_err());
}
#[test]
fn test_convert_time_frame_overflow_is_error() {
let result = convert_time_frame(Positive::MAX, &TimeFrame::Year, &TimeFrame::Day);
assert!(result.is_err());
}
#[test]
fn test_convert_time_frame_values_unchanged() {
let result = convert_time_frame(Positive::TWO, &TimeFrame::Week, &TimeFrame::Day);
assert_eq!(
result.ok(),
Some(
Positive::TWO * units_per_year(&TimeFrame::Day) / units_per_year(&TimeFrame::Week)
)
);
}
#[test]
fn test_get_x_days_formatted_i64_max_is_error() {
assert!(matches!(
get_x_days_formatted(i64::MAX),
Err(ExpirationDateError::ArithmeticOverflow(_))
));
assert!(get_x_days_formatted(i64::MIN).is_err());
}
#[test]
fn test_get_x_days_formatted_billion_days_is_error() {
assert!(matches!(
get_x_days_formatted(1_000_000_000),
Err(ExpirationDateError::ArithmeticOverflow(_))
));
}
#[test]
fn test_get_x_days_formatted_values_unchanged() {
let expected = (Local::now().date_naive() + Duration::days(2))
.format("%d-%b-%Y")
.to_string()
.to_lowercase();
assert_eq!(get_x_days_formatted(2).ok(), Some(expected));
let tomorrow = (Local::now().date_naive() + Duration::days(1))
.format("%d-%b-%Y")
.to_string()
.to_lowercase();
assert_eq!(get_tomorrow_formatted().ok(), Some(tomorrow));
}
}