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        Ok(Self {
51            period,
52            candles: VecDeque::with_capacity(period),
53        })
54    }
55
56    /// Configured period.
57    pub const fn period(&self) -> usize {
58        self.period
59    }
60}
61
62impl Indicator for MidPrice {
63    type Input = Candle;
64    type Output = f64;
65
66    fn update(&mut self, candle: Candle) -> Option<f64> {
67        if self.candles.len() == self.period {
68            self.candles.pop_front();
69        }
70        self.candles.push_back(candle);
71        if self.candles.len() < self.period {
72            return None;
73        }
74        let highest = self
75            .candles
76            .iter()
77            .map(|c| c.high)
78            .fold(f64::NEG_INFINITY, f64::max);
79        let lowest = self
80            .candles
81            .iter()
82            .map(|c| c.low)
83            .fold(f64::INFINITY, f64::min);
84        Some(f64::midpoint(highest, lowest))
85    }
86
87    fn reset(&mut self) {
88        self.candles.clear();
89    }
90
91    fn warmup_period(&self) -> usize {
92        self.period
93    }
94
95    fn is_ready(&self) -> bool {
96        self.candles.len() == self.period
97    }
98
99    fn name(&self) -> &'static str {
100        "MIDPRICE"
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use crate::traits::BatchExt;
108    use approx::assert_relative_eq;
109
110    fn c(h: f64, l: f64, cl: f64) -> Candle {
111        Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
112    }
113
114    #[test]
115    fn rejects_zero_period() {
116        assert!(matches!(MidPrice::new(0), Err(Error::PeriodZero)));
117    }
118
119    #[test]
120    fn accessors_report_config() {
121        let mp = MidPrice::new(7).unwrap();
122        assert_eq!(mp.period(), 7);
123        assert_eq!(mp.name(), "MIDPRICE");
124        assert_eq!(mp.warmup_period(), 7);
125        assert!(!mp.is_ready());
126    }
127
128    #[test]
129    fn averages_window_extremes() {
130        // Window highs {12, 14, 16}, lows {8, 9, 10}: highest 16, lowest 8 -> 12.
131        let candles = [c(12.0, 8.0, 10.0), c(14.0, 9.0, 11.0), c(16.0, 10.0, 12.0)];
132        let mut mp = MidPrice::new(3).unwrap();
133        let out: Vec<Option<f64>> = mp.batch(&candles);
134        assert_eq!(out[0], None);
135        assert_eq!(out[1], None);
136        assert_relative_eq!(out[2].unwrap(), 12.0, epsilon = 1e-12);
137        assert!(mp.is_ready());
138    }
139
140    #[test]
141    fn window_slides_and_drops_old_extremes() {
142        // After the spike leaves the window the midpoint falls back.
143        let candles = [
144            c(30.0, 10.0, 20.0),
145            c(12.0, 8.0, 10.0),
146            c(14.0, 9.0, 11.0),
147            c(16.0, 10.0, 12.0),
148        ];
149        let mut mp = MidPrice::new(3).unwrap();
150        let out: Vec<Option<f64>> = mp.batch(&candles);
151        // Last window {12,14,16}/{8,9,10}: (16 + 8) / 2 = 12.
152        assert_relative_eq!(out[3].unwrap(), 12.0, epsilon = 1e-12);
153    }
154
155    #[test]
156    fn reset_clears_state() {
157        let candles = [c(12.0, 8.0, 10.0), c(14.0, 9.0, 11.0), c(16.0, 10.0, 12.0)];
158        let mut mp = MidPrice::new(3).unwrap();
159        let _ = mp.batch(&candles);
160        assert!(mp.is_ready());
161        mp.reset();
162        assert!(!mp.is_ready());
163        assert_eq!(mp.update(candles[0]), None);
164    }
165}