Skip to main content

wickra_core/indicators/
mid_price.rs

1//! Midpoint Price (MIDPRICE) over a rolling window of high/low extremes.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9/// Midpoint Price (`MIDPRICE`): the average of the highest high and the lowest
10/// low over the last `period` candles.
11///
12/// ```text
13/// MIDPRICE = (highest(high, period) + lowest(low, period)) / 2
14/// ```
15///
16/// Unlike [`MedianPrice`](crate::MedianPrice), which averages a single bar's own
17/// high and low, `MIDPRICE` averages the *window* extremes — it is numerically
18/// the centre line of [`Donchian`](crate::Donchian) channels, exposed as a
19/// standalone scalar for TA-Lib parity. The first value is emitted once `period`
20/// candles have been seen.
21///
22/// # Example
23///
24/// ```
25/// use wickra_core::{Candle, Indicator, MidPrice};
26///
27/// let mut indicator = MidPrice::new(5).unwrap();
28/// let mut last = None;
29/// for i in 0..40 {
30///     let base = 100.0 + f64::from(i);
31///     let candle =
32///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
33///     last = indicator.update(candle);
34/// }
35/// assert!(last.is_some());
36/// ```
37#[derive(Debug, Clone)]
38pub struct MidPrice {
39    period: usize,
40    candles: VecDeque<Candle>,
41}
42
43impl MidPrice {
44    /// # Errors
45    /// Returns [`Error::PeriodZero`] if `period == 0`.
46    pub fn new(period: usize) -> Result<Self> {
47        if period == 0 {
48            return Err(Error::PeriodZero);
49        }
50        if period > crate::error::MAX_PERIOD {
51            return Err(Error::InvalidPeriod {
52                message: crate::error::PERIOD_ABOVE_MAX,
53            });
54        }
55        Ok(Self {
56            period,
57            candles: VecDeque::with_capacity(period),
58        })
59    }
60
61    /// Configured period.
62    pub const fn period(&self) -> usize {
63        self.period
64    }
65}
66
67impl Indicator for MidPrice {
68    type Input = Candle;
69    type Output = f64;
70
71    #[inline]
72    fn update(&mut self, candle: Candle) -> Option<f64> {
73        if self.candles.len() == self.period {
74            self.candles.pop_front();
75        }
76        self.candles.push_back(candle);
77        if self.candles.len() < self.period {
78            return None;
79        }
80        let highest = self
81            .candles
82            .iter()
83            .map(|c| c.high)
84            .fold(f64::NEG_INFINITY, f64::max);
85        let lowest = self
86            .candles
87            .iter()
88            .map(|c| c.low)
89            .fold(f64::INFINITY, f64::min);
90        Some(f64::midpoint(highest, lowest))
91    }
92
93    fn reset(&mut self) {
94        self.candles.clear();
95    }
96
97    #[inline]
98    fn warmup_period(&self) -> usize {
99        self.period
100    }
101
102    #[inline]
103    fn is_ready(&self) -> bool {
104        self.candles.len() == self.period
105    }
106
107    #[inline]
108    fn name(&self) -> &'static str {
109        "MIDPRICE"
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use crate::traits::BatchExt;
117    use approx::assert_relative_eq;
118
119    fn c(h: f64, l: f64, cl: f64) -> Candle {
120        Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
121    }
122
123    #[test]
124    fn rejects_zero_period() {
125        assert!(matches!(MidPrice::new(0), Err(Error::PeriodZero)));
126    }
127
128    #[test]
129    fn accessors_report_config() {
130        let mp = MidPrice::new(7).unwrap();
131        assert_eq!(mp.period(), 7);
132        assert_eq!(mp.name(), "MIDPRICE");
133        assert_eq!(mp.warmup_period(), 7);
134        assert!(!mp.is_ready());
135    }
136
137    #[test]
138    fn averages_window_extremes() {
139        // Window highs {12, 14, 16}, lows {8, 9, 10}: highest 16, lowest 8 -> 12.
140        let candles = [c(12.0, 8.0, 10.0), c(14.0, 9.0, 11.0), c(16.0, 10.0, 12.0)];
141        let mut mp = MidPrice::new(3).unwrap();
142        let out: Vec<Option<f64>> = mp.batch(&candles);
143        assert_eq!(out[0], None);
144        assert_eq!(out[1], None);
145        assert_relative_eq!(out[2].unwrap(), 12.0, epsilon = 1e-12);
146        assert!(mp.is_ready());
147    }
148
149    #[test]
150    fn window_slides_and_drops_old_extremes() {
151        // After the spike leaves the window the midpoint falls back.
152        let candles = [
153            c(30.0, 10.0, 20.0),
154            c(12.0, 8.0, 10.0),
155            c(14.0, 9.0, 11.0),
156            c(16.0, 10.0, 12.0),
157        ];
158        let mut mp = MidPrice::new(3).unwrap();
159        let out: Vec<Option<f64>> = mp.batch(&candles);
160        // Last window {12,14,16}/{8,9,10}: (16 + 8) / 2 = 12.
161        assert_relative_eq!(out[3].unwrap(), 12.0, epsilon = 1e-12);
162    }
163
164    #[test]
165    fn reset_clears_state() {
166        let candles = [c(12.0, 8.0, 10.0), c(14.0, 9.0, 11.0), c(16.0, 10.0, 12.0)];
167        let mut mp = MidPrice::new(3).unwrap();
168        let _ = mp.batch(&candles);
169        assert!(mp.is_ready());
170        mp.reset();
171        assert!(!mp.is_ready());
172        assert_eq!(mp.update(candles[0]), None);
173    }
174}