Skip to main content

kestrel_chartkit/indicator/
eom.rs

1use super::smoothing::Sma;
2use super::{Indicator, IndicatorAlert, IndicatorOutput};
3use crate::model::Bar;
4
5/// Ease of Movement (EOM), smoothed over `period` bars.
6///
7/// ```text
8/// distance = (high + low) / 2 - (prev_high + prev_low) / 2
9/// box      = (volume / volume_divisor) / (high - low)        (range floored at 1e-8)
10/// raw      = distance / box                                  (0 for box <= 1e-8)
11/// EOM      = SMA(period) of raw
12/// ```
13///
14/// `volume_divisor` (default `10000`) only rescales volume into a readable unit. The first bar has
15/// no predecessor and produces no raw value.
16///
17/// First output: with the `period + 1`-th bar. [`Indicator::reset`] clears the midpoint and the
18/// average.
19#[derive(Debug, Clone)]
20pub struct EomEngine {
21    period: usize,
22    volume_divisor: f64,
23    prev_hl2: Option<f64>,
24    sma: Sma,
25}
26
27impl EomEngine {
28    pub fn new(period: usize, volume_divisor: f64) -> Self {
29        Self {
30            period: period.max(1),
31            volume_divisor: volume_divisor.max(1.0),
32            prev_hl2: None,
33            sma: Sma::new(period),
34        }
35    }
36
37    pub fn with_defaults() -> Self {
38        Self::new(14, 10000.0)
39    }
40}
41
42impl Indicator for EomEngine {
43    fn name(&self) -> &str {
44        "eom"
45    }
46
47    fn warmup_period(&self) -> usize {
48        self.period + 1
49    }
50
51    fn reset(&mut self) {
52        self.prev_hl2 = None;
53        self.sma.reset();
54    }
55
56    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
57        let hl2 = (bar.high + bar.low) / 2.0;
58
59        let prev = match self.prev_hl2 {
60            Some(p) => p,
61            None => {
62                self.prev_hl2 = Some(hl2);
63                return None;
64            }
65        };
66        self.prev_hl2 = Some(hl2);
67
68        let distance = hl2 - prev;
69        let range = (bar.high - bar.low).max(1e-8);
70        let box_ratio = (bar.volume / self.volume_divisor) / range;
71
72        let raw_eom = if box_ratio > 1e-8 {
73            distance / box_ratio
74        } else {
75            0.0
76        };
77        let eom_val = self.sma.update(raw_eom)?;
78
79        Some(IndicatorOutput::new(eom_val))
80    }
81
82    fn alerts(&self) -> Vec<IndicatorAlert> {
83        Vec::new()
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    #[test]
92    fn test_eom_basic() {
93        let mut eom = EomEngine::with_defaults();
94        let mut out = None;
95        for i in 0..20 {
96            let b = Bar::new(i, 100.0, 105.0, 95.0, 100.0 + i as f64, 10000.0);
97            out = eom.on_bar(&b);
98        }
99        assert!(out.is_some());
100    }
101}