Skip to main content

egui_charts/studies/builtin/
ease_of_movement.rs

1use super::{clamp_param, over_finite_bars};
2use crate::model::Bar;
3use crate::studies::palette;
4use crate::studies::stepper::{update_window, usable_behind};
5use crate::studies::{Indicator, IndicatorValue, IndicatorValues};
6use egui::Color32;
7
8/// Ease of Movement (EoM / EMV), Richard Arms: how easily price moves for the volume traded.
9///
10/// The raw value of a bar is its midpoint move over the previous bar divided by its box
11/// ratio, (volume / divisor) / (high - low), and the study is the mean of the raw values of
12/// the last `period` bars. A bar with zero range has a raw value of 0. A bar with zero
13/// volume and a nonzero range has no raw value (its box ratio is 0). A bar with a NaN or
14/// infinite high, low or volume has no value and is left out: the next bar's raw value
15/// compares with the last usable bar. The mean is taken over the bars of the window that have a raw value, and a
16/// window without one has no value.
17#[derive(Clone)]
18pub struct EaseOfMovement {
19    /// SMA smoothing period (typically 14)
20    period: usize,
21    /// Volume divisor: Arms states volume in units of 10,000
22    divisor: f64,
23    values: IndicatorValues,
24    color: Color32,
25    visible: bool,
26}
27
28impl EaseOfMovement {
29    /// Create with a lookback of `period` bars, clamped to at least 1.
30    pub fn new(period: usize) -> Self {
31        Self {
32            period: period.max(1),
33            divisor: 10_000.0,
34            values: IndicatorValues::new(),
35            color: palette::DARK_GREEN,
36            visible: true,
37        }
38    }
39
40    /// Set the volume divisor, 10,000 by default (Arms states volume in units of 10,000), clamped into `f64::EPSILON` to `f64::MAX`, NaN to `f64::EPSILON`.
41    pub fn with_divisor(mut self, divisor: f64) -> Self {
42        self.divisor = clamp_param(divisor, f64::EPSILON, f64::MAX);
43        self
44    }
45
46    /// Set the plot color.
47    pub fn with_color(mut self, color: Color32) -> Self {
48        self.color = color;
49        self
50    }
51}
52
53/// Construct with the conventional default parameters.
54impl Default for EaseOfMovement {
55    fn default() -> Self {
56        Self::new(14)
57    }
58}
59
60impl EaseOfMovement {
61    /// The values over `data`, every bar of which has finite inputs.
62    fn compute(&self, data: &[Bar]) -> IndicatorValues {
63        // The raw value of bar `i + 1`, or `None` where it is undefined.
64        let raw: Vec<Option<f64>> = data
65            .windows(2)
66            .map(|pair| {
67                let (prev, current) = (&pair[0], &pair[1]);
68                // Distance Moved = ((High + Low) / 2) - ((Prev High + Prev Low) / 2)
69                let distance_moved =
70                    (current.high + current.low) / 2.0 - (prev.high + prev.low) / 2.0;
71                let range = current.high - current.low;
72                if range == 0.0 {
73                    Some(0.0)
74                } else {
75                    // Box Ratio = (Volume / divisor) / (High - Low); zero volume makes the value infinite.
76                    let box_ratio = (current.volume / self.divisor) / range;
77                    Some(distance_moved / box_ratio).filter(|eom| eom.is_finite())
78                }
79            })
80            .collect();
81
82        // Bar 0 has no raw value, so the first full window ends on bar `period`.
83        (0..data.len())
84            .map(|i| {
85                let Some(window) = i.checked_sub(self.period).map(|start| &raw[start..i]) else {
86                    return IndicatorValue::None;
87                };
88                let defined = window.iter().flatten();
89                match defined.clone().count() {
90                    0 => IndicatorValue::None,
91                    n => IndicatorValue::Single(defined.sum::<f64>() / n as f64),
92                }
93            })
94            .collect()
95    }
96}
97
98impl Indicator for EaseOfMovement {
99    crate::studies::inputs::study_inputs!(self;
100        period: length("Period", 1, usize::MAX),
101        divisor: number("Divisor", f64::EPSILON, f64::MAX)
102    ; {});
103
104    fn name(&self) -> &str {
105        "EoM"
106    }
107
108    fn desc(&self) -> &str {
109        "Ease of Movement - Relates price change to volume"
110    }
111
112    fn calculate(&mut self, data: &[Bar]) {
113        self.update(data, 0);
114    }
115
116    fn update(&mut self, data: &[Bar], from: usize) {
117        let mut values = std::mem::take(&mut self.values);
118        update_window(
119            &mut values,
120            data,
121            from,
122            usable_behind(self.period, |b| {
123                b.high.is_finite() && b.low.is_finite() && b.volume.is_finite()
124            }),
125            |data| {
126                over_finite_bars(
127                    data,
128                    |b| b.high.is_finite() && b.low.is_finite() && b.volume.is_finite(),
129                    |data| self.compute(data),
130                )
131            },
132        );
133        self.values = values;
134    }
135
136    fn is_incremental(&self) -> bool {
137        true
138    }
139
140    fn values(&self) -> &IndicatorValues {
141        &self.values
142    }
143
144    fn colors(&self) -> Vec<Color32> {
145        vec![self.color]
146    }
147
148    fn set_colors(&mut self, colors: Vec<Color32>) {
149        if !colors.is_empty() {
150            self.color = colors[0];
151        }
152    }
153
154    fn is_overlay(&self) -> bool {
155        false
156    }
157
158    fn is_visible(&self) -> bool {
159        self.visible
160    }
161
162    fn set_visible(&mut self, visible: bool) {
163        self.visible = visible;
164    }
165
166    fn clone_box(&self) -> Box<dyn Indicator> {
167        Box::new(self.clone())
168    }
169
170    fn line_names(&self) -> Vec<String> {
171        vec![format!("EoM({})", self.period)]
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use chrono::Utc;
179
180    fn make_bar(high: f64, low: f64, close: f64, volume: f64) -> Bar {
181        Bar {
182            time: Utc::now(),
183            open: (high + low) / 2.0,
184            high,
185            low,
186            close,
187            volume,
188            colors: None,
189            payload: None,
190        }
191    }
192
193    /// Arms' definition with volume in 10,000s, from the worked example in
194    /// "Arms' Ease of Movement: Adding Volume to the Equation" (AAII Journal,
195    /// October 2001): EMV 0.00638 on the second bar.
196    #[test]
197    fn default_divisor_matches_reference() {
198        // June 19 volume implied by the article's box ratio of 60.37 on a 1.63 range.
199        let example = [(75.10, 74.10, 584_800.0), (75.80, 74.17, 984_031.0)];
200        let trend = [
201            example[0],
202            example[1],
203            (76.20, 75.00, 700_000.0),
204            (75.90, 74.60, 1_200_000.0),
205            (77.00, 75.50, 900_000.0),
206            (77.40, 76.10, 650_000.0),
207        ];
208        let flat = [(100.0, 100.0, 1_000.0); 4];
209        let zero_volume = [
210            (10.5, 9.5, 1_000.0),
211            (12.0, 10.0, 0.0),
212            (13.0, 11.0, 1_000.0),
213        ];
214        // Bar 6 closes a window of three zero-volume bars; bar 8 has zero range and zero volume.
215        let zero_volume_windows = [
216            (10.0, 9.0, 50_000.0),
217            (11.0, 10.0, 40_000.0),
218            (12.0, 10.0, 0.0),
219            (12.5, 11.5, 60_000.0),
220            (12.0, 11.0, 0.0),
221            (13.0, 12.0, 0.0),
222            (13.0, 12.0, 0.0),
223            (14.0, 13.0, 30_000.0),
224            (14.0, 14.0, 0.0),
225            (15.0, 14.0, 20_000.0),
226        ];
227        // Bar 2 has a NaN input and is left out: bar 3 follows bar 1, so the raw values are 0.25, 1 and 1.
228        let non_finite = [
229            (10.0, 9.0, 50_000.0),
230            (11.0, 10.0, 40_000.0),
231            (f64::NAN, f64::NAN, 30_000.0),
232            (13.0, 12.0, 20_000.0),
233            (14.0, 13.0, 10_000.0),
234        ];
235        let cases = [
236            (
237                "worked example",
238                &example[..],
239                1,
240                vec![None, Some(0.00637733973828067)],
241            ),
242            (
243                "trend",
244                &trend[..],
245                3,
246                vec![
247                    None,
248                    None,
249                    None,
250                    Some(0.004376176738157037),
251                    Some(0.007805952380952369),
252                    Some(0.007625000000000039),
253                ],
254            ),
255            ("flat", &flat[..], 2, vec![None, None, Some(0.0), Some(0.0)]),
256            (
257                "zero volume",
258                &zero_volume[..],
259                1,
260                vec![None, None, Some(20.0)],
261            ),
262            (
263                "zero volume windows",
264                &zero_volume_windows[..],
265                3,
266                vec![
267                    None,
268                    None,
269                    None,
270                    Some(0.20833333333333334),
271                    Some(0.16666666666666666),
272                    Some(0.16666666666666666),
273                    None,
274                    Some(0.3333333333333333),
275                    Some(0.16666666666666666),
276                    Some(0.19444444444444445),
277                ],
278            ),
279            (
280                "non-finite bar",
281                &non_finite[..],
282                2,
283                vec![None, None, None, Some(0.625), Some(1.0)],
284            ),
285        ];
286
287        for (name, bars, period, expected) in cases {
288            let data: Vec<Bar> = bars
289                .iter()
290                .map(|&(h, l, v)| make_bar(h, l, (h + l) / 2.0, v))
291                .collect();
292            let mut eom = EaseOfMovement::new(period);
293            eom.calculate(&data);
294            assert_eq!(eom.values.len(), expected.len(), "{name}");
295            for (i, (got, want)) in eom.values.to_rows().iter().zip(expected.iter()).enumerate() {
296                match (got, want) {
297                    (IndicatorValue::None, None) => {}
298                    (IndicatorValue::Single(g), Some(w)) => {
299                        assert!((g - w).abs() <= 1e-12, "{name}[{i}]: got {g}, want {w}")
300                    }
301                    _ => panic!("{name}[{i}]: got {got:?}, want {want:?}"),
302                }
303            }
304        }
305    }
306}