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    fn update(&mut self, candle: Candle) -> Option<f64> {
95        let range = candle.high - candle.low;
96        let single = self.ema1.update(range)?;
97        let double = self.ema2.update(single)?;
98        let ratio = if double == 0.0 {
99            // A zero-range market: no expansion, neutral ratio.
100            1.0
101        } else {
102            single / double
103        };
104        if self.window.len() == self.sum_period {
105            self.sum -= self.window.pop_front().expect("window is non-empty");
106        }
107        self.window.push_back(ratio);
108        self.sum += ratio;
109        if self.window.len() < self.sum_period {
110            return None;
111        }
112        self.last = Some(self.sum);
113        Some(self.sum)
114    }
115
116    fn reset(&mut self) {
117        self.ema1.reset();
118        self.ema2.reset();
119        self.window.clear();
120        self.sum = 0.0;
121        self.last = None;
122    }
123
124    fn warmup_period(&self) -> usize {
125        // ema1 seeds at `ema_period`, ema2 at `2·ema_period − 1`, then the sum
126        // window needs `sum_period` ratios.
127        2 * self.ema_period + self.sum_period - 2
128    }
129
130    fn is_ready(&self) -> bool {
131        self.last.is_some()
132    }
133
134    fn name(&self) -> &'static str {
135        "MassIndex"
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use crate::traits::BatchExt;
143    use approx::assert_relative_eq;
144
145    /// A candle with a fixed high–low range `span` centred on `mid`.
146    fn candle(mid: f64, span: f64, ts: i64) -> Candle {
147        Candle::new(mid, mid + span / 2.0, mid - span / 2.0, mid, 1.0, ts).unwrap()
148    }
149
150    #[test]
151    fn new_rejects_zero_period() {
152        assert!(matches!(MassIndex::new(0, 25), Err(Error::PeriodZero)));
153        assert!(matches!(MassIndex::new(9, 0), Err(Error::PeriodZero)));
154    }
155
156    /// Cover the const accessors `periods` / `value` (80-87) and the
157    /// Indicator-impl `name` body (134-136). `warmup_period` is already
158    /// covered by `warmup_period_formula`.
159    #[test]
160    fn accessors_and_metadata() {
161        let mut mi = MassIndex::new(9, 25).unwrap();
162        assert_eq!(mi.periods(), (9, 25));
163        assert_eq!(mi.name(), "MassIndex");
164        assert_eq!(mi.value(), None);
165        for i in 0..mi.warmup_period() {
166            mi.update(candle(100.0, 2.0, i64::try_from(i).unwrap()));
167        }
168        assert!(mi.value().is_some());
169    }
170
171    #[test]
172    fn warmup_period_formula() {
173        let mi = MassIndex::new(9, 25).unwrap();
174        assert_eq!(mi.warmup_period(), 2 * 9 + 25 - 2);
175    }
176
177    #[test]
178    fn first_emission_at_warmup_period() {
179        let mut mi = MassIndex::new(3, 4).unwrap();
180        let warmup = mi.warmup_period(); // 2*3 + 4 - 2 = 8
181        assert_eq!(warmup, 8);
182        let candles: Vec<Candle> = (0..20).map(|i| candle(100.0 + i as f64, 2.0, i)).collect();
183        let out = mi.batch(&candles);
184        for v in out.iter().take(warmup - 1) {
185            assert!(v.is_none());
186        }
187        assert!(out[warmup - 1].is_some());
188    }
189
190    #[test]
191    fn constant_range_sums_to_sum_period() {
192        // A constant high–low range makes both EMAs converge to the same
193        // value, so every ratio is 1 and the Mass Index equals `sum_period`.
194        let mut mi = MassIndex::new(3, 4).unwrap();
195        let candles: Vec<Candle> = (0..40).map(|i| candle(100.0 + i as f64, 2.0, i)).collect();
196        for v in mi.batch(&candles).into_iter().flatten() {
197            assert_relative_eq!(v, 4.0, epsilon = 1e-9);
198        }
199    }
200
201    #[test]
202    fn zero_range_market_sums_to_sum_period() {
203        let mut mi = MassIndex::new(3, 4).unwrap();
204        let candles: Vec<Candle> = (0..40).map(|i| candle(100.0, 0.0, i)).collect();
205        for v in mi.batch(&candles).into_iter().flatten() {
206            assert_relative_eq!(v, 4.0, epsilon = 1e-12);
207        }
208    }
209
210    #[test]
211    fn reset_clears_state() {
212        let mut mi = MassIndex::new(3, 4).unwrap();
213        let candles: Vec<Candle> = (0..20).map(|i| candle(100.0 + i as f64, 2.0, i)).collect();
214        mi.batch(&candles);
215        assert!(mi.is_ready());
216        mi.reset();
217        assert!(!mi.is_ready());
218        assert_eq!(mi.update(candles[0]), None);
219    }
220
221    #[test]
222    fn batch_equals_streaming() {
223        let candles: Vec<Candle> = (0..120)
224            .map(|i| {
225                let span = 2.0 + (i as f64 * 0.3).sin().abs() * 3.0;
226                candle(100.0 + (i as f64 * 0.2).cos() * 5.0, span, i)
227            })
228            .collect();
229        let batch = MassIndex::new(9, 25).unwrap().batch(&candles);
230        let mut b = MassIndex::new(9, 25).unwrap();
231        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
232        assert_eq!(batch, streamed);
233    }
234}