Skip to main content

quant_primitives/
interval.rs

1//! Time interval for candle aggregation.
2
3use chrono::Duration;
4use serde::{Deserialize, Deserializer, Serialize, Serializer};
5use std::fmt;
6use std::str::FromStr;
7use thiserror::Error;
8
9/// Errors from parsing an [`Interval`] from a string.
10#[derive(Debug, Error, PartialEq, Eq)]
11pub enum IntervalError {
12    /// The provided string does not match any known interval format.
13    #[error("unknown interval: {0}")]
14    Unknown(String),
15}
16
17/// Candle aggregation interval.
18///
19/// Represents the time granularity of OHLCV candles used for backtesting,
20/// data collection, and strategy execution.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub enum Interval {
23    /// 1-minute candles.
24    Minute,
25    /// 1-hour candles.
26    Hour,
27    /// 1-day (daily) candles.
28    Day,
29    /// 1-week (weekly) candles.
30    Week,
31}
32
33impl Interval {
34    /// Canonical short string representation.
35    pub fn as_str(&self) -> &'static str {
36        match self {
37            Interval::Minute => "1m",
38            Interval::Hour => "1h",
39            Interval::Day => "1d",
40            Interval::Week => "1w",
41        }
42    }
43
44    /// Number of periods per year for annualization (#1530).
45    ///
46    /// Crypto markets trade 24/7 (365 days, 8760 hours). Traditional equity
47    /// markets trade ~252 days/year, ~6.5 hours/day.
48    ///
49    /// Used by Sharpe, Sortino, annualized return, CAGR calculations.
50    pub fn periods_per_year(&self, is_crypto: bool) -> u32 {
51        match (self, is_crypto) {
52            (Interval::Minute, true) => 525_600, // 365 * 24 * 60
53            (Interval::Minute, false) => 98_280, // 252 * 6.5 * 60
54            (Interval::Hour, true) => 8_760,     // 365 * 24
55            (Interval::Hour, false) => 1_638,    // 252 * 6.5
56            (Interval::Day, true) => 365,
57            (Interval::Day, false) => 252,
58            (Interval::Week, _) => 52,
59        }
60    }
61
62    /// Returns lower intervals that can be aggregated into this one, ordered by preference.
63    ///
64    /// For example, `Day` returns `[Hour, Minute]` — try hourly first, then minute.
65    /// `Minute` returns `[]` — nothing can be aggregated into minute candles.
66    pub fn lower_intervals(&self) -> &'static [Interval] {
67        match self {
68            Interval::Minute => &[],
69            Interval::Hour => &[Interval::Minute],
70            Interval::Day => &[Interval::Hour, Interval::Minute],
71            Interval::Week => &[Interval::Day, Interval::Hour, Interval::Minute],
72        }
73    }
74}
75
76impl fmt::Display for Interval {
77    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78        f.write_str(self.as_str())
79    }
80}
81
82impl Serialize for Interval {
83    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
84        serializer.serialize_str(self.as_str())
85    }
86}
87
88impl<'de> Deserialize<'de> for Interval {
89    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
90        let s = String::deserialize(deserializer)?;
91        s.parse().map_err(serde::de::Error::custom)
92    }
93}
94
95impl FromStr for Interval {
96    type Err = IntervalError;
97
98    fn from_str(s: &str) -> Result<Self, Self::Err> {
99        match s.to_lowercase().as_str() {
100            "1m" | "m" | "min" | "minute" => Ok(Interval::Minute),
101            "1h" | "h" | "hour" | "hourly" => Ok(Interval::Hour),
102            "1d" | "d" | "day" | "daily" => Ok(Interval::Day),
103            "1w" | "w" | "week" | "weekly" => Ok(Interval::Week),
104            _ => Err(IntervalError::Unknown(s.to_string())),
105        }
106    }
107}
108
109/// Get duration for an interval.
110pub fn interval_duration(interval: Interval) -> Duration {
111    match interval {
112        Interval::Minute => Duration::minutes(1),
113        Interval::Hour => Duration::hours(1),
114        Interval::Day => Duration::days(1),
115        Interval::Week => Duration::weeks(1),
116    }
117}
118
119#[cfg(test)]
120#[path = "interval_tests.rs"]
121mod tests;