1use crate::error::{Error, Result};
4
5#[derive(Debug, Clone, Copy, PartialEq)]
25#[non_exhaustive]
26pub struct Candle {
27 pub open: f64,
29 pub high: f64,
31 pub low: f64,
33 pub close: f64,
35 pub volume: f64,
37 pub timestamp: i64,
39}
40
41impl Candle {
42 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 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 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 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 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 #[inline]
194 pub fn typical_price(&self) -> f64 {
195 (self.high + self.low + self.close) / 3.0
196 }
197
198 #[inline]
200 pub fn median_price(&self) -> f64 {
201 f64::midpoint(self.high, self.low)
202 }
203
204 #[inline]
206 pub fn weighted_close(&self) -> f64 {
207 (self.high + self.low + 2.0 * self.close) / 4.0
208 }
209
210 #[inline]
212 pub fn avg_price(&self) -> f64 {
213 (self.open + self.high + self.low + self.close) / 4.0
214 }
215
216 #[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#[derive(Debug, Clone, Copy, PartialEq)]
247#[non_exhaustive]
248pub struct Tick {
249 pub price: f64,
251 pub volume: f64,
253 pub timestamp: i64,
255}
256
257impl Tick {
258 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
282struct AllValid<'a> {
286 open: &'a [f64],
287 high: &'a [f64],
288 low: &'a [f64],
289 close: &'a [f64],
290 volume: &'a [f64],
291 bound: f64,
293}
294
295#[allow(clippy::inline_always, clippy::needless_bitwise_bool)]
300impl wickra_simd::Kernel for AllValid<'_> {
301 type Output = bool;
302 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 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 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
353struct AllValidHlc<'a> {
356 high: &'a [f64],
357 low: &'a [f64],
358 close: &'a [f64],
359}
360
361#[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 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 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 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 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 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 #[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 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 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 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 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}