Skip to main content

wickra_core/indicators/
mass_index.rs

1//! Mass Index.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9use super::Ema;
10
11/// Mass Index — Donald Dorsey's range-expansion indicator.
12///
13/// The Mass Index watches the high–low range, not direction. It smooths the
14/// range with an EMA, smooths that again, takes the ratio of the two, and sums
15/// the ratio over a window:
16///
17/// ```text
18/// range_t  = high_t − low_t
19/// ratio_t  = EMA(range, ema_period) / EMA(EMA(range, ema_period), ema_period)
20/// MassIndex = Σ ratio over sum_period
21/// ```
22///
23/// When the range widens, the single EMA pulls ahead of the double EMA, the
24/// ratio rises above `1`, and the sum climbs. Dorsey's "reversal bulge" is the
25/// Mass Index rising above `27` and then falling back below `26.5` — a sign
26/// that a range expansion is about to resolve into a trend reversal. With the
27/// conventional `(ema_period = 9, sum_period = 25)` a flat-range market sits at
28/// `25`.
29///
30/// # Example
31///
32/// ```
33/// use wickra_core::{Candle, Indicator, MassIndex};
34///
35/// let mut indicator = MassIndex::new(9, 25).unwrap();
36/// let mut last = None;
37/// for i in 0..80 {
38///     let base = 100.0 + i as f64;
39///     let candle =
40///         Candle::new(base, base + 2.0, base - 2.0, base, 10.0, i64::from(i)).unwrap();
41///     last = indicator.update(candle);
42/// }
43/// assert!(last.is_some());
44/// ```
45#[derive(Debug, Clone)]
46pub struct MassIndex {
47    ema_period: usize,
48    sum_period: usize,
49    ema1: Ema,
50    ema2: Ema,
51    /// Rolling window of the last `sum_period` EMA ratios.
52    window: VecDeque<f64>,
53    sum: f64,
54    last: Option<f64>,
55}
56
57impl MassIndex {
58    /// Construct a new Mass Index with the EMA smoothing period and the sum
59    /// window length.
60    ///
61    /// # Errors
62    ///
63    /// Returns [`Error::PeriodZero`] if either period is `0`.
64    pub fn new(ema_period: usize, sum_period: usize) -> Result<Self> {
65        if ema_period == 0 || sum_period == 0 {
66            return Err(Error::PeriodZero);
67        }
68        Ok(Self {
69            ema_period,
70            sum_period,
71            ema1: Ema::new(ema_period)?,
72            ema2: Ema::new(ema_period)?,
73            window: VecDeque::with_capacity(sum_period),
74            sum: 0.0,
75            last: None,
76        })
77    }
78
79    /// The `(ema_period, sum_period)` pair.
80    pub const fn periods(&self) -> (usize, usize) {
81        (self.ema_period, self.sum_period)
82    }
83
84    /// Current value if available.
85    pub const fn value(&self) -> Option<f64> {
86        self.last
87    }
88}
89
90impl Indicator for MassIndex {
91    type Input = Candle;
92    type Output = f64;
93
94    #[inline]
95    fn update(&mut self, candle: Candle) -> Option<f64> {
96        let range = candle.high - candle.low;
97        let single = self.ema1.update(range)?;
98        let double = self.ema2.update(single)?;
99        let ratio = if double == 0.0 {
100            // A zero-range market: no expansion, neutral ratio.
101            1.0
102        } else {
103            single / double
104        };
105        if self.window.len() == self.sum_period {
106            self.sum -= self.window.pop_front().expect("window is non-empty");
107        }
108        self.window.push_back(ratio);
109        self.sum += ratio;
110        if self.window.len() < self.sum_period {
111            return None;
112        }
113        self.last = Some(self.sum);
114        Some(self.sum)
115    }
116
117    fn reset(&mut self) {
118        self.ema1.reset();
119        self.ema2.reset();
120        self.window.clear();
121        self.sum = 0.0;
122        self.last = None;
123    }
124
125    #[inline]
126    fn warmup_period(&self) -> usize {
127        // ema1 seeds at `ema_period`, ema2 at `2·ema_period − 1`, then the sum
128        // window needs `sum_period` ratios.
129        2 * self.ema_period + self.sum_period - 2
130    }
131
132    #[inline]
133    fn is_ready(&self) -> bool {
134        self.last.is_some()
135    }
136
137    #[inline]
138    fn name(&self) -> &'static str {
139        "MassIndex"
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use crate::traits::BatchExt;
147    use approx::assert_relative_eq;
148
149    /// A candle with a fixed high–low range `span` centred on `mid`.
150    fn candle(mid: f64, span: f64, ts: i64) -> Candle {
151        Candle::new(mid, mid + span / 2.0, mid - span / 2.0, mid, 1.0, ts).unwrap()
152    }
153
154    #[test]
155    fn new_rejects_zero_period() {
156        assert!(matches!(MassIndex::new(0, 25), Err(Error::PeriodZero)));
157        assert!(matches!(MassIndex::new(9, 0), Err(Error::PeriodZero)));
158    }
159
160    /// Cover the const accessors `periods` / `value` (80-87) and the
161    /// Indicator-impl `name` body (134-136). `warmup_period` is already
162    /// covered by `warmup_period_formula`.
163    #[test]
164    fn accessors_and_metadata() {
165        let mut mi = MassIndex::new(9, 25).unwrap();
166        assert_eq!(mi.periods(), (9, 25));
167        assert_eq!(mi.name(), "MassIndex");
168        assert_eq!(mi.value(), None);
169        for i in 0..mi.warmup_period() {
170            mi.update(candle(100.0, 2.0, i64::try_from(i).unwrap()));
171        }
172        assert!(mi.value().is_some());
173    }
174
175    #[test]
176    fn warmup_period_formula() {
177        let mi = MassIndex::new(9, 25).unwrap();
178        assert_eq!(mi.warmup_period(), 2 * 9 + 25 - 2);
179    }
180
181    #[test]
182    fn first_emission_at_warmup_period() {
183        let mut mi = MassIndex::new(3, 4).unwrap();
184        let warmup = mi.warmup_period(); // 2*3 + 4 - 2 = 8
185        assert_eq!(warmup, 8);
186        let candles: Vec<Candle> = (0..20).map(|i| candle(100.0 + i as f64, 2.0, i)).collect();
187        let out = mi.batch(&candles);
188        for v in out.iter().take(warmup - 1) {
189            assert!(v.is_none());
190        }
191        assert!(out[warmup - 1].is_some());
192    }
193
194    #[test]
195    fn constant_range_sums_to_sum_period() {
196        // A constant high–low range makes both EMAs converge to the same
197        // value, so every ratio is 1 and the Mass Index equals `sum_period`.
198        let mut mi = MassIndex::new(3, 4).unwrap();
199        let candles: Vec<Candle> = (0..40).map(|i| candle(100.0 + i as f64, 2.0, i)).collect();
200        for v in mi.batch(&candles).into_iter().flatten() {
201            assert_relative_eq!(v, 4.0, epsilon = 1e-9);
202        }
203    }
204
205    #[test]
206    fn zero_range_market_sums_to_sum_period() {
207        let mut mi = MassIndex::new(3, 4).unwrap();
208        let candles: Vec<Candle> = (0..40).map(|i| candle(100.0, 0.0, i)).collect();
209        for v in mi.batch(&candles).into_iter().flatten() {
210            assert_relative_eq!(v, 4.0, epsilon = 1e-12);
211        }
212    }
213
214    #[test]
215    fn reset_clears_state() {
216        let mut mi = MassIndex::new(3, 4).unwrap();
217        let candles: Vec<Candle> = (0..20).map(|i| candle(100.0 + i as f64, 2.0, i)).collect();
218        mi.batch(&candles);
219        assert!(mi.is_ready());
220        mi.reset();
221        assert!(!mi.is_ready());
222        assert_eq!(mi.update(candles[0]), None);
223    }
224
225    #[test]
226    fn batch_equals_streaming() {
227        let candles: Vec<Candle> = (0..120)
228            .map(|i| {
229                let span = 2.0 + (i as f64 * 0.3).sin().abs() * 3.0;
230                candle(100.0 + (i as f64 * 0.2).cos() * 5.0, span, i)
231            })
232            .collect();
233        let batch = MassIndex::new(9, 25).unwrap().batch(&candles);
234        let mut b = MassIndex::new(9, 25).unwrap();
235        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
236        assert_eq!(batch, streamed);
237    }
238}