Skip to main content

wickra_core/
ohlcv.rs

1//! OHLCV value types: candles and ticks.
2
3use crate::error::{Error, Result};
4
5/// A single OHLCV bar.
6///
7/// Timestamps are unitless `i64` values so callers can use whatever epoch resolution
8/// they prefer (milliseconds, microseconds, seconds…). Wickra never inspects them
9/// numerically beyond passing them through.
10#[derive(Debug, Clone, Copy, PartialEq)]
11pub struct Candle {
12    /// Bar open price.
13    pub open: f64,
14    /// Bar high price.
15    pub high: f64,
16    /// Bar low price.
17    pub low: f64,
18    /// Bar close price.
19    pub close: f64,
20    /// Bar volume.
21    pub volume: f64,
22    /// Bar timestamp (caller-defined epoch / resolution).
23    pub timestamp: i64,
24}
25
26impl Candle {
27    /// Construct a new candle, validating the OHLC relationships and finiteness.
28    ///
29    /// # Errors
30    ///
31    /// Returns [`Error::InvalidCandle`] if any of these invariants are violated:
32    /// - `high >= max(open, close, low)`
33    /// - `low  <= min(open, close, high)`
34    /// - all of `open`, `high`, `low`, `close`, `volume` are finite
35    /// - `volume >= 0`
36    pub fn new(
37        open: f64,
38        high: f64,
39        low: f64,
40        close: f64,
41        volume: f64,
42        timestamp: i64,
43    ) -> Result<Self> {
44        if !(open.is_finite() && high.is_finite() && low.is_finite() && close.is_finite()) {
45            return Err(Error::InvalidCandle {
46                message: "open, high, low, close must all be finite",
47            });
48        }
49        if !volume.is_finite() {
50            return Err(Error::InvalidCandle {
51                message: "volume must be finite",
52            });
53        }
54        if volume < 0.0 {
55            return Err(Error::InvalidCandle {
56                message: "volume must be non-negative",
57            });
58        }
59        if high < low {
60            return Err(Error::InvalidCandle {
61                message: "high must be >= low",
62            });
63        }
64        if high < open || high < close {
65            return Err(Error::InvalidCandle {
66                message: "high must be >= open and >= close",
67            });
68        }
69        if low > open || low > close {
70            return Err(Error::InvalidCandle {
71                message: "low must be <= open and <= close",
72            });
73        }
74        Ok(Self {
75            open,
76            high,
77            low,
78            close,
79            volume,
80            timestamp,
81        })
82    }
83
84    /// Construct a candle without validation. The caller asserts that all OHLC
85    /// invariants hold and that no field is NaN or infinite.
86    pub const fn new_unchecked(
87        open: f64,
88        high: f64,
89        low: f64,
90        close: f64,
91        volume: f64,
92        timestamp: i64,
93    ) -> Self {
94        Self {
95            open,
96            high,
97            low,
98            close,
99            volume,
100            timestamp,
101        }
102    }
103
104    /// The typical price `(high + low + close) / 3`. Used by CCI, MFI, VWAP, etc.
105    #[inline]
106    pub fn typical_price(&self) -> f64 {
107        (self.high + self.low + self.close) / 3.0
108    }
109
110    /// The mid price `(high + low) / 2`.
111    #[inline]
112    pub fn median_price(&self) -> f64 {
113        f64::midpoint(self.high, self.low)
114    }
115
116    /// The weighted close `(high + low + 2*close) / 4`.
117    #[inline]
118    pub fn weighted_close(&self) -> f64 {
119        (self.high + self.low + 2.0 * self.close) / 4.0
120    }
121
122    /// True range of this candle relative to a previous close: `max(H-L, |H-prev|, |L-prev|)`.
123    /// If no previous close is supplied, falls back to `high - low`.
124    #[inline]
125    pub fn true_range(&self, prev_close: Option<f64>) -> f64 {
126        let hl = self.high - self.low;
127        match prev_close {
128            Some(prev) => {
129                let hp = (self.high - prev).abs();
130                let lp = (self.low - prev).abs();
131                hl.max(hp).max(lp)
132            }
133            None => hl,
134        }
135    }
136}
137
138/// A single trade tick.
139#[derive(Debug, Clone, Copy, PartialEq)]
140pub struct Tick {
141    /// Trade price.
142    pub price: f64,
143    /// Trade size.
144    pub volume: f64,
145    /// Trade timestamp (caller-defined epoch / resolution).
146    pub timestamp: i64,
147}
148
149impl Tick {
150    /// Construct a new tick, validating finiteness and non-negativity of volume.
151    ///
152    /// # Errors
153    ///
154    /// Returns [`Error::NonFiniteInput`] if `price` or `volume` is NaN or infinite,
155    /// or [`Error::InvalidTick`] for `volume < 0`. (Audit finding R14 — previously
156    /// returned [`Error::InvalidCandle`], which is semantically wrong for a tick.)
157    pub fn new(price: f64, volume: f64, timestamp: i64) -> Result<Self> {
158        if !price.is_finite() || !volume.is_finite() {
159            return Err(Error::NonFiniteInput);
160        }
161        if volume < 0.0 {
162            return Err(Error::InvalidTick {
163                message: "tick volume must be non-negative",
164            });
165        }
166        Ok(Self {
167            price,
168            volume,
169            timestamp,
170        })
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn candle_new_accepts_valid_ohlc() {
180        let c = Candle::new(10.0, 11.0, 9.0, 10.5, 100.0, 1).unwrap();
181        assert_eq!(c.open, 10.0);
182        assert_eq!(c.high, 11.0);
183        assert_eq!(c.low, 9.0);
184        assert_eq!(c.close, 10.5);
185        assert_eq!(c.volume, 100.0);
186        assert_eq!(c.timestamp, 1);
187    }
188
189    #[test]
190    fn candle_new_rejects_high_below_low() {
191        let err = Candle::new(10.0, 9.0, 10.0, 10.0, 1.0, 0).unwrap_err();
192        assert!(matches!(err, Error::InvalidCandle { .. }));
193    }
194
195    #[test]
196    fn candle_new_rejects_high_below_close() {
197        let err = Candle::new(10.0, 10.0, 9.0, 11.0, 1.0, 0).unwrap_err();
198        assert!(matches!(err, Error::InvalidCandle { .. }));
199    }
200
201    #[test]
202    fn candle_new_rejects_low_above_open() {
203        let err = Candle::new(10.0, 11.0, 10.5, 10.5, 1.0, 0).unwrap_err();
204        assert!(matches!(err, Error::InvalidCandle { .. }));
205    }
206
207    #[test]
208    fn candle_new_rejects_negative_volume() {
209        let err = Candle::new(10.0, 11.0, 9.0, 10.5, -1.0, 0).unwrap_err();
210        assert!(matches!(err, Error::InvalidCandle { .. }));
211    }
212
213    #[test]
214    fn candle_new_rejects_nan_price() {
215        let err = Candle::new(f64::NAN, 11.0, 9.0, 10.5, 1.0, 0).unwrap_err();
216        assert!(matches!(err, Error::InvalidCandle { .. }));
217    }
218
219    /// Cover the unchecked constructor `Candle::new_unchecked` (lines 86-102).
220    /// Every existing test routes through the validating `Candle::new`, so the
221    /// unchecked path is dead.
222    ///
223    /// The first assertion shows that a valid set of fields round-trips
224    /// verbatim. The second feeds `high < low` (which `Candle::new` would
225    /// reject with `Error::InvalidCandle`) and asserts the unchecked
226    /// constructor still produces the struct as-is — documenting and
227    /// enforcing the API contract that the unchecked variant performs no
228    /// validation and is the caller's responsibility.
229    #[test]
230    fn candle_new_unchecked_preserves_fields_verbatim() {
231        let c = Candle::new_unchecked(1.0, 2.0, 0.5, 1.5, 100.0, 42);
232        assert_eq!(c.open, 1.0);
233        assert_eq!(c.high, 2.0);
234        assert_eq!(c.low, 0.5);
235        assert_eq!(c.close, 1.5);
236        assert_eq!(c.volume, 100.0);
237        assert_eq!(c.timestamp, 42);
238
239        // Skip-validation contract: an OHLC combination that the checked
240        // constructor rejects (high < low) is still built without error.
241        assert!(Candle::new(10.0, 9.0, 10.0, 10.0, 1.0, 0).is_err());
242        let unchecked = Candle::new_unchecked(10.0, 9.0, 10.0, 10.0, 1.0, 0);
243        assert_eq!(unchecked.high, 9.0);
244        assert_eq!(unchecked.low, 10.0);
245    }
246
247    #[test]
248    fn candle_typical_price() {
249        let c = Candle::new(10.0, 12.0, 9.0, 11.0, 1.0, 0).unwrap();
250        assert_eq!(c.typical_price(), (12.0 + 9.0 + 11.0) / 3.0);
251    }
252
253    #[test]
254    fn candle_median_price() {
255        let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
256        assert_eq!(c.median_price(), 10.0);
257    }
258
259    #[test]
260    fn candle_weighted_close() {
261        let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
262        assert_eq!(c.weighted_close(), (12.0 + 8.0 + 22.0) / 4.0);
263    }
264
265    #[test]
266    fn candle_true_range_without_prev() {
267        let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
268        assert_eq!(c.true_range(None), 4.0);
269    }
270
271    #[test]
272    fn candle_true_range_with_gap_up() {
273        // Previous close 6, today's range 8-12: gap covered by |H-prev|=6
274        let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
275        assert_eq!(c.true_range(Some(6.0)), 6.0);
276    }
277
278    #[test]
279    fn candle_true_range_with_gap_down() {
280        // Previous close 14, today's range 8-12: gap covered by |L-prev|=6
281        let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
282        assert_eq!(c.true_range(Some(14.0)), 6.0);
283    }
284
285    #[test]
286    fn tick_new_accepts_valid() {
287        let t = Tick::new(100.5, 0.5, 42).unwrap();
288        assert_eq!(t.price, 100.5);
289        assert_eq!(t.volume, 0.5);
290        assert_eq!(t.timestamp, 42);
291    }
292
293    #[test]
294    fn tick_new_rejects_nan() {
295        assert!(matches!(
296            Tick::new(f64::NAN, 1.0, 0),
297            Err(Error::NonFiniteInput)
298        ));
299    }
300
301    #[test]
302    fn tick_new_rejects_inf() {
303        assert!(matches!(
304            Tick::new(f64::INFINITY, 1.0, 0),
305            Err(Error::NonFiniteInput)
306        ));
307    }
308
309    #[test]
310    fn tick_new_rejects_negative_volume() {
311        // Audit R14: the variant is `InvalidTick`, not `InvalidCandle` — a tick
312        // is not a candle, and downstream pipelines should be able to match on
313        // the correct semantic.
314        let err = Tick::new(100.0, -1.0, 0).unwrap_err();
315        assert!(matches!(err, Error::InvalidTick { .. }));
316        assert!(
317            err.to_string().contains("tick volume"),
318            "expected the InvalidTick message in the formatted error, got {err}"
319        );
320    }
321}