//! OHLCV value types: candles and ticks. use crate::error::{Error, Result}; /// A single OHLCV bar. /// /// Timestamps are unitless `i64` values so callers can use whatever epoch resolution /// they prefer (milliseconds, microseconds, seconds…). Wickra never inspects them /// numerically beyond passing them through. #[derive(Debug, Clone, Copy, PartialEq)] pub struct Candle { /// Bar open price. pub open: f64, /// Bar high price. pub high: f64, /// Bar low price. pub low: f64, /// Bar close price. pub close: f64, /// Bar volume. pub volume: f64, /// Bar timestamp (caller-defined epoch / resolution). pub timestamp: i64, } impl Candle { /// Construct a new candle, validating the OHLC relationships and finiteness. /// /// # Errors /// /// Returns [`Error::InvalidCandle`] if any of these invariants are violated: /// - `high >= max(open, close, low)` /// - `low <= min(open, close, high)` /// - all of `open`, `high`, `low`, `close`, `volume` are finite /// - `volume >= 0` pub fn new( open: f64, high: f64, low: f64, close: f64, volume: f64, timestamp: i64, ) -> Result { 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, }) } /// Construct a candle without validation. The caller asserts that all OHLC /// invariants hold and that no field is NaN or infinite. 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, } } /// The typical price `(high + low + close) / 3`. Used by CCI, MFI, VWAP, etc. #[inline] pub fn typical_price(&self) -> f64 { (self.high + self.low + self.close) / 3.0 } /// The mid price `(high + low) / 2`. #[inline] pub fn median_price(&self) -> f64 { (self.high + self.low) / 2.0 } /// The weighted close `(high + low + 2*close) / 4`. #[inline] pub fn weighted_close(&self) -> f64 { (self.high + self.low + 2.0 * self.close) / 4.0 } /// True range of this candle relative to a previous close: `max(H-L, |H-prev|, |L-prev|)`. /// If no previous close is supplied, falls back to `high - low`. #[inline] pub fn true_range(&self, prev_close: Option) -> 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, } } } /// A single trade tick. #[derive(Debug, Clone, Copy, PartialEq)] pub struct Tick { /// Trade price. pub price: f64, /// Trade size. pub volume: f64, /// Trade timestamp (caller-defined epoch / resolution). pub timestamp: i64, } impl Tick { /// Construct a new tick, validating finiteness and non-negativity of volume. /// /// # Errors /// /// Returns [`Error::NonFiniteInput`] if `price` or `volume` is NaN or infinite, /// or [`Error::InvalidCandle`] for `volume < 0`. pub fn new(price: f64, volume: f64, timestamp: i64) -> Result { if !price.is_finite() || !volume.is_finite() { return Err(Error::NonFiniteInput); } if volume < 0.0 { return Err(Error::InvalidCandle { message: "tick volume must be non-negative", }); } Ok(Self { price, volume, timestamp, }) } } #[cfg(test)] mod tests { use super::*; #[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_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() { // Previous close 6, today's range 8-12: gap covered by |H-prev|=6 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() { // Previous close 14, today's range 8-12: gap covered by |L-prev|=6 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::InvalidCandle { .. })); } }