use crate::error::{Error, Result};
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub struct Candle {
pub open: f64,
pub high: f64,
pub low: f64,
pub close: f64,
pub volume: f64,
pub timestamp: i64,
}
impl Candle {
pub fn new(
open: f64,
high: f64,
low: f64,
close: f64,
volume: f64,
timestamp: i64,
) -> Result<Self> {
if !(open.is_finite() && high.is_finite() && low.is_finite() && close.is_finite()) {
return Err(Error::InvalidCandle {
message: "open, high, low, close must all be finite",
});
}
if !volume.is_finite() {
return Err(Error::InvalidCandle {
message: "volume must be finite",
});
}
if volume < 0.0 {
return Err(Error::InvalidCandle {
message: "volume must be non-negative",
});
}
if high < low {
return Err(Error::InvalidCandle {
message: "high must be >= low",
});
}
if high < open || high < close {
return Err(Error::InvalidCandle {
message: "high must be >= open and >= close",
});
}
if low > open || low > close {
return Err(Error::InvalidCandle {
message: "low must be <= open and <= close",
});
}
Ok(Self {
open,
high,
low,
close,
volume,
timestamp,
})
}
pub const fn new_unchecked(
open: f64,
high: f64,
low: f64,
close: f64,
volume: f64,
timestamp: i64,
) -> Self {
Self {
open,
high,
low,
close,
volume,
timestamp,
}
}
pub fn all_valid(
open: &[f64],
high: &[f64],
low: &[f64],
close: &[f64],
volume: &[f64],
) -> bool {
Self::all_valid_within(open, high, low, close, volume, f64::MAX)
}
pub(crate) fn all_valid_within(
open: &[f64],
high: &[f64],
low: &[f64],
close: &[f64],
volume: &[f64],
bound: f64,
) -> bool {
let n = open.len();
assert!(
high.len() == n && low.len() == n && close.len() == n && volume.len() == n,
"every column must be equally long"
);
wickra_simd::dispatch(AllValid {
open,
high: &high[..n],
low: &low[..n],
close: &close[..n],
volume: &volume[..n],
bound,
})
}
pub fn all_valid_hlc(high: &[f64], low: &[f64], close: &[f64]) -> bool {
let n = high.len();
assert!(
low.len() == n && close.len() == n,
"every column must be equally long"
);
wickra_simd::dispatch(AllValidHlc {
high,
low: &low[..n],
close: &close[..n],
})
}
#[inline]
pub fn typical_price(&self) -> f64 {
(self.high + self.low + self.close) / 3.0
}
#[inline]
pub fn median_price(&self) -> f64 {
f64::midpoint(self.high, self.low)
}
#[inline]
pub fn weighted_close(&self) -> f64 {
(self.high + self.low + 2.0 * self.close) / 4.0
}
#[inline]
pub fn avg_price(&self) -> f64 {
(self.open + self.high + self.low + self.close) / 4.0
}
#[inline]
pub fn true_range(&self, prev_close: Option<f64>) -> f64 {
let hl = self.high - self.low;
match prev_close {
Some(prev) => {
let hp = (self.high - prev).abs();
let lp = (self.low - prev).abs();
hl.max(hp).max(lp)
}
None => hl,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub struct Tick {
pub price: f64,
pub volume: f64,
pub timestamp: i64,
}
impl Tick {
pub fn new(price: f64, volume: f64, timestamp: i64) -> Result<Self> {
if !price.is_finite() || !volume.is_finite() {
return Err(Error::NonFiniteInput);
}
if volume < 0.0 {
return Err(Error::InvalidTick {
message: "tick volume must be non-negative",
});
}
Ok(Self {
price,
volume,
timestamp,
})
}
}
struct AllValid<'a> {
open: &'a [f64],
high: &'a [f64],
low: &'a [f64],
close: &'a [f64],
volume: &'a [f64],
bound: f64,
}
#[allow(clippy::inline_always, clippy::needless_bitwise_bool)]
impl wickra_simd::Kernel for AllValid<'_> {
type Output = bool;
const WIDE: bool = true;
#[inline(always)]
fn run<S: wickra_simd::Simd>(self, _simd: S) -> bool {
const LANES: usize = 8;
fn blocks(col: &[f64]) -> impl Iterator<Item = &[f64; LANES]> {
col.chunks_exact(LANES)
.map(|block| <&[f64; LANES]>::try_from(block).expect("a whole block"))
}
let (open, high, low, close, volume, bound) = (
self.open,
self.high,
self.low,
self.close,
self.volume,
self.bound,
);
let n = open.len();
let bar = |o: f64, h: f64, l: f64, c: f64, v: f64| {
let bounded = (h.abs() <= bound) & (l.abs() <= bound) & (v <= bound);
let ordered = (h >= o) & (h >= c) & (l <= o) & (l <= c);
bounded & (v >= 0.0) & ordered
};
let mut lanes = [true; LANES];
for ((((o, h), l), c), v) in blocks(open)
.zip(blocks(high))
.zip(blocks(low))
.zip(blocks(close))
.zip(blocks(volume))
{
for (k, lane) in lanes.iter_mut().enumerate() {
*lane &= bar(o[k], h[k], l[k], c[k], v[k]);
}
}
let full = n - n % LANES;
(full..n).fold(lanes.iter().all(|&ok| ok), |ok, i| {
ok & bar(open[i], high[i], low[i], close[i], volume[i])
})
}
}
struct AllValidHlc<'a> {
high: &'a [f64],
low: &'a [f64],
close: &'a [f64],
}
#[allow(clippy::inline_always, clippy::needless_bitwise_bool)]
impl wickra_simd::Kernel for AllValidHlc<'_> {
type Output = bool;
#[inline(always)]
fn run<S: wickra_simd::Simd>(self, _simd: S) -> bool {
const LANES: usize = 8;
fn blocks(col: &[f64]) -> impl Iterator<Item = &[f64; LANES]> {
col.chunks_exact(LANES)
.map(|block| <&[f64; LANES]>::try_from(block).expect("a whole block"))
}
let (high, low, close) = (self.high, self.low, self.close);
let n = high.len();
let bar =
|h: f64, l: f64, c: f64| (h * 0.0 == 0.0) & (l * 0.0 == 0.0) & (h >= c) & (l <= c);
let mut lanes = [true; LANES];
for ((h, l), c) in blocks(high).zip(blocks(low)).zip(blocks(close)) {
for (k, lane) in lanes.iter_mut().enumerate() {
*lane &= bar(h[k], l[k], c[k]);
}
}
let full = n - n % LANES;
(full..n).fold(lanes.iter().all(|&ok| ok), |ok, i| {
ok & bar(high[i], low[i], close[i])
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_valid_agrees_with_candle_new_bar_by_bar() {
let bars: [(f64, f64, f64, f64, f64); 21] = [
(10.0, 11.0, 9.0, 10.5, 100.0),
(10.0, 10.0, 10.0, 10.0, 0.0),
(-0.0, 0.0, -0.0, 0.0, -0.0),
(f64::NAN, 11.0, 9.0, 10.0, 1.0),
(10.0, f64::INFINITY, 9.0, 10.0, 1.0),
(10.0, 11.0, f64::NEG_INFINITY, 10.0, 1.0),
(10.0, 11.0, 9.0, f64::NAN, 1.0),
(10.0, 11.0, 9.0, 10.0, f64::INFINITY),
(10.0, 11.0, 9.0, 10.0, -1.0),
(10.0, 9.0, 9.5, 9.2, 1.0),
(12.0, 11.0, 9.0, 10.0, 1.0),
(10.0, 11.0, 9.0, 8.0, 1.0),
(f64::INFINITY, f64::INFINITY, 9.0, 10.0, 1.0),
(10.0, f64::INFINITY, 9.0, f64::INFINITY, 1.0),
(f64::NEG_INFINITY, 11.0, f64::NEG_INFINITY, 10.0, 1.0),
(
f64::INFINITY,
f64::INFINITY,
f64::INFINITY,
f64::INFINITY,
1.0,
),
(
f64::NEG_INFINITY,
f64::NEG_INFINITY,
f64::NEG_INFINITY,
f64::NEG_INFINITY,
1.0,
),
(10.0, f64::NAN, 9.0, 10.0, 1.0),
(10.0, 11.0, f64::NAN, 10.0, 1.0),
(10.0, 11.0, 9.0, 10.0, f64::NAN),
(10.0, 11.0, 9.0, 10.0, f64::NEG_INFINITY),
];
for &(o, h, l, c, v) in &bars {
let single = Candle::all_valid(&[o], &[h], &[l], &[c], &[v]);
assert_eq!(
single,
Candle::new(o, h, l, c, v, 0).is_ok(),
"{o} {h} {l} {c} {v}"
);
}
let fine = bars[0];
for &(o, h, l, c, v) in &bars {
let want = Candle::new(o, h, l, c, v, 0).is_ok();
for at in 0..21 {
let mut run = [fine; 21];
run[at] = (o, h, l, c, v);
let column = |pick: fn(&(f64, f64, f64, f64, f64)) -> f64| {
run.iter().map(pick).collect::<Vec<_>>()
};
let got = Candle::all_valid(
&column(|b| b.0),
&column(|b| b.1),
&column(|b| b.2),
&column(|b| b.3),
&column(|b| b.4),
);
assert_eq!(got, want, "bar {o} {h} {l} {c} {v} at {at}");
}
}
let n = 1_300;
let close: Vec<f64> = (0..n).map(|i| 100.0 + f64::from(i % 17)).collect();
let high: Vec<f64> = close.iter().map(|c| c + 1.0).collect();
let low: Vec<f64> = close.iter().map(|c| c - 1.0).collect();
let volume = vec![5.0; close.len()];
assert!(Candle::all_valid(&close, &high, &low, &close, &volume));
let mut bad = low.clone();
bad[1_100] = high[1_100] + 1.0;
assert!(!Candle::all_valid(&close, &high, &bad, &close, &volume));
assert!(Candle::all_valid(&[], &[], &[], &[], &[]));
}
#[test]
fn all_valid_hlc_agrees_with_candle_new_at_every_position() {
let bars: [(f64, f64, f64); 15] = [
(11.0, 9.0, 10.0),
(10.0, 10.0, 10.0),
(0.0, -0.0, 0.0),
(f64::NAN, 9.0, 10.0),
(11.0, f64::NEG_INFINITY, 10.0),
(11.0, 9.0, f64::INFINITY),
(9.0, 9.5, 9.2),
(11.0, 9.0, 12.0),
(11.0, 9.0, 8.0),
(-5.0, -7.0, -6.0),
(f64::INFINITY, 9.0, f64::INFINITY),
(11.0, f64::NEG_INFINITY, f64::NEG_INFINITY),
(f64::INFINITY, f64::INFINITY, f64::INFINITY),
(11.0, f64::NAN, 10.0),
(11.0, 9.0, f64::NAN),
];
let fine = bars[0];
for &(h, l, c) in &bars {
let want = Candle::new(c, h, l, c, 0.0, 0).is_ok();
for at in 0..21 {
let mut run = [fine; 21];
run[at] = (h, l, c);
let high: Vec<f64> = run.iter().map(|b| b.0).collect();
let low: Vec<f64> = run.iter().map(|b| b.1).collect();
let close: Vec<f64> = run.iter().map(|b| b.2).collect();
assert_eq!(
Candle::all_valid_hlc(&high, &low, &close),
want,
"{h} {l} {c} at {at}"
);
}
}
assert!(Candle::all_valid_hlc(&[], &[], &[]));
}
#[test]
#[should_panic(expected = "every column must be equally long")]
fn all_valid_hlc_rejects_mismatched_columns() {
let _ = Candle::all_valid_hlc(&[1.0, 2.0], &[1.0], &[1.0, 2.0]);
}
#[test]
#[should_panic(expected = "every column must be equally long")]
fn all_valid_rejects_ragged_columns() {
let _ = Candle::all_valid(&[1.0], &[1.0], &[1.0], &[1.0], &[]);
}
#[test]
fn candle_new_accepts_valid_ohlc() {
let c = Candle::new(10.0, 11.0, 9.0, 10.5, 100.0, 1).unwrap();
assert_eq!(c.open, 10.0);
assert_eq!(c.high, 11.0);
assert_eq!(c.low, 9.0);
assert_eq!(c.close, 10.5);
assert_eq!(c.volume, 100.0);
assert_eq!(c.timestamp, 1);
}
#[test]
fn candle_new_rejects_high_below_low() {
let err = Candle::new(10.0, 9.0, 10.0, 10.0, 1.0, 0).unwrap_err();
assert!(matches!(err, Error::InvalidCandle { .. }));
}
#[test]
fn candle_new_rejects_high_below_close() {
let err = Candle::new(10.0, 10.0, 9.0, 11.0, 1.0, 0).unwrap_err();
assert!(matches!(err, Error::InvalidCandle { .. }));
}
#[test]
fn candle_new_rejects_low_above_open() {
let err = Candle::new(10.0, 11.0, 10.5, 10.5, 1.0, 0).unwrap_err();
assert!(matches!(err, Error::InvalidCandle { .. }));
}
#[test]
fn candle_new_rejects_negative_volume() {
let err = Candle::new(10.0, 11.0, 9.0, 10.5, -1.0, 0).unwrap_err();
assert!(matches!(err, Error::InvalidCandle { .. }));
}
#[test]
fn candle_new_rejects_nan_price() {
let err = Candle::new(f64::NAN, 11.0, 9.0, 10.5, 1.0, 0).unwrap_err();
assert!(matches!(err, Error::InvalidCandle { .. }));
}
#[test]
fn candle_new_unchecked_preserves_fields_verbatim() {
let c = Candle::new_unchecked(1.0, 2.0, 0.5, 1.5, 100.0, 42);
assert_eq!(c.open, 1.0);
assert_eq!(c.high, 2.0);
assert_eq!(c.low, 0.5);
assert_eq!(c.close, 1.5);
assert_eq!(c.volume, 100.0);
assert_eq!(c.timestamp, 42);
assert!(Candle::new(10.0, 9.0, 10.0, 10.0, 1.0, 0).is_err());
let unchecked = Candle::new_unchecked(10.0, 9.0, 10.0, 10.0, 1.0, 0);
assert_eq!(unchecked.high, 9.0);
assert_eq!(unchecked.low, 10.0);
}
#[test]
fn candle_typical_price() {
let c = Candle::new(10.0, 12.0, 9.0, 11.0, 1.0, 0).unwrap();
assert_eq!(c.typical_price(), (12.0 + 9.0 + 11.0) / 3.0);
}
#[test]
fn candle_median_price() {
let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
assert_eq!(c.median_price(), 10.0);
}
#[test]
fn candle_weighted_close() {
let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
assert_eq!(c.weighted_close(), (12.0 + 8.0 + 22.0) / 4.0);
}
#[test]
fn candle_true_range_without_prev() {
let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
assert_eq!(c.true_range(None), 4.0);
}
#[test]
fn candle_true_range_with_gap_up() {
let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
assert_eq!(c.true_range(Some(6.0)), 6.0);
}
#[test]
fn candle_true_range_with_gap_down() {
let c = Candle::new(10.0, 12.0, 8.0, 11.0, 1.0, 0).unwrap();
assert_eq!(c.true_range(Some(14.0)), 6.0);
}
#[test]
fn tick_new_accepts_valid() {
let t = Tick::new(100.5, 0.5, 42).unwrap();
assert_eq!(t.price, 100.5);
assert_eq!(t.volume, 0.5);
assert_eq!(t.timestamp, 42);
}
#[test]
fn tick_new_rejects_nan() {
assert!(matches!(
Tick::new(f64::NAN, 1.0, 0),
Err(Error::NonFiniteInput)
));
}
#[test]
fn tick_new_rejects_inf() {
assert!(matches!(
Tick::new(f64::INFINITY, 1.0, 0),
Err(Error::NonFiniteInput)
));
}
#[test]
fn tick_new_rejects_negative_volume() {
let err = Tick::new(100.0, -1.0, 0).unwrap_err();
assert!(matches!(err, Error::InvalidTick { .. }));
assert!(
err.to_string().contains("tick volume"),
"expected the InvalidTick message in the formatted error, got {err}"
);
}
}