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    /// Whether [`Candle::new`] would accept every bar `(open[i], high[i],
120    /// low[i], close[i], volume[i])`: all finite, a non-negative volume, and
121    /// `low <= open, close <= high`.
122    ///
123    /// The bars are checked in eight independent lanes reduced at the end, with
124    /// no early exit and no branch per rule, inside a kernel compiled for AVX2
125    /// where the CPU has it: a batch over OHLCV columns otherwise spends as long
126    /// validating as computing.
127    ///
128    /// # Panics
129    ///
130    /// Panics if the columns differ in length.
131    pub fn all_valid(
132        open: &[f64],
133        high: &[f64],
134        low: &[f64],
135        close: &[f64],
136        volume: &[f64],
137    ) -> bool {
138        Self::all_valid_within(open, high, low, close, volume, f64::MAX)
139    }
140
141    /// [`all_valid`](Self::all_valid) with every value at most `bound` in
142    /// magnitude as well -- the one pass a fast batch needs to know its
143    /// columns are both valid bars and inside its kernel's range. With
144    /// `f64::MAX` it is exactly `all_valid`.
145    ///
146    /// # Panics
147    ///
148    /// Panics if the columns differ in length.
149    pub(crate) fn all_valid_within(
150        open: &[f64],
151        high: &[f64],
152        low: &[f64],
153        close: &[f64],
154        volume: &[f64],
155        bound: f64,
156    ) -> bool {
157        let n = open.len();
158        assert!(
159            high.len() == n && low.len() == n && close.len() == n && volume.len() == n,
160            "every column must be equally long"
161        );
162        wickra_simd::dispatch(AllValid {
163            open,
164            high: &high[..n],
165            low: &low[..n],
166            close: &close[..n],
167            volume: &volume[..n],
168            bound,
169        })
170    }
171
172    /// [`all_valid`](Self::all_valid) for the candles a high/low/close series
173    /// builds, `Candle::new(close, high, low, close, 0.0, _)`: every value finite
174    /// and `low <= close <= high`. The same kernel over three columns.
175    ///
176    /// # Panics
177    ///
178    /// Panics if the columns differ in length.
179    pub fn all_valid_hlc(high: &[f64], low: &[f64], close: &[f64]) -> bool {
180        let n = high.len();
181        assert!(
182            low.len() == n && close.len() == n,
183            "every column must be equally long"
184        );
185        wickra_simd::dispatch(AllValidHlc {
186            high,
187            low: &low[..n],
188            close: &close[..n],
189        })
190    }
191
192    /// The typical price `(high + low + close) / 3`. Used by CCI, MFI, VWAP, etc.
193    #[inline]
194    pub fn typical_price(&self) -> f64 {
195        (self.high + self.low + self.close) / 3.0
196    }
197
198    /// The mid price `(high + low) / 2`.
199    #[inline]
200    pub fn median_price(&self) -> f64 {
201        f64::midpoint(self.high, self.low)
202    }
203
204    /// The weighted close `(high + low + 2*close) / 4`.
205    #[inline]
206    pub fn weighted_close(&self) -> f64 {
207        (self.high + self.low + 2.0 * self.close) / 4.0
208    }
209
210    /// The average price `(open + high + low + close) / 4`.
211    #[inline]
212    pub fn avg_price(&self) -> f64 {
213        (self.open + self.high + self.low + self.close) / 4.0
214    }
215
216    /// True range of this candle relative to a previous close: `max(H-L, |H-prev|, |L-prev|)`.
217    /// If no previous close is supplied, falls back to `high - low`.
218    #[inline]
219    pub fn true_range(&self, prev_close: Option<f64>) -> f64 {
220        let hl = self.high - self.low;
221        match prev_close {
222            Some(prev) => {
223                let hp = (self.high - prev).abs();
224                let lp = (self.low - prev).abs();
225                hl.max(hp).max(lp)
226            }
227            None => hl,
228        }
229    }
230}
231
232/// A single trade tick.
233///
234/// # Construction and the limits of its guarantee
235///
236/// The struct is `#[non_exhaustive]`, so code outside this crate cannot build
237/// one from a field literal and must go through [`new`](Self::new), which
238/// validates. A tick has no unchecked constructor.
239///
240/// The fields stay public because reading them is by far the common operation
241/// and an accessor on each would buy nothing. That does mean a validated value
242/// can still be *written* into an invalid state afterwards, and nothing detects
243/// it: the code that consumes this type relies on the constructor's guarantee
244/// rather than re-checking. A mutation is an assertion that the invariants
245/// still hold.
246#[derive(Debug, Clone, Copy, PartialEq)]
247#[non_exhaustive]
248pub struct Tick {
249    /// Trade price.
250    pub price: f64,
251    /// Trade size.
252    pub volume: f64,
253    /// Trade timestamp (caller-defined epoch / resolution).
254    pub timestamp: i64,
255}
256
257impl Tick {
258    /// Construct a new tick, validating finiteness and non-negativity of volume.
259    ///
260    /// # Errors
261    ///
262    /// Returns [`Error::NonFiniteInput`] if `price` or `volume` is NaN or infinite,
263    /// or [`Error::InvalidTick`] for `volume < 0`. (Audit finding R14 — previously
264    /// returned [`Error::InvalidCandle`], which is semantically wrong for a tick.)
265    pub fn new(price: f64, volume: f64, timestamp: i64) -> Result<Self> {
266        if !price.is_finite() || !volume.is_finite() {
267            return Err(Error::NonFiniteInput);
268        }
269        if volume < 0.0 {
270            return Err(Error::InvalidTick {
271                message: "tick volume must be non-negative",
272            });
273        }
274        Ok(Self {
275            price,
276            volume,
277            timestamp,
278        })
279    }
280}
281
282/// [`Candle::all_valid`] over equally long columns, as a dispatched kernel:
283/// compiled into its own function, it vectorizes the same whatever calls it,
284/// where inlined into a caller it did or did not depending on the caller.
285struct AllValid<'a> {
286    open: &'a [f64],
287    high: &'a [f64],
288    low: &'a [f64],
289    close: &'a [f64],
290    volume: &'a [f64],
291    /// The largest magnitude a value may have: `f64::MAX` for finite.
292    bound: f64,
293}
294
295// Inlining into the dispatching function is what compiles the body with its
296// features; see `wickra_simd::Kernel`. `&` rather than `&&` on purpose: every
297// rule is evaluated for every bar, which is what lets the loop vectorize
298// instead of branching.
299#[allow(clippy::inline_always, clippy::needless_bitwise_bool)]
300impl wickra_simd::Kernel for AllValid<'_> {
301    type Output = bool;
302    // The compiler widens the fold to eight lanes where the CPU allows.
303    const WIDE: bool = true;
304
305    #[inline(always)]
306    fn run<S: wickra_simd::Simd>(self, _simd: S) -> bool {
307        const LANES: usize = 8;
308        // Whole blocks as fixed-size arrays: their length is in the type, so
309        // the loop carries no bounds check and vectorizes; indexing the slices
310        // directly kept a check per element, which no reslicing removed.
311        fn blocks(col: &[f64]) -> impl Iterator<Item = &[f64; LANES]> {
312            col.chunks_exact(LANES)
313                .map(|block| <&[f64; LANES]>::try_from(block).expect("a whole block"))
314        }
315        let (open, high, low, close, volume, bound) = (
316            self.open,
317            self.high,
318            self.low,
319            self.close,
320            self.volume,
321            self.bound,
322        );
323        let n = open.len();
324        // `x.abs() <= bound` is false for NaN and the infinities, so with
325        // `f64::MAX` it is `x.is_finite()` as a plain comparison, which
326        // vectorizes where `is_finite` did not. `NaN` fails every comparison,
327        // and an open and close between a low and high within the bound are
328        // within it themselves (and put the high above the low), so only the
329        // two ends and the volume need bounding.
330        let bar = |o: f64, h: f64, l: f64, c: f64, v: f64| {
331            let bounded = (h.abs() <= bound) & (l.abs() <= bound) & (v <= bound);
332            let ordered = (h >= o) & (h >= c) & (l <= o) & (l <= c);
333            bounded & (v >= 0.0) & ordered
334        };
335        let mut lanes = [true; LANES];
336        for ((((o, h), l), c), v) in blocks(open)
337            .zip(blocks(high))
338            .zip(blocks(low))
339            .zip(blocks(close))
340            .zip(blocks(volume))
341        {
342            for (k, lane) in lanes.iter_mut().enumerate() {
343                *lane &= bar(o[k], h[k], l[k], c[k], v[k]);
344            }
345        }
346        let full = n - n % LANES;
347        (full..n).fold(lanes.iter().all(|&ok| ok), |ok, i| {
348            ok & bar(open[i], high[i], low[i], close[i], volume[i])
349        })
350    }
351}
352
353/// [`Candle::all_valid_hlc`]: [`AllValid`] over three columns, the open being the
354/// close and the volume zero.
355struct AllValidHlc<'a> {
356    high: &'a [f64],
357    low: &'a [f64],
358    close: &'a [f64],
359}
360
361// As for `AllValid`.
362#[allow(clippy::inline_always, clippy::needless_bitwise_bool)]
363impl wickra_simd::Kernel for AllValidHlc<'_> {
364    type Output = bool;
365
366    #[inline(always)]
367    fn run<S: wickra_simd::Simd>(self, _simd: S) -> bool {
368        const LANES: usize = 8;
369        fn blocks(col: &[f64]) -> impl Iterator<Item = &[f64; LANES]> {
370            col.chunks_exact(LANES)
371                .map(|block| <&[f64; LANES]>::try_from(block).expect("a whole block"))
372        }
373        let (high, low, close) = (self.high, self.low, self.close);
374        let n = high.len();
375        // As in `AllValid`: a close between a finite low and high is finite.
376        let bar =
377            |h: f64, l: f64, c: f64| (h * 0.0 == 0.0) & (l * 0.0 == 0.0) & (h >= c) & (l <= c);
378        let mut lanes = [true; LANES];
379        for ((h, l), c) in blocks(high).zip(blocks(low)).zip(blocks(close)) {
380            for (k, lane) in lanes.iter_mut().enumerate() {
381                *lane &= bar(h[k], l[k], c[k]);
382            }
383        }
384        let full = n - n % LANES;
385        (full..n).fold(lanes.iter().all(|&ok| ok), |ok, i| {
386            ok & bar(high[i], low[i], close[i])
387        })
388    }
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    #[test]
396    fn all_valid_agrees_with_candle_new_bar_by_bar() {
397        // Every rule is broken once, alone; then the bars an ordering check
398        // alone would let through -- an infinite price bounded by an infinite
399        // high or low, a `NaN` at either end, a `NaN` or negative infinite
400        // volume.
401        let bars: [(f64, f64, f64, f64, f64); 21] = [
402            (10.0, 11.0, 9.0, 10.5, 100.0),
403            (10.0, 10.0, 10.0, 10.0, 0.0),
404            (-0.0, 0.0, -0.0, 0.0, -0.0),
405            (f64::NAN, 11.0, 9.0, 10.0, 1.0),
406            (10.0, f64::INFINITY, 9.0, 10.0, 1.0),
407            (10.0, 11.0, f64::NEG_INFINITY, 10.0, 1.0),
408            (10.0, 11.0, 9.0, f64::NAN, 1.0),
409            (10.0, 11.0, 9.0, 10.0, f64::INFINITY),
410            (10.0, 11.0, 9.0, 10.0, -1.0),
411            (10.0, 9.0, 9.5, 9.2, 1.0),
412            (12.0, 11.0, 9.0, 10.0, 1.0),
413            (10.0, 11.0, 9.0, 8.0, 1.0),
414            (f64::INFINITY, f64::INFINITY, 9.0, 10.0, 1.0),
415            (10.0, f64::INFINITY, 9.0, f64::INFINITY, 1.0),
416            (f64::NEG_INFINITY, 11.0, f64::NEG_INFINITY, 10.0, 1.0),
417            (
418                f64::INFINITY,
419                f64::INFINITY,
420                f64::INFINITY,
421                f64::INFINITY,
422                1.0,
423            ),
424            (
425                f64::NEG_INFINITY,
426                f64::NEG_INFINITY,
427                f64::NEG_INFINITY,
428                f64::NEG_INFINITY,
429                1.0,
430            ),
431            (10.0, f64::NAN, 9.0, 10.0, 1.0),
432            (10.0, 11.0, f64::NAN, 10.0, 1.0),
433            (10.0, 11.0, 9.0, 10.0, f64::NAN),
434            (10.0, 11.0, 9.0, 10.0, f64::NEG_INFINITY),
435        ];
436        for &(o, h, l, c, v) in &bars {
437            let single = Candle::all_valid(&[o], &[h], &[l], &[c], &[v]);
438            assert_eq!(
439                single,
440                Candle::new(o, h, l, c, v, 0).is_ok(),
441                "{o} {h} {l} {c} {v}"
442            );
443        }
444        // The same bars at every position of a 21-bar run of valid ones: every
445        // lane of the vector loop and every slot of its tail.
446        let fine = bars[0];
447        for &(o, h, l, c, v) in &bars {
448            let want = Candle::new(o, h, l, c, v, 0).is_ok();
449            for at in 0..21 {
450                let mut run = [fine; 21];
451                run[at] = (o, h, l, c, v);
452                let column = |pick: fn(&(f64, f64, f64, f64, f64)) -> f64| {
453                    run.iter().map(pick).collect::<Vec<_>>()
454                };
455                let got = Candle::all_valid(
456                    &column(|b| b.0),
457                    &column(|b| b.1),
458                    &column(|b| b.2),
459                    &column(|b| b.3),
460                    &column(|b| b.4),
461                );
462                assert_eq!(got, want, "bar {o} {h} {l} {c} {v} at {at}");
463            }
464        }
465        // A long run of valid bars; one bad bar anywhere fails it.
466        let n = 1_300;
467        let close: Vec<f64> = (0..n).map(|i| 100.0 + f64::from(i % 17)).collect();
468        let high: Vec<f64> = close.iter().map(|c| c + 1.0).collect();
469        let low: Vec<f64> = close.iter().map(|c| c - 1.0).collect();
470        let volume = vec![5.0; close.len()];
471        assert!(Candle::all_valid(&close, &high, &low, &close, &volume));
472        let mut bad = low.clone();
473        bad[1_100] = high[1_100] + 1.0;
474        assert!(!Candle::all_valid(&close, &high, &bad, &close, &volume));
475        assert!(Candle::all_valid(&[], &[], &[], &[], &[]));
476    }
477
478    #[test]
479    fn all_valid_hlc_agrees_with_candle_new_at_every_position() {
480        // (high, low, close); each breaks at most one rule of the candle a
481        // high/low/close series builds, `Candle::new(c, h, l, c, 0.0, _)`,
482        // then the infinite closes an infinite high or low would bound and a
483        // `NaN` at either end.
484        let bars: [(f64, f64, f64); 15] = [
485            (11.0, 9.0, 10.0),
486            (10.0, 10.0, 10.0),
487            (0.0, -0.0, 0.0),
488            (f64::NAN, 9.0, 10.0),
489            (11.0, f64::NEG_INFINITY, 10.0),
490            (11.0, 9.0, f64::INFINITY),
491            (9.0, 9.5, 9.2),
492            (11.0, 9.0, 12.0),
493            (11.0, 9.0, 8.0),
494            (-5.0, -7.0, -6.0),
495            (f64::INFINITY, 9.0, f64::INFINITY),
496            (11.0, f64::NEG_INFINITY, f64::NEG_INFINITY),
497            (f64::INFINITY, f64::INFINITY, f64::INFINITY),
498            (11.0, f64::NAN, 10.0),
499            (11.0, 9.0, f64::NAN),
500        ];
501        let fine = bars[0];
502        for &(h, l, c) in &bars {
503            let want = Candle::new(c, h, l, c, 0.0, 0).is_ok();
504            for at in 0..21 {
505                let mut run = [fine; 21];
506                run[at] = (h, l, c);
507                let high: Vec<f64> = run.iter().map(|b| b.0).collect();
508                let low: Vec<f64> = run.iter().map(|b| b.1).collect();
509                let close: Vec<f64> = run.iter().map(|b| b.2).collect();
510                assert_eq!(
511                    Candle::all_valid_hlc(&high, &low, &close),
512                    want,
513                    "{h} {l} {c} at {at}"
514                );
515            }
516        }
517        assert!(Candle::all_valid_hlc(&[], &[], &[]));
518    }
519
520    #[test]
521    #[should_panic(expected = "every column must be equally long")]
522    fn all_valid_hlc_rejects_mismatched_columns() {
523        let _ = Candle::all_valid_hlc(&[1.0, 2.0], &[1.0], &[1.0, 2.0]);
524    }
525
526    #[test]
527    #[should_panic(expected = "every column must be equally long")]
528    fn all_valid_rejects_ragged_columns() {
529        let _ = Candle::all_valid(&[1.0], &[1.0], &[1.0], &[1.0], &[]);
530    }
531
532    #[test]
533    fn candle_new_accepts_valid_ohlc() {
534        let c = Candle::new(10.0, 11.0, 9.0, 10.5, 100.0, 1).unwrap();
535        assert_eq!(c.open, 10.0);
536        assert_eq!(c.high, 11.0);
537        assert_eq!(c.low, 9.0);
538        assert_eq!(c.close, 10.5);
539        assert_eq!(c.volume, 100.0);
540        assert_eq!(c.timestamp, 1);
541    }
542
543    #[test]
544    fn candle_new_rejects_high_below_low() {
545        let err = Candle::new(10.0, 9.0, 10.0, 10.0, 1.0, 0).unwrap_err();
546        assert!(matches!(err, Error::InvalidCandle { .. }));
547    }
548
549    #[test]
550    fn candle_new_rejects_high_below_close() {
551        let err = Candle::new(10.0, 10.0, 9.0, 11.0, 1.0, 0).unwrap_err();
552        assert!(matches!(err, Error::InvalidCandle { .. }));
553    }
554
555    #[test]
556    fn candle_new_rejects_low_above_open() {
557        let err = Candle::new(10.0, 11.0, 10.5, 10.5, 1.0, 0).unwrap_err();
558        assert!(matches!(err, Error::InvalidCandle { .. }));
559    }
560
561    #[test]
562    fn candle_new_rejects_negative_volume() {
563        let err = Candle::new(10.0, 11.0, 9.0, 10.5, -1.0, 0).unwrap_err();
564        assert!(matches!(err, Error::InvalidCandle { .. }));
565    }
566
567    #[test]
568    fn candle_new_rejects_nan_price() {
569        let err = Candle::new(f64::NAN, 11.0, 9.0, 10.5, 1.0, 0).unwrap_err();
570        assert!(matches!(err, Error::InvalidCandle { .. }));
571    }
572
573    /// Cover the unchecked constructor `Candle::new_unchecked` (lines 86-102).
574    /// Every existing test routes through the validating `Candle::new`, so the
575    /// unchecked path is dead.
576    ///
577    /// The first assertion shows that a valid set of fields round-trips
578    /// verbatim. The second feeds `high < low` (which `Candle::new` would
579    /// reject with `Error::InvalidCandle`) and asserts the unchecked
580    /// constructor still produces the struct as-is — documenting and
581    /// enforcing the API contract that the unchecked variant performs no
582    /// validation and is the caller's responsibility.
583    #[test]
584    fn candle_new_unchecked_preserves_fields_verbatim() {
585        let c = Candle::new_unchecked(1.0, 2.0, 0.5, 1.5, 100.0, 42);
586        assert_eq!(c.open, 1.0);
587        assert_eq!(c.high, 2.0);
588        assert_eq!(c.low, 0.5);
589        assert_eq!(c.close, 1.5);
590        assert_eq!(c.volume, 100.0);
591        assert_eq!(c.timestamp, 42);
592
593        // Skip-validation contract: an OHLC combination that the checked
594        // constructor rejects (high < low) is still built without error.
595        assert!(Candle::new(10.0, 9.0, 10.0, 10.0, 1.0, 0).is_err());
596        let unchecked = Candle::new_unchecked(10.0, 9.0, 10.0, 10.0, 1.0, 0);
597        assert_eq!(unchecked.high, 9.0);
598        assert_eq!(unchecked.low, 10.0);
599    }
600
601    #[test]
602    fn candle_typical_price() {
603        let c = Candle::new(10.0, 12.0, 9.0, 11.0, 1.0, 0).unwrap();
604        assert_eq!(c.typical_price(), (12.0 + 9.0 + 11.0) / 3.0);
605    }
606
607    #[test]
608    fn candle_median_price() {
609        let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
610        assert_eq!(c.median_price(), 10.0);
611    }
612
613    #[test]
614    fn candle_weighted_close() {
615        let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
616        assert_eq!(c.weighted_close(), (12.0 + 8.0 + 22.0) / 4.0);
617    }
618
619    #[test]
620    fn candle_true_range_without_prev() {
621        let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
622        assert_eq!(c.true_range(None), 4.0);
623    }
624
625    #[test]
626    fn candle_true_range_with_gap_up() {
627        // Previous close 6, today's range 8-12: gap covered by |H-prev|=6
628        let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
629        assert_eq!(c.true_range(Some(6.0)), 6.0);
630    }
631
632    #[test]
633    fn candle_true_range_with_gap_down() {
634        // Previous close 14, today's range 8-12: gap covered by |L-prev|=6
635        let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
636        assert_eq!(c.true_range(Some(14.0)), 6.0);
637    }
638
639    #[test]
640    fn tick_new_accepts_valid() {
641        let t = Tick::new(100.5, 0.5, 42).unwrap();
642        assert_eq!(t.price, 100.5);
643        assert_eq!(t.volume, 0.5);
644        assert_eq!(t.timestamp, 42);
645    }
646
647    #[test]
648    fn tick_new_rejects_nan() {
649        assert!(matches!(
650            Tick::new(f64::NAN, 1.0, 0),
651            Err(Error::NonFiniteInput)
652        ));
653    }
654
655    #[test]
656    fn tick_new_rejects_inf() {
657        assert!(matches!(
658            Tick::new(f64::INFINITY, 1.0, 0),
659            Err(Error::NonFiniteInput)
660        ));
661    }
662
663    #[test]
664    fn tick_new_rejects_negative_volume() {
665        // Audit R14: the variant is `InvalidTick`, not `InvalidCandle` — a tick
666        // is not a candle, and downstream pipelines should be able to match on
667        // the correct semantic.
668        let err = Tick::new(100.0, -1.0, 0).unwrap_err();
669        assert!(matches!(err, Error::InvalidTick { .. }));
670        assert!(
671            err.to_string().contains("tick volume"),
672            "expected the InvalidTick message in the formatted error, got {err}"
673        );
674    }
675}