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    /// The average price `(open + high + low + close) / 4`.
123    #[inline]
124    pub fn avg_price(&self) -> f64 {
125        (self.open + self.high + self.low + self.close) / 4.0
126    }
127
128    /// True range of this candle relative to a previous close: `max(H-L, |H-prev|, |L-prev|)`.
129    /// If no previous close is supplied, falls back to `high - low`.
130    #[inline]
131    pub fn true_range(&self, prev_close: Option<f64>) -> f64 {
132        let hl = self.high - self.low;
133        match prev_close {
134            Some(prev) => {
135                let hp = (self.high - prev).abs();
136                let lp = (self.low - prev).abs();
137                hl.max(hp).max(lp)
138            }
139            None => hl,
140        }
141    }
142}
143
144/// A single trade tick.
145#[derive(Debug, Clone, Copy, PartialEq)]
146pub struct Tick {
147    /// Trade price.
148    pub price: f64,
149    /// Trade size.
150    pub volume: f64,
151    /// Trade timestamp (caller-defined epoch / resolution).
152    pub timestamp: i64,
153}
154
155impl Tick {
156    /// Construct a new tick, validating finiteness and non-negativity of volume.
157    ///
158    /// # Errors
159    ///
160    /// Returns [`Error::NonFiniteInput`] if `price` or `volume` is NaN or infinite,
161    /// or [`Error::InvalidTick`] for `volume < 0`. (Audit finding R14 — previously
162    /// returned [`Error::InvalidCandle`], which is semantically wrong for a tick.)
163    pub fn new(price: f64, volume: f64, timestamp: i64) -> Result<Self> {
164        if !price.is_finite() || !volume.is_finite() {
165            return Err(Error::NonFiniteInput);
166        }
167        if volume < 0.0 {
168            return Err(Error::InvalidTick {
169                message: "tick volume must be non-negative",
170            });
171        }
172        Ok(Self {
173            price,
174            volume,
175            timestamp,
176        })
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn candle_new_accepts_valid_ohlc() {
186        let c = Candle::new(10.0, 11.0, 9.0, 10.5, 100.0, 1).unwrap();
187        assert_eq!(c.open, 10.0);
188        assert_eq!(c.high, 11.0);
189        assert_eq!(c.low, 9.0);
190        assert_eq!(c.close, 10.5);
191        assert_eq!(c.volume, 100.0);
192        assert_eq!(c.timestamp, 1);
193    }
194
195    #[test]
196    fn candle_new_rejects_high_below_low() {
197        let err = Candle::new(10.0, 9.0, 10.0, 10.0, 1.0, 0).unwrap_err();
198        assert!(matches!(err, Error::InvalidCandle { .. }));
199    }
200
201    #[test]
202    fn candle_new_rejects_high_below_close() {
203        let err = Candle::new(10.0, 10.0, 9.0, 11.0, 1.0, 0).unwrap_err();
204        assert!(matches!(err, Error::InvalidCandle { .. }));
205    }
206
207    #[test]
208    fn candle_new_rejects_low_above_open() {
209        let err = Candle::new(10.0, 11.0, 10.5, 10.5, 1.0, 0).unwrap_err();
210        assert!(matches!(err, Error::InvalidCandle { .. }));
211    }
212
213    #[test]
214    fn candle_new_rejects_negative_volume() {
215        let err = Candle::new(10.0, 11.0, 9.0, 10.5, -1.0, 0).unwrap_err();
216        assert!(matches!(err, Error::InvalidCandle { .. }));
217    }
218
219    #[test]
220    fn candle_new_rejects_nan_price() {
221        let err = Candle::new(f64::NAN, 11.0, 9.0, 10.5, 1.0, 0).unwrap_err();
222        assert!(matches!(err, Error::InvalidCandle { .. }));
223    }
224
225    /// Cover the unchecked constructor `Candle::new_unchecked` (lines 86-102).
226    /// Every existing test routes through the validating `Candle::new`, so the
227    /// unchecked path is dead.
228    ///
229    /// The first assertion shows that a valid set of fields round-trips
230    /// verbatim. The second feeds `high < low` (which `Candle::new` would
231    /// reject with `Error::InvalidCandle`) and asserts the unchecked
232    /// constructor still produces the struct as-is — documenting and
233    /// enforcing the API contract that the unchecked variant performs no
234    /// validation and is the caller's responsibility.
235    #[test]
236    fn candle_new_unchecked_preserves_fields_verbatim() {
237        let c = Candle::new_unchecked(1.0, 2.0, 0.5, 1.5, 100.0, 42);
238        assert_eq!(c.open, 1.0);
239        assert_eq!(c.high, 2.0);
240        assert_eq!(c.low, 0.5);
241        assert_eq!(c.close, 1.5);
242        assert_eq!(c.volume, 100.0);
243        assert_eq!(c.timestamp, 42);
244
245        // Skip-validation contract: an OHLC combination that the checked
246        // constructor rejects (high < low) is still built without error.
247        assert!(Candle::new(10.0, 9.0, 10.0, 10.0, 1.0, 0).is_err());
248        let unchecked = Candle::new_unchecked(10.0, 9.0, 10.0, 10.0, 1.0, 0);
249        assert_eq!(unchecked.high, 9.0);
250        assert_eq!(unchecked.low, 10.0);
251    }
252
253    #[test]
254    fn candle_typical_price() {
255        let c = Candle::new(10.0, 12.0, 9.0, 11.0, 1.0, 0).unwrap();
256        assert_eq!(c.typical_price(), (12.0 + 9.0 + 11.0) / 3.0);
257    }
258
259    #[test]
260    fn candle_median_price() {
261        let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
262        assert_eq!(c.median_price(), 10.0);
263    }
264
265    #[test]
266    fn candle_weighted_close() {
267        let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
268        assert_eq!(c.weighted_close(), (12.0 + 8.0 + 22.0) / 4.0);
269    }
270
271    #[test]
272    fn candle_true_range_without_prev() {
273        let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
274        assert_eq!(c.true_range(None), 4.0);
275    }
276
277    #[test]
278    fn candle_true_range_with_gap_up() {
279        // Previous close 6, today's range 8-12: gap covered by |H-prev|=6
280        let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
281        assert_eq!(c.true_range(Some(6.0)), 6.0);
282    }
283
284    #[test]
285    fn candle_true_range_with_gap_down() {
286        // Previous close 14, today's range 8-12: gap covered by |L-prev|=6
287        let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
288        assert_eq!(c.true_range(Some(14.0)), 6.0);
289    }
290
291    #[test]
292    fn tick_new_accepts_valid() {
293        let t = Tick::new(100.5, 0.5, 42).unwrap();
294        assert_eq!(t.price, 100.5);
295        assert_eq!(t.volume, 0.5);
296        assert_eq!(t.timestamp, 42);
297    }
298
299    #[test]
300    fn tick_new_rejects_nan() {
301        assert!(matches!(
302            Tick::new(f64::NAN, 1.0, 0),
303            Err(Error::NonFiniteInput)
304        ));
305    }
306
307    #[test]
308    fn tick_new_rejects_inf() {
309        assert!(matches!(
310            Tick::new(f64::INFINITY, 1.0, 0),
311            Err(Error::NonFiniteInput)
312        ));
313    }
314
315    #[test]
316    fn tick_new_rejects_negative_volume() {
317        // Audit R14: the variant is `InvalidTick`, not `InvalidCandle` — a tick
318        // is not a candle, and downstream pipelines should be able to match on
319        // the correct semantic.
320        let err = Tick::new(100.0, -1.0, 0).unwrap_err();
321        assert!(matches!(err, Error::InvalidTick { .. }));
322        assert!(
323            err.to_string().contains("tick volume"),
324            "expected the InvalidTick message in the formatted error, got {err}"
325        );
326    }
327}