quant-primitives 0.7.0

Pure trading primitives — candles, intervals, symbols, currencies, asset taxonomy
Documentation
//! OHLCV candle representing price action over a time interval.

use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::fmt;
use thiserror::Error;

#[derive(Debug, Error, PartialEq, Eq)]
pub enum CandleError {
    #[error("high ({high}) must be >= low ({low})")]
    HighBelowLow { high: Decimal, low: Decimal },

    #[error("aggregation received empty OHLC data")]
    EmptyAggregation,
}

/// Origin of the candle volume figure.
///
/// Tracks whether volume represents actual trade quantities, tick count
/// fallback, exchange-reported aggregates, or unknown legacy data.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum VolumeSource {
    /// Sum of individual trade sizes from exchange (crypto spot/perps).
    TradeSize,
    /// Number of quote changes — fallback when no real volume (FX, some CFDs).
    TickCount,
    /// Official aggregate volume from a data provider API (EODHD, CCData).
    ExchangeReported,
    /// Volume origin unknown or not determinable (legacy, CSV imports).
    #[default]
    Unknown,
}

impl VolumeSource {
    /// String representation for database storage.
    #[must_use]
    pub fn as_str(&self) -> &str {
        match self {
            Self::TradeSize => "trade_size",
            Self::TickCount => "tick_count",
            Self::ExchangeReported => "exchange_reported",
            Self::Unknown => "unknown",
        }
    }

    /// Parse from database string representation.
    #[must_use]
    pub fn from_str_value(s: &str) -> Self {
        match s {
            "trade_size" => Self::TradeSize,
            "tick_count" => Self::TickCount,
            "exchange_reported" => Self::ExchangeReported,
            _ => Self::Unknown,
        }
    }
}

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

/// Data quality flag for OHLCV candles.
///
/// Tracks whether a candle represents a complete interval or partial data
/// (e.g., provider outage, window invalidation, gap fill).
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum CandleQuality {
    /// All OHLCV data is present and valid for the full interval.
    #[default]
    Complete,
    /// Data is incomplete — `reason` explains why (e.g., "provider timeout").
    Partial { reason: String },
}

impl CandleQuality {
    /// String representation for database storage.
    #[must_use]
    pub fn as_str(&self) -> &str {
        match self {
            Self::Complete => "complete",
            Self::Partial { .. } => "partial",
        }
    }

    /// Parse from database string representation.
    ///
    /// Unknown strings fall back to `Complete` (the default variant).
    /// Note: the `reason` field travels via serde (JSON), not via this method.
    #[must_use]
    pub fn from_str_value(s: &str) -> Self {
        match s {
            "complete" => Self::Complete,
            "partial" => Self::Partial {
                reason: String::new(),
            },
            _ => Self::Complete,
        }
    }
}

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

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Candle {
    open: Decimal,
    high: Decimal,
    low: Decimal,
    close: Decimal,
    volume: Decimal,
    timestamp: DateTime<Utc>,
    #[serde(default)]
    volume_source: VolumeSource,
    #[serde(default)]
    quality: CandleQuality,
}

impl Candle {
    /// Create a candle with `VolumeSource::Unknown` (backward-compatible).
    pub fn new(
        open: Decimal,
        high: Decimal,
        low: Decimal,
        close: Decimal,
        volume: Decimal,
        timestamp: DateTime<Utc>,
    ) -> Result<Self, CandleError> {
        if high < low {
            return Err(CandleError::HighBelowLow { high, low });
        }
        Ok(Self {
            open,
            high,
            low,
            close,
            volume,
            timestamp,
            volume_source: VolumeSource::Unknown,
            quality: CandleQuality::Complete,
        })
    }

    /// Set the volume source (builder pattern).
    #[must_use]
    pub fn with_volume_source(mut self, source: VolumeSource) -> Self {
        self.volume_source = source;
        self
    }

    /// Set the data quality flag (builder pattern).
    #[must_use]
    pub fn with_quality(mut self, quality: CandleQuality) -> Self {
        self.quality = quality;
        self
    }

    #[must_use]
    pub fn open(&self) -> Decimal {
        self.open
    }

    #[must_use]
    pub fn high(&self) -> Decimal {
        self.high
    }

    #[must_use]
    pub fn low(&self) -> Decimal {
        self.low
    }

    #[must_use]
    pub fn close(&self) -> Decimal {
        self.close
    }

    #[must_use]
    pub fn volume(&self) -> Decimal {
        self.volume
    }

    #[must_use]
    pub fn timestamp(&self) -> DateTime<Utc> {
        self.timestamp
    }

    #[must_use]
    pub fn volume_source(&self) -> VolumeSource {
        self.volume_source
    }

    #[must_use]
    pub fn quality(&self) -> &CandleQuality {
        &self.quality
    }
}

/// Time boundaries extracted from a candle slice.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CandleRange {
    pub earliest: DateTime<Utc>,
    pub latest: DateTime<Utc>,
}

impl CandleRange {
    /// Extract time boundaries from a non-empty, sorted candle slice.
    /// Returns `None` if the slice is empty.
    #[must_use]
    pub fn from_candles(candles: &[Candle]) -> Option<Self> {
        let earliest = candles.first()?.timestamp;
        let latest = candles.last()?.timestamp;
        Some(Self { earliest, latest })
    }
}

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