Skip to main content

quant_primitives/
candle.rs

1//! OHLCV candle representing price action over a time interval.
2
3use chrono::{DateTime, Utc};
4use rust_decimal::Decimal;
5use serde::{Deserialize, Serialize};
6use std::fmt;
7use thiserror::Error;
8
9#[derive(Debug, Error, PartialEq, Eq)]
10pub enum CandleError {
11    #[error("high ({high}) must be >= low ({low})")]
12    HighBelowLow { high: Decimal, low: Decimal },
13
14    #[error("aggregation received empty OHLC data")]
15    EmptyAggregation,
16}
17
18/// Origin of the candle volume figure.
19///
20/// Tracks whether volume represents actual trade quantities, tick count
21/// fallback, exchange-reported aggregates, or unknown legacy data.
22#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23pub enum VolumeSource {
24    /// Sum of individual trade sizes from exchange (crypto spot/perps).
25    TradeSize,
26    /// Number of quote changes — fallback when no real volume (FX, some CFDs).
27    TickCount,
28    /// Official aggregate volume from a data provider API (EODHD, CCData).
29    ExchangeReported,
30    /// Volume origin unknown or not determinable (legacy, CSV imports).
31    #[default]
32    Unknown,
33}
34
35impl VolumeSource {
36    /// String representation for database storage.
37    #[must_use]
38    pub fn as_str(&self) -> &str {
39        match self {
40            Self::TradeSize => "trade_size",
41            Self::TickCount => "tick_count",
42            Self::ExchangeReported => "exchange_reported",
43            Self::Unknown => "unknown",
44        }
45    }
46
47    /// Parse from database string representation.
48    #[must_use]
49    pub fn from_str_value(s: &str) -> Self {
50        match s {
51            "trade_size" => Self::TradeSize,
52            "tick_count" => Self::TickCount,
53            "exchange_reported" => Self::ExchangeReported,
54            _ => Self::Unknown,
55        }
56    }
57}
58
59impl fmt::Display for VolumeSource {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        f.write_str(self.as_str())
62    }
63}
64
65/// Data quality flag for OHLCV candles.
66///
67/// Tracks whether a candle represents a complete interval or partial data
68/// (e.g., provider outage, window invalidation, gap fill).
69#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub enum CandleQuality {
71    /// All OHLCV data is present and valid for the full interval.
72    #[default]
73    Complete,
74    /// Data is incomplete — `reason` explains why (e.g., "provider timeout").
75    Partial { reason: String },
76}
77
78impl CandleQuality {
79    /// String representation for database storage.
80    #[must_use]
81    pub fn as_str(&self) -> &str {
82        match self {
83            Self::Complete => "complete",
84            Self::Partial { .. } => "partial",
85        }
86    }
87
88    /// Parse from database string representation.
89    ///
90    /// Unknown strings fall back to `Complete` (the default variant).
91    /// Note: the `reason` field travels via serde (JSON), not via this method.
92    #[must_use]
93    pub fn from_str_value(s: &str) -> Self {
94        match s {
95            "complete" => Self::Complete,
96            "partial" => Self::Partial {
97                reason: String::new(),
98            },
99            _ => Self::Complete,
100        }
101    }
102}
103
104impl fmt::Display for CandleQuality {
105    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106        f.write_str(self.as_str())
107    }
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111pub struct Candle {
112    open: Decimal,
113    high: Decimal,
114    low: Decimal,
115    close: Decimal,
116    volume: Decimal,
117    timestamp: DateTime<Utc>,
118    #[serde(default)]
119    volume_source: VolumeSource,
120    #[serde(default)]
121    quality: CandleQuality,
122}
123
124impl Candle {
125    /// Create a candle with `VolumeSource::Unknown` (backward-compatible).
126    pub fn new(
127        open: Decimal,
128        high: Decimal,
129        low: Decimal,
130        close: Decimal,
131        volume: Decimal,
132        timestamp: DateTime<Utc>,
133    ) -> Result<Self, CandleError> {
134        if high < low {
135            return Err(CandleError::HighBelowLow { high, low });
136        }
137        Ok(Self {
138            open,
139            high,
140            low,
141            close,
142            volume,
143            timestamp,
144            volume_source: VolumeSource::Unknown,
145            quality: CandleQuality::Complete,
146        })
147    }
148
149    /// Set the volume source (builder pattern).
150    #[must_use]
151    pub fn with_volume_source(mut self, source: VolumeSource) -> Self {
152        self.volume_source = source;
153        self
154    }
155
156    /// Set the data quality flag (builder pattern).
157    #[must_use]
158    pub fn with_quality(mut self, quality: CandleQuality) -> Self {
159        self.quality = quality;
160        self
161    }
162
163    #[must_use]
164    pub fn open(&self) -> Decimal {
165        self.open
166    }
167
168    #[must_use]
169    pub fn high(&self) -> Decimal {
170        self.high
171    }
172
173    #[must_use]
174    pub fn low(&self) -> Decimal {
175        self.low
176    }
177
178    #[must_use]
179    pub fn close(&self) -> Decimal {
180        self.close
181    }
182
183    #[must_use]
184    pub fn volume(&self) -> Decimal {
185        self.volume
186    }
187
188    #[must_use]
189    pub fn timestamp(&self) -> DateTime<Utc> {
190        self.timestamp
191    }
192
193    #[must_use]
194    pub fn volume_source(&self) -> VolumeSource {
195        self.volume_source
196    }
197
198    #[must_use]
199    pub fn quality(&self) -> &CandleQuality {
200        &self.quality
201    }
202}
203
204/// Time boundaries extracted from a candle slice.
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub struct CandleRange {
207    pub earliest: DateTime<Utc>,
208    pub latest: DateTime<Utc>,
209}
210
211impl CandleRange {
212    /// Extract time boundaries from a non-empty, sorted candle slice.
213    /// Returns `None` if the slice is empty.
214    #[must_use]
215    pub fn from_candles(candles: &[Candle]) -> Option<Self> {
216        let earliest = candles.first()?.timestamp;
217        let latest = candles.last()?.timestamp;
218        Some(Self { earliest, latest })
219    }
220}
221
222#[cfg(test)]
223#[path = "candle_tests.rs"]
224mod tests;