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