Skip to main content

optionstratlib_core/utils/
time.rs

1/******************************************************************************
2   Author: Joaquín Béjar García
3   Email: jb@taunais.com
4   Date: 23/10/24
5******************************************************************************/
6
7use crate::constants::*;
8use chrono::{Duration, Local, NaiveTime, Utc};
9use expiration_date::error::ExpirationDateError;
10use positive::{Positive, PositiveError};
11use rust_decimal_macros::dec;
12use serde::{Deserialize, Serialize};
13use std::fmt;
14
15#[cfg(test)]
16use positive::pos_or_panic;
17
18/// Represents different timeframes for volatility calculations.
19///
20/// This enum provides a standardized way to represent various time periods
21/// used in financial calculations, including common periods like days, weeks,
22/// months, and years, as well as custom periods defined by the user.
23///
24/// The `TimeFrame` enum is used throughout the library to specify the timeframe
25/// for calculations like volatility, returns, and other time-dependent metrics.
26///
27/// # Examples
28///
29/// ```
30/// use optionstratlib_core::utils::time::TimeFrame;
31/// use positive::pos_or_panic;
32///
33/// // Using standard timeframes
34/// let daily = TimeFrame::Day;
35/// let weekly = TimeFrame::Week;
36///
37/// // Using custom timeframes
38/// let custom_period = TimeFrame::Custom(pos_or_panic!(360.0));
39///
40/// // Accessing the number of periods per year
41/// let periods_per_year = daily.periods_per_year(); // Returns 252.0
42/// let custom_periods = custom_period.periods_per_year(); // Returns 360.0
43/// ```
44#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, PartialOrd)]
45#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
46pub enum TimeFrame {
47    /// 1-microsecond data.
48    Microsecond,
49    /// 1-millisecond data.
50    Millisecond,
51    /// 1-second data.
52    Second,
53    /// 1-minute data.
54    Minute,
55    /// 1-hour data.
56    Hour,
57    /// Daily data.
58    Day,
59    /// Weekly data.
60    Week,
61    /// Monthly data.
62    Month,
63    /// Quarterly data.
64    Quarter,
65    /// Yearly data.
66    Year,
67    /// Custom periods per year.
68    Custom(Positive),
69}
70
71impl TimeFrame {
72    /// Returns the number of periods in a trading year for this timeframe.
73    ///
74    /// This function calculates the number of periods that occur within a trading year
75    /// based on the chosen `TimeFrame`.  A trading year is assumed to have 252 days
76    /// and 6.5 trading hours per day.
77    ///
78    /// For custom timeframes, the number of periods is directly specified by the user.
79    ///
80    /// # Examples
81    ///
82    /// ```
83    /// use optionstratlib_core::utils::time::TimeFrame;
84    /// use positive::pos_or_panic;
85    ///
86    /// let daily = TimeFrame::Day;
87    /// let periods_per_year = daily.periods_per_year(); // Returns 252
88    /// assert_eq!(periods_per_year, pos_or_panic!(252.0));
89    ///
90    /// let hourly = TimeFrame::Hour;
91    /// let periods_per_year = hourly.periods_per_year(); // Returns 1638
92    /// assert_eq!(periods_per_year, pos_or_panic!(1638.0));
93    ///
94    /// let custom = TimeFrame::Custom(pos_or_panic!(360.0));
95    /// let periods_per_year = custom.periods_per_year(); // Returns 360
96    /// assert_eq!(periods_per_year, pos_or_panic!(360.0));
97    /// ```
98    #[must_use]
99    pub fn periods_per_year(&self) -> Positive {
100        // The sub-day frames are products of `TRADING_DAYS` (252.0),
101        // `TRADING_HOURS` (6.5), `SECONDS_PER_HOUR` (3600.0),
102        // `MINUTES_PER_HOUR` (60), `MILLISECONDS_PER_SECOND` (1000) and
103        // `MICROSECONDS_PER_SECOND` (1_000_000.0). They are spelled out as
104        // literals, with the scale the `Positive` product carried, rather
105        // than multiplied here: `Positive * Positive` aborts on overflow and
106        // this method has no error channel, while the literals need no
107        // arithmetic at all (#788). `test_periods_per_year_matches_products`
108        // pins each literal to the product of the constants.
109        match self {
110            // 252.0 × 6.5 × 3600.0 × 1_000_000.0 microseconds in a trading year
111            TimeFrame::Microsecond => pos_lit(dec!(5896800000000.0000)),
112            // 252.0 × 6.5 × 3600.0 × 1000 milliseconds in a trading year
113            TimeFrame::Millisecond => pos_lit(dec!(5896800000.000)),
114            // 252.0 × 6.5 × 3600.0 seconds in a trading year
115            TimeFrame::Second => pos_lit(dec!(5896800.000)),
116            // 252.0 × 6.5 × 60 minutes in a trading year
117            TimeFrame::Minute => pos_lit(dec!(98280.00)),
118            // 252.0 × 6.5 hours in a trading year
119            TimeFrame::Hour => pos_lit(dec!(1638.00)),
120            TimeFrame::Day => *TRADING_DAYS, // Trading days in a year
121            TimeFrame::Week => *WEEKS_PER_YEAR, // Weeks in a year
122            TimeFrame::Month => *MONTHS_PER_YEAR, // Months in a year
123            TimeFrame::Quarter => QUARTERS_PER_YEAR, // Quarters in a year
124            TimeFrame::Year => Positive::ONE, // Base unit
125            TimeFrame::Custom(periods) => *periods, // Custom periods per year
126        }
127    }
128}
129
130impl fmt::Display for TimeFrame {
131    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132        match self {
133            TimeFrame::Microsecond => write!(f, "microsecond"),
134            TimeFrame::Millisecond => write!(f, "millisecond"),
135            TimeFrame::Second => write!(f, "second"),
136            TimeFrame::Minute => write!(f, "minute"),
137            TimeFrame::Hour => write!(f, "hour"),
138            TimeFrame::Day => write!(f, "day"),
139            TimeFrame::Week => write!(f, "week"),
140            TimeFrame::Month => write!(f, "month"),
141            TimeFrame::Quarter => write!(f, "quarter"),
142            TimeFrame::Year => write!(f, "year"),
143            TimeFrame::Custom(periods) => write!(f, "custom ({periods})"),
144        }
145    }
146}
147
148/// Returns the number of units per year for each TimeFrame.
149///
150/// # Arguments
151///
152/// * `time_frame` - The TimeFrame to get the units per year for
153///
154/// # Returns
155///
156/// A `Positive` representing how many of the given time frame fit in a year
157fn pos_lit(d: rust_decimal::Decimal) -> Positive {
158    Positive::new_decimal(d).unwrap_or(Positive::ZERO)
159}
160
161/// Returns how many units of the given `TimeFrame` fit into a calendar year
162/// as a `Positive`. Used by annualisation helpers (volatility, yield
163/// curves) to scale per-period values to a standardised annual basis.
164#[must_use]
165pub fn units_per_year(time_frame: &TimeFrame) -> Positive {
166    match time_frame {
167        TimeFrame::Microsecond => pos_lit(dec!(31536000000000.0)), // 365 * 24 * 60 * 60 * 1_000_000
168        TimeFrame::Millisecond => pos_lit(dec!(31536000000.0)),    // 365 * 24 * 60 * 60 * 1_000
169        TimeFrame::Second => pos_lit(dec!(31536000.0)),            // 365 * 24 * 60 * 60
170        TimeFrame::Minute => pos_lit(dec!(525600.0)),              // 365 * 24 * 60
171        TimeFrame::Hour => pos_lit(dec!(8760.0)),                  // 365 * 24
172        TimeFrame::Day => pos_lit(dec!(365.0)),                    // 365
173        // 365 / 7: the exact `Decimal` quotient `dec!(365.0) / dec!(7.0)`
174        // (28 significant digits, scale 27), spelled out so that no division
175        // runs here (#788). Exact `Decimal` keeps the round-trip identity
176        // Week→Day→Week (an f64 literal would accumulate ~1 ulp of error and
177        // break the strict assertion in tests like
178        // `test_step_next_with_weeks`). `test_units_per_year_week_is_365_over_7`
179        // pins the literal to the quotient.
180        TimeFrame::Week => pos_lit(dec!(52.142857142857142857142857143)),
181        TimeFrame::Month => pos_lit(dec!(12.0)),  // 12
182        TimeFrame::Quarter => pos_lit(dec!(4.0)), // 4
183        TimeFrame::Year => Positive::ONE,         // 1
184        TimeFrame::Custom(periods) => *periods,   // Custom periods per year
185    }
186}
187
188/// Converts a value from one TimeFrame to another.
189///
190/// # Arguments
191///
192/// * `value` - The value to convert
193/// * `from_time_frame` - The source TimeFrame
194/// * `to_time_frame` - The target TimeFrame
195///
196/// # Returns
197///
198/// A Decimal representing the converted value
199///
200/// # Errors
201///
202/// Returns [`PositiveError`] when the source frame has no units in a year
203/// (`TimeFrame::Custom(Positive::ZERO)`), so the conversion factor is
204/// undefined, and when the factor or the converted value leaves the
205/// `Positive` range. Both aborted inside the `Positive` operators before
206/// #788.
207///
208/// # Examples
209///
210/// ```
211///
212/// use optionstratlib_core::utils::time::convert_time_frame;
213/// use optionstratlib_core::utils::TimeFrame;
214/// use positive::{pos_or_panic, Positive, assert_pos_relative_eq};
215///
216/// // Convert 60 seconds to minutes
217/// let result = convert_time_frame(pos_or_panic!(60.0), &TimeFrame::Second, &TimeFrame::Minute)?;
218/// assert_pos_relative_eq!(result, Positive::ONE, pos_or_panic!(0.0000001));
219///
220/// // Convert 12 hours to days
221/// let result = convert_time_frame(pos_or_panic!(12.0), &TimeFrame::Hour, &TimeFrame::Day)?;
222/// assert_pos_relative_eq!(result, pos_or_panic!(0.5), pos_or_panic!(0.0000001));
223/// # Ok::<(), positive::PositiveError>(())
224/// ```
225pub fn convert_time_frame(
226    value: Positive,
227    from_time_frame: &TimeFrame,
228    to_time_frame: &TimeFrame,
229) -> Result<Positive, PositiveError> {
230    // If the time frames are the same, return the original value
231    if from_time_frame == to_time_frame {
232        return Ok(value);
233    }
234
235    if value.is_zero() {
236        return Ok(Positive::ZERO);
237    }
238
239    // Get the units per year for each time frame
240    let from_units_per_year = units_per_year(from_time_frame);
241    let to_units_per_year = units_per_year(to_time_frame);
242
243    // Calculate the conversion factor
244    // The conversion factor is the ratio of units per year
245    // For example, to convert from seconds to minutes:
246    // seconds per year / minutes per year = 31536000 / 525600 = 60
247    // So 60 seconds = 1 minute
248    let conversion_factor = to_units_per_year.checked_div(&from_units_per_year)?;
249    // Apply the conversion
250    value.checked_mul(&conversion_factor)
251}
252
253/// Returns tomorrow's date in "dd-mmm-yyyy" format (lowercase).
254///
255/// # Examples
256///
257/// ```
258/// use tracing::info;
259/// use optionstratlib_core::utils::time::get_tomorrow_formatted;
260/// let tomorrow = get_tomorrow_formatted()?;
261/// info!("{}", tomorrow); // Output will vary depending on the current date.
262/// # Ok::<(), optionstratlib_core::model::ExpirationDateError>(())
263/// ```
264///
265/// # Errors
266///
267/// Returns [`ExpirationDateError::ArithmeticOverflow`] when today is the last
268/// date the calendar can hold; see [`get_x_days_formatted`].
269pub fn get_tomorrow_formatted() -> Result<String, ExpirationDateError> {
270    get_x_days_formatted(1)
271}
272
273/// Formats a date a specified number of days from the current date.
274///
275/// This function calculates the date that is `days` days from the current date and
276/// formats it as a lowercase string in the format "dd-mmm-yyyy".  For example,
277/// if the current date is 2024-11-20 and `days` is 1, the returned string will be
278/// "21-nov-2024".
279///
280/// # Arguments
281///
282/// * `days`: The number of days to offset from the current date.  This can be
283///   positive or negative.
284///
285/// # Returns
286///
287/// A lowercase string representing the calculated date in "dd-mmm-yyyy" format.
288///
289/// # Errors
290///
291/// Returns [`ExpirationDateError::ArithmeticOverflow`] when `days` is beyond
292/// the range of a day count (`TimeDelta::days` aborted on it) or the date
293/// it lands on is outside the calendar (`NaiveDate + TimeDelta` aborted on
294/// it), both before #788.
295pub fn get_x_days_formatted(days: i64) -> Result<String, ExpirationDateError> {
296    let target = Duration::try_days(days)
297        .and_then(|delta| Local::now().date_naive().checked_add_signed(delta))
298        .ok_or_else(|| {
299            ExpirationDateError::ArithmeticOverflow(format!(
300                "a date {days} days from today is outside the representable calendar range"
301            ))
302        })?;
303    Ok(target.format("%d-%b-%Y").to_string().to_lowercase())
304}
305
306/// Returns a formatted date string representing the date `x` days in the future.
307///
308/// This function takes a `Positive` number of days, calculates the ceiling value
309/// as an integer, adds that many days to the current local date, and returns the
310/// resulting date formatted in the "dd-MMM-yyyy" format in lowercase.
311///
312/// # Arguments
313///
314/// * `days` - A `Positive` value representing a positive number of days.
315///
316/// # Returns
317///
318/// A `String` containing the formatted date in lowercase. The format of the date
319/// is "dd-MMM-yyyy", where:
320/// - `dd` is the day of the month, zero-padded to 2 digits.
321/// - `MMM` is the three-letter abbreviated name of the month.
322/// - `yyyy` is the 4-digit year.
323///
324/// # Errors
325///
326/// Returns [`ExpirationDateError::ArithmeticOverflow`] when the rounded-up day
327/// count does not fit an `i64`, does not fit a `chrono` day span, or moves the
328/// date outside the calendar range `chrono` can represent. The date is never
329/// replaced by today's: a caller asking for a date `days` ahead either gets
330/// that date or an error.
331///
332/// # Note
333/// - The function uses the local time zone and the `chrono` crate for date manipulation.
334/// - The `Positive` type is expected to provide a `.ceiling()` method that converts it to an integer-compatible representation.
335pub fn get_x_days_formatted_pos(days: Positive) -> Result<String, ExpirationDateError> {
336    // `checked_ceiling` rather than `ceiling`, which aborts when the result
337    // leaves the `Positive` range (#788).
338    let ceiling = days
339        .checked_ceiling()
340        .ok()
341        .and_then(|whole| whole.to_i64_checked())
342        .ok_or_else(|| unrepresentable_day_offset(days))?;
343    let today = Local::now().date_naive();
344    let target = Duration::try_days(ceiling)
345        .and_then(|delta| today.checked_add_signed(delta))
346        .ok_or_else(|| unrepresentable_day_offset(days))?;
347    Ok(target.format("%d-%b-%Y").to_string().to_lowercase())
348}
349
350/// Reports a day offset no calendar date can represent.
351#[cold]
352#[inline(never)]
353fn unrepresentable_day_offset(days: Positive) -> ExpirationDateError {
354    ExpirationDateError::ArithmeticOverflow(format!(
355        "a date {days} days from today is outside the representable calendar range"
356    ))
357}
358
359/// Returns the current date formatted as "dd-mmm-yyyy" in lowercase.
360///
361/// # Examples
362///
363/// ```
364/// use chrono::Local;
365/// use optionstratlib_core::utils::time::get_today_formatted;
366///
367/// let today_formatted = get_today_formatted();
368/// let expected_format = Local::now().date_naive().format("%d-%b-%Y").to_string().to_lowercase();
369/// assert_eq!(today_formatted, expected_format);
370/// ```
371#[must_use]
372pub fn get_today_formatted() -> String {
373    let today = Local::now().date_naive();
374    today.format("%d-%b-%Y").to_string().to_lowercase()
375}
376
377/// UTC time of day after which [`get_today_or_tomorrow_formatted`] rolls
378/// over to the next date.
379///
380/// `NaiveTime::from_hms_opt` is a `const fn`, so the `match` is evaluated by
381/// the compiler: a literal outside chrono's ranges would fail the build with
382/// `evaluation of constant value failed`, and no run time reaches the `None`
383/// arm (#788), as with `DEFAULT_BINOMIAL_STEPS`.
384const TODAY_CUTOFF: NaiveTime = match NaiveTime::from_hms_opt(18, 30, 0) {
385    Some(t) => t,
386    None => unreachable!(), // scan-banned: allow -- `const` context: an unreachable arm here fails compilation, it cannot abort at run time
387};
388
389/// Formats the current date or the next day's date based on the current UTC time.
390///
391/// The function checks the current UTC time against a cutoff time of 18:30:00.
392/// If the current time is past the cutoff, the date for the next day is returned.
393/// Otherwise, the current date is returned.  The returned date is formatted
394/// as `dd-mmm-yyyy` in lowercase. Note that getting the next day is done safely,
395/// handling potential overflow (e.g. the last day of the year).
396///
397/// Returns:
398///
399/// A lowercase String representing the formatted date.
400///
401/// # Examples
402///
403/// ```
404/// use chrono::{Utc, NaiveTime, Timelike};
405/// use tracing::info;
406/// use optionstratlib_core::utils::time::get_today_or_tomorrow_formatted;
407///
408/// info!("{}", get_today_or_tomorrow_formatted());
409/// ```
410#[must_use]
411pub fn get_today_or_tomorrow_formatted() -> String {
412    let cutoff_time = TODAY_CUTOFF;
413    let now = Utc::now();
414    // Get the date we should use based on current UTC time
415    let target_date = if now.time() > cutoff_time {
416        now.date_naive()
417            .succ_opt()
418            .unwrap_or_else(|| now.date_naive()) // Get next day safely
419    } else {
420        now.date_naive()
421    };
422    target_date.format("%d-%b-%Y").to_string().to_lowercase()
423}
424
425#[cfg(test)]
426mod tests_timeframe {
427    use super::*;
428    use positive::assert_pos_relative_eq;
429
430    #[test]
431    fn test_microsecond_periods() {
432        let expected =
433            *TRADING_DAYS * *TRADING_HOURS * *SECONDS_PER_HOUR * *MICROSECONDS_PER_SECOND;
434        assert_eq!(TimeFrame::Microsecond.periods_per_year(), expected);
435    }
436
437    #[test]
438    fn test_millisecond_periods() {
439        let expected = *TRADING_DAYS * *TRADING_HOURS * *SECONDS_PER_HOUR * MILLISECONDS_PER_SECOND;
440        assert_eq!(TimeFrame::Millisecond.periods_per_year(), expected);
441    }
442
443    #[test]
444    fn test_second_periods() {
445        let expected = *TRADING_DAYS * *TRADING_HOURS * *SECONDS_PER_HOUR;
446        assert_eq!(TimeFrame::Second.periods_per_year(), expected);
447    }
448
449    #[test]
450    fn test_minute_periods() {
451        let expected = *TRADING_DAYS * *TRADING_HOURS * MINUTES_PER_HOUR;
452        assert_eq!(TimeFrame::Minute.periods_per_year(), expected);
453    }
454
455    #[test]
456    fn test_hour_periods() {
457        let expected = *TRADING_DAYS * *TRADING_HOURS;
458        assert_eq!(TimeFrame::Hour.periods_per_year(), expected);
459    }
460
461    #[test]
462    fn test_day_periods() {
463        assert_eq!(TimeFrame::Day.periods_per_year(), *TRADING_DAYS);
464    }
465
466    // The literals replaced `Positive` products (#788); they must carry the
467    // same value and the same scale, so the comparison is on the decimal
468    // representation, not only on numeric equality.
469    #[test]
470    fn test_periods_per_year_matches_products() {
471        let hours = *TRADING_DAYS * *TRADING_HOURS;
472        let seconds = hours * *SECONDS_PER_HOUR;
473        let cases = [
474            (TimeFrame::Microsecond, seconds * *MICROSECONDS_PER_SECOND),
475            (TimeFrame::Millisecond, seconds * MILLISECONDS_PER_SECOND),
476            (TimeFrame::Second, seconds),
477            (TimeFrame::Minute, hours * MINUTES_PER_HOUR),
478            (TimeFrame::Hour, hours),
479        ];
480        for (frame, product) in cases {
481            assert_eq!(
482                frame.periods_per_year().to_dec().to_string(),
483                product.to_dec().to_string(),
484                "{frame}"
485            );
486        }
487    }
488
489    #[test]
490    fn test_units_per_year_week_is_365_over_7() {
491        assert_eq!(
492            units_per_year(&TimeFrame::Week).to_dec().to_string(),
493            (dec!(365.0) / dec!(7.0)).to_string()
494        );
495    }
496
497    #[test]
498    fn test_week_periods() {
499        assert_eq!(TimeFrame::Week.periods_per_year(), 52.0);
500    }
501
502    #[test]
503    fn test_month_periods() {
504        assert_eq!(TimeFrame::Month.periods_per_year(), 12.0);
505    }
506
507    #[test]
508    fn test_quarter_periods() {
509        assert_eq!(TimeFrame::Quarter.periods_per_year(), 4.0);
510    }
511
512    #[test]
513    fn test_year_periods() {
514        assert_eq!(TimeFrame::Year.periods_per_year(), 1.0);
515    }
516
517    #[test]
518    fn test_custom_periods() {
519        let custom_periods = pos_or_panic!(123.45);
520        assert_eq!(
521            TimeFrame::Custom(custom_periods).periods_per_year(),
522            custom_periods
523        );
524    }
525
526    #[test]
527    fn test_relative_period_relationships() {
528        // Test that higher timeframes have fewer periods
529        assert!(
530            TimeFrame::Microsecond.periods_per_year() > TimeFrame::Millisecond.periods_per_year()
531        );
532        assert!(TimeFrame::Millisecond.periods_per_year() > TimeFrame::Second.periods_per_year());
533        assert!(TimeFrame::Second.periods_per_year() > TimeFrame::Minute.periods_per_year());
534        assert!(TimeFrame::Minute.periods_per_year() > TimeFrame::Hour.periods_per_year());
535        assert!(TimeFrame::Hour.periods_per_year() > TimeFrame::Day.periods_per_year());
536        assert!(TimeFrame::Day.periods_per_year() > TimeFrame::Week.periods_per_year());
537        assert!(TimeFrame::Week.periods_per_year() > TimeFrame::Month.periods_per_year());
538        assert!(TimeFrame::Month.periods_per_year() > TimeFrame::Quarter.periods_per_year());
539        assert!(TimeFrame::Quarter.periods_per_year() > TimeFrame::Year.periods_per_year());
540    }
541
542    #[test]
543    fn test_specific_conversion_ratios() {
544        // Test specific conversion ratios between timeframes
545        assert_pos_relative_eq!(
546            TimeFrame::Hour.periods_per_year() / TimeFrame::Day.periods_per_year(),
547            *TRADING_HOURS,
548            pos_or_panic!(1e-10)
549        );
550
551        assert_pos_relative_eq!(
552            TimeFrame::Minute.periods_per_year() / TimeFrame::Hour.periods_per_year(),
553            MINUTES_PER_HOUR,
554            pos_or_panic!(1e-10)
555        );
556
557        assert_pos_relative_eq!(
558            TimeFrame::Second.periods_per_year() / TimeFrame::Minute.periods_per_year(),
559            MINUTES_PER_HOUR,
560            pos_or_panic!(1e-10)
561        );
562    }
563
564    #[test]
565    fn test_trading_days_relationship() {
566        // Verify relationships with trading days
567        assert_pos_relative_eq!(
568            TimeFrame::Day.periods_per_year(),
569            *TRADING_DAYS,
570            pos_or_panic!(1e-10)
571        );
572
573        assert_pos_relative_eq!(
574            TimeFrame::Hour.periods_per_year() / *TRADING_HOURS,
575            *TRADING_DAYS,
576            pos_or_panic!(1e-10)
577        );
578    }
579
580    #[test]
581    fn test_custom_edge_cases() {
582        // Test edge cases for custom periods
583        assert_eq!(TimeFrame::Custom(Positive::ZERO).periods_per_year(), 0.0);
584        assert_eq!(
585            TimeFrame::Custom(Positive::MAX).periods_per_year(),
586            Positive::MAX
587        );
588    }
589
590    #[test]
591    fn test_timeframe_debug() {
592        assert_eq!(format!("{:?}", TimeFrame::Day), "Day");
593        assert_eq!(
594            format!("{:?}", TimeFrame::Custom(pos_or_panic!(1.5))),
595            "Custom(1.5)"
596        );
597    }
598
599    #[test]
600    fn test_timeframe_clone() {
601        let tf = TimeFrame::Day;
602        let cloned = tf;
603        assert_eq!(tf.periods_per_year(), cloned.periods_per_year());
604    }
605
606    #[test]
607    fn test_timeframe_copy() {
608        let tf = TimeFrame::Day;
609        let copied = tf;
610        assert_eq!(tf.periods_per_year(), copied.periods_per_year());
611    }
612}
613
614#[cfg(test)]
615mod tests_timeframe_convert {
616    use super::*;
617    use positive::assert_pos_relative_eq;
618
619    #[test]
620    fn test_convert_seconds_to_minutes() {
621        let result =
622            convert_time_frame(pos_or_panic!(60.0), &TimeFrame::Second, &TimeFrame::Minute)
623                .unwrap();
624        assert_pos_relative_eq!(result, Positive::ONE, pos_or_panic!(1e-10));
625    }
626
627    #[test]
628    fn test_convert_hours_to_days() {
629        let result =
630            convert_time_frame(pos_or_panic!(12.0), &TimeFrame::Hour, &TimeFrame::Day).unwrap();
631        assert_pos_relative_eq!(result, pos_or_panic!(0.5), pos_or_panic!(1e-10));
632    }
633
634    #[test]
635    fn test_convert_days_to_weeks() {
636        let result =
637            convert_time_frame(pos_or_panic!(7.0), &TimeFrame::Day, &TimeFrame::Week).unwrap();
638        assert_pos_relative_eq!(result, Positive::ONE, pos_or_panic!(1e-10));
639    }
640
641    #[test]
642    fn test_convert_weeks_to_days() {
643        let result = convert_time_frame(Positive::TWO, &TimeFrame::Week, &TimeFrame::Day).unwrap();
644        assert_pos_relative_eq!(result, pos_or_panic!(14.0), pos_or_panic!(1e-10));
645    }
646
647    #[test]
648    fn test_convert_months_to_quarters() {
649        let result =
650            convert_time_frame(pos_or_panic!(3.0), &TimeFrame::Month, &TimeFrame::Quarter).unwrap();
651        assert_pos_relative_eq!(result, Positive::ONE, pos_or_panic!(1e-10));
652    }
653
654    #[test]
655    fn test_convert_minutes_to_hours() {
656        let result =
657            convert_time_frame(pos_or_panic!(120.0), &TimeFrame::Minute, &TimeFrame::Hour).unwrap();
658        assert_pos_relative_eq!(result, Positive::TWO, pos_or_panic!(1e-10));
659    }
660
661    #[test]
662    fn test_convert_custom_to_day() {
663        let result = convert_time_frame(
664            pos_or_panic!(10.0),
665            &TimeFrame::Custom(pos_or_panic!(365.0)),
666            &TimeFrame::Day,
667        )
668        .unwrap();
669        assert_pos_relative_eq!(result, pos_or_panic!(10.0), pos_or_panic!(1e-10));
670    }
671
672    #[test]
673    fn test_convert_day_to_custom() {
674        let result = convert_time_frame(
675            Positive::TWO,
676            &TimeFrame::Day,
677            &TimeFrame::Custom(pos_or_panic!(365.0)),
678        )
679        .unwrap();
680        assert_pos_relative_eq!(result, Positive::TWO, pos_or_panic!(1e-10));
681    }
682
683    #[test]
684    fn test_convert_same_timeframe() {
685        let result =
686            convert_time_frame(pos_or_panic!(42.0), &TimeFrame::Hour, &TimeFrame::Hour).unwrap();
687        assert_pos_relative_eq!(result, pos_or_panic!(42.0), pos_or_panic!(1e-10));
688    }
689
690    #[test]
691    fn test_convert_weeks_to_months() {
692        let result =
693            convert_time_frame(pos_or_panic!(4.0), &TimeFrame::Week, &TimeFrame::Month).unwrap();
694        // Approximately 0.92 months (4 weeks / 4.33 weeks per month)
695        assert_pos_relative_eq!(
696            result,
697            pos_or_panic!(0.920_547_945_255_920_4),
698            pos_or_panic!(1e-10)
699        );
700    }
701
702    #[test]
703    fn test_convert_milliseconds_to_seconds() {
704        let result = convert_time_frame(
705            pos_or_panic!(1000.0),
706            &TimeFrame::Millisecond,
707            &TimeFrame::Second,
708        )
709        .unwrap();
710        assert_pos_relative_eq!(result, Positive::ONE, pos_or_panic!(1e-10));
711    }
712
713    #[test]
714    fn test_zero() {
715        let result =
716            convert_time_frame(Positive::ZERO, &TimeFrame::Millisecond, &TimeFrame::Second)
717                .unwrap();
718        assert_pos_relative_eq!(result, Positive::ZERO, pos_or_panic!(1e-10));
719    }
720}
721
722#[cfg(test)]
723mod tests_x_days_formatted_pos {
724    use super::*;
725
726    #[test]
727    fn test_get_x_days_formatted_pos_rounds_up_and_matches_the_integer_form() {
728        let date = get_x_days_formatted_pos(pos_or_panic!(6.2)).expect("seven days ahead");
729        assert_eq!(Some(date), get_x_days_formatted(7).ok());
730    }
731
732    #[test]
733    fn test_get_x_days_formatted_pos_zero_days_is_today() {
734        let date = get_x_days_formatted_pos(Positive::ZERO).expect("today");
735        assert_eq!(date, get_today_formatted());
736    }
737
738    #[test]
739    fn test_get_x_days_formatted_pos_beyond_the_calendar_reports_overflow() {
740        // A billion days is a valid `chrono` span but no `NaiveDate` holds it.
741        let result = get_x_days_formatted_pos(pos_or_panic!(1_000_000_000.0));
742        assert!(matches!(
743            result,
744            Err(ExpirationDateError::ArithmeticOverflow(_))
745        ));
746    }
747
748    #[test]
749    fn test_get_x_days_formatted_pos_beyond_i64_reports_overflow() {
750        let result = get_x_days_formatted_pos(Positive::MAX);
751        assert!(matches!(
752            result,
753            Err(ExpirationDateError::ArithmeticOverflow(_))
754        ));
755    }
756}
757
758#[cfg(test)]
759mod tests_panic_paths {
760    use super::*;
761    use chrono::{Duration, Local};
762
763    // `to / from` with a zero `from` aborted with `Positive invariant broken
764    // in div: result would be non-positive` (#788).
765    #[test]
766    fn test_convert_time_frame_from_custom_zero_is_error() {
767        let result = convert_time_frame(
768            Positive::ONE,
769            &TimeFrame::Custom(Positive::ZERO),
770            &TimeFrame::Day,
771        );
772        assert!(result.is_err());
773    }
774
775    // `value * factor` aborted with `Positive arithmetic overflow in mul`
776    // (#788).
777    #[test]
778    fn test_convert_time_frame_overflow_is_error() {
779        let result = convert_time_frame(Positive::MAX, &TimeFrame::Year, &TimeFrame::Day);
780        assert!(result.is_err());
781    }
782
783    #[test]
784    fn test_convert_time_frame_values_unchanged() {
785        let result = convert_time_frame(Positive::TWO, &TimeFrame::Week, &TimeFrame::Day);
786        assert_eq!(
787            result.ok(),
788            Some(
789                Positive::TWO * units_per_year(&TimeFrame::Day) / units_per_year(&TimeFrame::Week)
790            )
791        );
792    }
793
794    // `Duration::days(i64::MAX)` aborted with `TimeDelta::days out of
795    // bounds` (#788).
796    #[test]
797    fn test_get_x_days_formatted_i64_max_is_error() {
798        assert!(matches!(
799            get_x_days_formatted(i64::MAX),
800            Err(ExpirationDateError::ArithmeticOverflow(_))
801        ));
802        assert!(get_x_days_formatted(i64::MIN).is_err());
803    }
804
805    // `NaiveDate + TimeDelta` aborted with `` `NaiveDate + TimeDelta`
806    // overflowed `` a billion days out (#788).
807    #[test]
808    fn test_get_x_days_formatted_billion_days_is_error() {
809        assert!(matches!(
810            get_x_days_formatted(1_000_000_000),
811            Err(ExpirationDateError::ArithmeticOverflow(_))
812        ));
813    }
814
815    #[test]
816    fn test_get_x_days_formatted_values_unchanged() {
817        let expected = (Local::now().date_naive() + Duration::days(2))
818            .format("%d-%b-%Y")
819            .to_string()
820            .to_lowercase();
821        assert_eq!(get_x_days_formatted(2).ok(), Some(expected));
822        let tomorrow = (Local::now().date_naive() + Duration::days(1))
823            .format("%d-%b-%Y")
824            .to_string()
825            .to_lowercase();
826        assert_eq!(get_tomorrow_formatted().ok(), Some(tomorrow));
827    }
828}