quant-primitives 0.7.0

Pure trading primitives — candles, intervals, symbols, currencies, asset taxonomy
Documentation
//! Time interval for candle aggregation.

use chrono::Duration;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::fmt;
use std::str::FromStr;
use thiserror::Error;

/// Errors from parsing an [`Interval`] from a string.
#[derive(Debug, Error, PartialEq, Eq)]
pub enum IntervalError {
    /// The provided string does not match any known interval format.
    #[error("unknown interval: {0}")]
    Unknown(String),
}

/// Candle aggregation interval.
///
/// Represents the time granularity of OHLCV candles used for backtesting,
/// data collection, and strategy execution.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Interval {
    /// 1-minute candles.
    Minute,
    /// 1-hour candles.
    Hour,
    /// 1-day (daily) candles.
    Day,
    /// 1-week (weekly) candles.
    Week,
}

impl Interval {
    /// Canonical short string representation.
    pub fn as_str(&self) -> &'static str {
        match self {
            Interval::Minute => "1m",
            Interval::Hour => "1h",
            Interval::Day => "1d",
            Interval::Week => "1w",
        }
    }

    /// Number of periods per year for annualization (#1530).
    ///
    /// Crypto markets trade 24/7 (365 days, 8760 hours). Traditional equity
    /// markets trade ~252 days/year, ~6.5 hours/day.
    ///
    /// Used by Sharpe, Sortino, annualized return, CAGR calculations.
    pub fn periods_per_year(&self, is_crypto: bool) -> u32 {
        match (self, is_crypto) {
            (Interval::Minute, true) => 525_600, // 365 * 24 * 60
            (Interval::Minute, false) => 98_280, // 252 * 6.5 * 60
            (Interval::Hour, true) => 8_760,     // 365 * 24
            (Interval::Hour, false) => 1_638,    // 252 * 6.5
            (Interval::Day, true) => 365,
            (Interval::Day, false) => 252,
            (Interval::Week, _) => 52,
        }
    }

    /// Returns lower intervals that can be aggregated into this one, ordered by preference.
    ///
    /// For example, `Day` returns `[Hour, Minute]` — try hourly first, then minute.
    /// `Minute` returns `[]` — nothing can be aggregated into minute candles.
    pub fn lower_intervals(&self) -> &'static [Interval] {
        match self {
            Interval::Minute => &[],
            Interval::Hour => &[Interval::Minute],
            Interval::Day => &[Interval::Hour, Interval::Minute],
            Interval::Week => &[Interval::Day, Interval::Hour, Interval::Minute],
        }
    }
}

impl fmt::Display for Interval {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

impl Serialize for Interval {
    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
        serializer.serialize_str(self.as_str())
    }
}

impl<'de> Deserialize<'de> for Interval {
    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
        let s = String::deserialize(deserializer)?;
        s.parse().map_err(serde::de::Error::custom)
    }
}

impl FromStr for Interval {
    type Err = IntervalError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s.to_lowercase().as_str() {
            "1m" | "m" | "min" | "minute" => Ok(Interval::Minute),
            "1h" | "h" | "hour" | "hourly" => Ok(Interval::Hour),
            "1d" | "d" | "day" | "daily" => Ok(Interval::Day),
            "1w" | "w" | "week" | "weekly" => Ok(Interval::Week),
            _ => Err(IntervalError::Unknown(s.to_string())),
        }
    }
}

/// Get duration for an interval.
pub fn interval_duration(interval: Interval) -> Duration {
    match interval {
        Interval::Minute => Duration::minutes(1),
        Interval::Hour => Duration::hours(1),
        Interval::Day => Duration::days(1),
        Interval::Week => Duration::weeks(1),
    }
}

#[cfg(test)]
#[path = "interval_tests.rs"]
mod tests;