Skip to main content

wickra_core/indicators/
ease_of_movement.rs

1//! Ease of Movement (Arms).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::rolling_moments::RollingSum;
7use crate::ohlcv::Candle;
8use crate::traits::Indicator;
9
10/// Richard Arms' Ease of Movement — how far price travels per unit of volume.
11///
12/// ```text
13/// distance_t = (high_t + low_t)/2 − (high_{t−1} + low_{t−1})/2
14/// EMV_t      = distance_t · (high_t − low_t) · divisor / volume_t
15/// EOM_t      = SMA(EMV, period)_t
16/// ```
17///
18/// A large positive EMV means price climbed a long way on light volume — it
19/// moved "easily"; a value near zero means heavy volume was needed to shift
20/// price at all. The `divisor` only rescales the output: the conventional
21/// `1e8` keeps `EMV` in a readable range for typical share volumes. A bar with
22/// zero volume contributes `EMV = 0` (no trading carries no signal), as does a
23/// zero-range bar. The first candle only seeds the previous midpoint, so the
24/// first value appears on candle `period + 1`.
25///
26/// # Example
27///
28/// ```
29/// use wickra_core::{Candle, Indicator, EaseOfMovement};
30///
31/// let mut indicator = EaseOfMovement::new(14).unwrap();
32/// let mut last = None;
33/// for i in 0..80 {
34///     let base = 100.0 + f64::from(i);
35///     let candle =
36///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
37///     last = indicator.update(candle);
38/// }
39/// assert!(last.is_some());
40/// ```
41#[derive(Debug, Clone)]
42pub struct EaseOfMovement {
43    period: usize,
44    divisor: f64,
45    prev_mid: Option<f64>,
46    window: VecDeque<f64>,
47    sum: RollingSum,
48}
49
50impl EaseOfMovement {
51    /// Construct an Ease of Movement with the conventional `1e8` volume divisor.
52    ///
53    /// # Errors
54    /// Returns [`Error::PeriodZero`] if `period == 0`.
55    pub fn new(period: usize) -> Result<Self> {
56        Self::with_divisor(period, 100_000_000.0)
57    }
58
59    /// Construct an Ease of Movement with an explicit volume divisor. The
60    /// divisor is a pure output-scaling constant; pick whatever keeps `EMV`
61    /// readable for your instrument's volume magnitude.
62    ///
63    /// # Errors
64    /// Returns [`Error::PeriodZero`] if `period == 0` and
65    /// [`Error::NonPositiveMultiplier`] if `divisor` is not strictly positive
66    /// and finite.
67    pub fn with_divisor(period: usize, divisor: f64) -> Result<Self> {
68        if period == 0 {
69            return Err(Error::PeriodZero);
70        }
71        if period > crate::error::MAX_PERIOD {
72            return Err(Error::InvalidPeriod {
73                message: crate::error::PERIOD_ABOVE_MAX,
74            });
75        }
76        if !divisor.is_finite() || divisor <= 0.0 {
77            return Err(Error::NonPositiveMultiplier);
78        }
79        Ok(Self {
80            period,
81            divisor,
82            prev_mid: None,
83            window: VecDeque::with_capacity(period),
84            sum: RollingSum::new(),
85        })
86    }
87
88    /// Configured period.
89    pub const fn period(&self) -> usize {
90        self.period
91    }
92
93    /// Configured volume divisor.
94    pub const fn divisor(&self) -> f64 {
95        self.divisor
96    }
97}
98
99impl Indicator for EaseOfMovement {
100    type Input = Candle;
101    type Output = f64;
102
103    #[inline]
104    fn update(&mut self, candle: Candle) -> Option<f64> {
105        let mid = f64::midpoint(candle.high, candle.low);
106        let Some(prev_mid) = self.prev_mid else {
107            // The first candle only establishes the previous midpoint.
108            self.prev_mid = Some(mid);
109            return None;
110        };
111        let distance = mid - prev_mid;
112        let range = candle.high - candle.low;
113        let emv = if candle.volume == 0.0 {
114            // No volume traded — the move carries no ease-of-movement signal.
115            0.0
116        } else {
117            distance * range * self.divisor / candle.volume
118        };
119        self.prev_mid = Some(mid);
120
121        if self.window.len() == self.period {
122            let oldest = self.window.pop_front().expect("non-empty");
123            self.sum.evict(oldest);
124        }
125        self.window.push_back(emv);
126        self.sum.push(emv);
127        if self.sum.needs_reseed(self.period) {
128            self.sum.reseed(self.window.iter().copied());
129        }
130        if self.window.len() < self.period {
131            return None;
132        }
133        Some(self.sum.value() / self.period as f64)
134    }
135
136    fn reset(&mut self) {
137        self.prev_mid = None;
138        self.window.clear();
139        self.sum.reset();
140    }
141
142    #[inline]
143    fn warmup_period(&self) -> usize {
144        // One seed candle establishes the first previous midpoint, then
145        // `period` EMV values fill the averaging window.
146        self.period + 1
147    }
148
149    #[inline]
150    fn is_ready(&self) -> bool {
151        self.window.len() == self.period
152    }
153
154    #[inline]
155    fn name(&self) -> &'static str {
156        "EaseOfMovement"
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use crate::traits::BatchExt;
164    use approx::assert_relative_eq;
165
166    fn candle(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle {
167        Candle::new(open, high, low, close, volume, ts).unwrap()
168    }
169
170    #[test]
171    fn reference_values() {
172        // EOM(period = 1, divisor = 1): one EMV value is its own average.
173        //   candle 1: midpoint (10 + 8)/2 = 9 only seeds the previous mid.
174        //   candle 2: mid = (14 + 10)/2 = 12, distance = 3, range = 4,
175        //             EMV = 3 * 4 * 1 / 100 = 0.12.
176        let mut eom = EaseOfMovement::with_divisor(1, 1.0).unwrap();
177        let out = eom.batch(&[
178            candle(9.0, 10.0, 8.0, 9.0, 50.0, 0),
179            candle(12.0, 14.0, 10.0, 12.0, 100.0, 1),
180        ]);
181        assert!(out[0].is_none());
182        assert_relative_eq!(out[1].unwrap(), 0.12, epsilon = 1e-12);
183    }
184
185    #[test]
186    fn rising_midpoints_yield_positive_eom() {
187        // Strictly rising midpoints on constant volume -> every EMV is
188        // positive, so the averaged EOM is positive.
189        let candles: Vec<Candle> = (0..40)
190            .map(|i| {
191                let base = 100.0 + i as f64;
192                candle(base, base + 1.0, base - 1.0, base, 100.0, i)
193            })
194            .collect();
195        let mut eom = EaseOfMovement::new(14).unwrap();
196        for v in eom.batch(&candles).into_iter().flatten() {
197            assert!(v > 0.0, "EOM {v} should be positive on a rising series");
198        }
199    }
200
201    #[test]
202    fn constant_series_yields_zero() {
203        // Unchanging candles -> zero distance -> EMV is zero throughout.
204        let candles: Vec<Candle> = (0..30)
205            .map(|i| candle(10.0, 11.0, 9.0, 10.0, 50.0, i))
206            .collect();
207        let mut eom = EaseOfMovement::new(10).unwrap();
208        for v in eom.batch(&candles).into_iter().flatten() {
209            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
210        }
211    }
212
213    #[test]
214    fn zero_volume_contributes_zero() {
215        // A zero-volume bar yields EMV = 0 instead of dividing by zero.
216        let candles: Vec<Candle> = (0..20)
217            .map(|i| {
218                let base = 100.0 + i as f64;
219                candle(base, base + 1.0, base - 1.0, base, 0.0, i)
220            })
221            .collect();
222        let mut eom = EaseOfMovement::new(10).unwrap();
223        for v in eom.batch(&candles).into_iter().flatten() {
224            assert_relative_eq!(v, 0.0, epsilon = 1e-12);
225        }
226    }
227
228    #[test]
229    fn first_value_on_period_plus_one_candle() {
230        let candles: Vec<Candle> = (0..12)
231            .map(|i| {
232                let base = 100.0 + i as f64;
233                candle(base, base + 1.0, base - 1.0, base, 50.0, i)
234            })
235            .collect();
236        let mut eom = EaseOfMovement::new(5).unwrap();
237        let out = eom.batch(&candles);
238        for (i, v) in out.iter().enumerate().take(5) {
239            assert!(v.is_none(), "index {i} must be None during warmup");
240        }
241        assert!(out[5].is_some(), "first EOM lands at index period");
242        assert_eq!(eom.warmup_period(), 6);
243    }
244
245    #[test]
246    fn rejects_invalid_input() {
247        assert!(EaseOfMovement::new(0).is_err());
248        assert!(EaseOfMovement::with_divisor(14, 0.0).is_err());
249        assert!(EaseOfMovement::with_divisor(14, -1.0).is_err());
250        assert!(EaseOfMovement::with_divisor(14, f64::NAN).is_err());
251    }
252
253    /// Cover the const accessors `period` / `divisor` (82-90) and the
254    /// Indicator-impl `name` body (141-143). Existing tests inspect EMV
255    /// output but never query the metadata methods.
256    #[test]
257    fn accessors_and_metadata() {
258        let emv = EaseOfMovement::new(14).unwrap();
259        assert_eq!(emv.period(), 14);
260        // The canonical divisor (per the new() default) — keep in sync with src.
261        assert_relative_eq!(emv.divisor(), 100_000_000.0, epsilon = 1e-6);
262        assert_eq!(emv.name(), "EaseOfMovement");
263    }
264
265    #[test]
266    fn reset_clears_state() {
267        let candles: Vec<Candle> = (0..30)
268            .map(|i| {
269                let base = 100.0 + i as f64;
270                candle(base, base + 1.0, base - 1.0, base, 50.0, i)
271            })
272            .collect();
273        let mut eom = EaseOfMovement::new(10).unwrap();
274        eom.batch(&candles);
275        assert!(eom.is_ready());
276        eom.reset();
277        assert!(!eom.is_ready());
278        assert_eq!(eom.update(candles[0]), None);
279    }
280
281    #[test]
282    fn batch_equals_streaming() {
283        let candles: Vec<Candle> = (0..80)
284            .map(|i| {
285                let mid = 100.0 + (i as f64 * 0.3).sin() * 8.0;
286                candle(
287                    mid,
288                    mid + 2.0,
289                    mid - 2.0,
290                    mid + 0.5,
291                    10.0 + (i % 5) as f64,
292                    i,
293                )
294            })
295            .collect();
296        let mut a = EaseOfMovement::new(14).unwrap();
297        let mut b = EaseOfMovement::new(14).unwrap();
298        assert_eq!(
299            a.batch(&candles),
300            candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
301        );
302    }
303}