1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9#[derive(Debug, Clone)]
41pub struct EaseOfMovement {
42 period: usize,
43 divisor: f64,
44 prev_mid: Option<f64>,
45 window: VecDeque<f64>,
46 sum: f64,
47}
48
49impl EaseOfMovement {
50 pub fn new(period: usize) -> Result<Self> {
55 Self::with_divisor(period, 100_000_000.0)
56 }
57
58 pub fn with_divisor(period: usize, divisor: f64) -> Result<Self> {
67 if period == 0 {
68 return Err(Error::PeriodZero);
69 }
70 if !divisor.is_finite() || divisor <= 0.0 {
71 return Err(Error::NonPositiveMultiplier);
72 }
73 Ok(Self {
74 period,
75 divisor,
76 prev_mid: None,
77 window: VecDeque::with_capacity(period),
78 sum: 0.0,
79 })
80 }
81
82 pub const fn period(&self) -> usize {
84 self.period
85 }
86
87 pub const fn divisor(&self) -> f64 {
89 self.divisor
90 }
91}
92
93impl Indicator for EaseOfMovement {
94 type Input = Candle;
95 type Output = f64;
96
97 fn update(&mut self, candle: Candle) -> Option<f64> {
98 let mid = f64::midpoint(candle.high, candle.low);
99 let Some(prev_mid) = self.prev_mid else {
100 self.prev_mid = Some(mid);
102 return None;
103 };
104 let distance = mid - prev_mid;
105 let range = candle.high - candle.low;
106 let emv = if candle.volume == 0.0 {
107 0.0
109 } else {
110 distance * range * self.divisor / candle.volume
111 };
112 self.prev_mid = Some(mid);
113
114 if self.window.len() == self.period {
115 self.sum -= self.window.pop_front().expect("non-empty");
116 }
117 self.window.push_back(emv);
118 self.sum += emv;
119 if self.window.len() < self.period {
120 return None;
121 }
122 Some(self.sum / self.period as f64)
123 }
124
125 fn reset(&mut self) {
126 self.prev_mid = None;
127 self.window.clear();
128 self.sum = 0.0;
129 }
130
131 fn warmup_period(&self) -> usize {
132 self.period + 1
135 }
136
137 fn is_ready(&self) -> bool {
138 self.window.len() == self.period
139 }
140
141 fn name(&self) -> &'static str {
142 "EaseOfMovement"
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149 use crate::traits::BatchExt;
150 use approx::assert_relative_eq;
151
152 fn candle(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle {
153 Candle::new(open, high, low, close, volume, ts).unwrap()
154 }
155
156 #[test]
157 fn reference_values() {
158 let mut eom = EaseOfMovement::with_divisor(1, 1.0).unwrap();
163 let out = eom.batch(&[
164 candle(9.0, 10.0, 8.0, 9.0, 50.0, 0),
165 candle(12.0, 14.0, 10.0, 12.0, 100.0, 1),
166 ]);
167 assert!(out[0].is_none());
168 assert_relative_eq!(out[1].unwrap(), 0.12, epsilon = 1e-12);
169 }
170
171 #[test]
172 fn rising_midpoints_yield_positive_eom() {
173 let candles: Vec<Candle> = (0..40)
176 .map(|i| {
177 let base = 100.0 + i as f64;
178 candle(base, base + 1.0, base - 1.0, base, 100.0, i)
179 })
180 .collect();
181 let mut eom = EaseOfMovement::new(14).unwrap();
182 for v in eom.batch(&candles).into_iter().flatten() {
183 assert!(v > 0.0, "EOM {v} should be positive on a rising series");
184 }
185 }
186
187 #[test]
188 fn constant_series_yields_zero() {
189 let candles: Vec<Candle> = (0..30)
191 .map(|i| candle(10.0, 11.0, 9.0, 10.0, 50.0, i))
192 .collect();
193 let mut eom = EaseOfMovement::new(10).unwrap();
194 for v in eom.batch(&candles).into_iter().flatten() {
195 assert_relative_eq!(v, 0.0, epsilon = 1e-12);
196 }
197 }
198
199 #[test]
200 fn zero_volume_contributes_zero() {
201 let candles: Vec<Candle> = (0..20)
203 .map(|i| {
204 let base = 100.0 + i as f64;
205 candle(base, base + 1.0, base - 1.0, base, 0.0, i)
206 })
207 .collect();
208 let mut eom = EaseOfMovement::new(10).unwrap();
209 for v in eom.batch(&candles).into_iter().flatten() {
210 assert_relative_eq!(v, 0.0, epsilon = 1e-12);
211 }
212 }
213
214 #[test]
215 fn first_value_on_period_plus_one_candle() {
216 let candles: Vec<Candle> = (0..12)
217 .map(|i| {
218 let base = 100.0 + i as f64;
219 candle(base, base + 1.0, base - 1.0, base, 50.0, i)
220 })
221 .collect();
222 let mut eom = EaseOfMovement::new(5).unwrap();
223 let out = eom.batch(&candles);
224 for (i, v) in out.iter().enumerate().take(5) {
225 assert!(v.is_none(), "index {i} must be None during warmup");
226 }
227 assert!(out[5].is_some(), "first EOM lands at index period");
228 assert_eq!(eom.warmup_period(), 6);
229 }
230
231 #[test]
232 fn rejects_invalid_input() {
233 assert!(EaseOfMovement::new(0).is_err());
234 assert!(EaseOfMovement::with_divisor(14, 0.0).is_err());
235 assert!(EaseOfMovement::with_divisor(14, -1.0).is_err());
236 assert!(EaseOfMovement::with_divisor(14, f64::NAN).is_err());
237 }
238
239 #[test]
243 fn accessors_and_metadata() {
244 let emv = EaseOfMovement::new(14).unwrap();
245 assert_eq!(emv.period(), 14);
246 assert_relative_eq!(emv.divisor(), 100_000_000.0, epsilon = 1e-6);
248 assert_eq!(emv.name(), "EaseOfMovement");
249 }
250
251 #[test]
252 fn reset_clears_state() {
253 let candles: Vec<Candle> = (0..30)
254 .map(|i| {
255 let base = 100.0 + i as f64;
256 candle(base, base + 1.0, base - 1.0, base, 50.0, i)
257 })
258 .collect();
259 let mut eom = EaseOfMovement::new(10).unwrap();
260 eom.batch(&candles);
261 assert!(eom.is_ready());
262 eom.reset();
263 assert!(!eom.is_ready());
264 assert_eq!(eom.update(candles[0]), None);
265 }
266
267 #[test]
268 fn batch_equals_streaming() {
269 let candles: Vec<Candle> = (0..80)
270 .map(|i| {
271 let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
272 candle(
273 mid,
274 mid + 2.0,
275 mid - 2.0,
276 mid + 0.5,
277 10.0 + (i % 5) as f64,
278 i,
279 )
280 })
281 .collect();
282 let mut a = EaseOfMovement::new(14).unwrap();
283 let mut b = EaseOfMovement::new(14).unwrap();
284 assert_eq!(
285 a.batch(&candles),
286 candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
287 );
288 }
289}