wickra_core/indicators/
mass_index.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9use super::Ema;
10
11#[derive(Debug, Clone)]
46pub struct MassIndex {
47 ema_period: usize,
48 sum_period: usize,
49 ema1: Ema,
50 ema2: Ema,
51 window: VecDeque<f64>,
53 sum: f64,
54 last: Option<f64>,
55}
56
57impl MassIndex {
58 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 pub const fn periods(&self) -> (usize, usize) {
81 (self.ema_period, self.sum_period)
82 }
83
84 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 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 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 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 #[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(); 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 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}