Skip to main content

wickra_core/indicators/
minus_dm.rs

1//! Minus Directional Movement (-DM), Wilder-smoothed.
2
3use crate::error::{Error, Result};
4use crate::indicators::adx::directional_movement;
5use crate::ohlcv::Candle;
6use crate::traits::Indicator;
7
8/// Wilder's Minus Directional Movement (`MINUS_DM`).
9///
10/// The raw minus directional movement of a bar is `max(low_prev − low, 0)` when
11/// the down-move exceeds the up-move `high − high_prev`, and `0` otherwise. This
12/// indicator returns the Wilder-smoothed running total of that raw `-DM` over
13/// `period` bars, the same accumulation that feeds [`Adx`](crate::Adx) and
14/// [`MinusDi`](crate::MinusDi).
15///
16/// The first `period` raw values seed the sum; from then on each update applies
17/// the Wilder recursion `smoothed − smoothed / period + raw`. Because a bar's
18/// directional movement needs the previous bar, the first value is emitted after
19/// `period + 1` candles.
20///
21/// # Example
22///
23/// ```
24/// use wickra_core::{Candle, Indicator, MinusDm};
25///
26/// let mut indicator = MinusDm::new(5).unwrap();
27/// let mut last = None;
28/// for i in 0..40 {
29///     let base = 100.0 - f64::from(i);
30///     let candle =
31///         Candle::new(base, base + 2.0, base - 2.0, base - 1.0, 10.0, i64::from(i)).unwrap();
32///     last = indicator.update(candle);
33/// }
34/// assert!(last.is_some());
35/// ```
36#[derive(Debug, Clone)]
37pub struct MinusDm {
38    period: usize,
39    prev: Option<Candle>,
40    seed: f64,
41    seed_count: usize,
42    smooth: Option<f64>,
43}
44
45impl MinusDm {
46    /// # Errors
47    /// Returns [`Error::PeriodZero`] if `period == 0`.
48    pub fn new(period: usize) -> Result<Self> {
49        if period == 0 {
50            return Err(Error::PeriodZero);
51        }
52        Ok(Self {
53            period,
54            prev: None,
55            seed: 0.0,
56            seed_count: 0,
57            smooth: None,
58        })
59    }
60
61    /// Configured period.
62    pub const fn period(&self) -> usize {
63        self.period
64    }
65}
66
67impl Indicator for MinusDm {
68    type Input = Candle;
69    type Output = f64;
70
71    fn update(&mut self, candle: Candle) -> Option<f64> {
72        let Some(prev) = self.prev else {
73            self.prev = Some(candle);
74            return None;
75        };
76        self.prev = Some(candle);
77
78        let (_, minus_dm) = directional_movement(&prev, &candle);
79        let n = self.period as f64;
80
81        if let Some(s) = self.smooth {
82            let s_new = s - s / n + minus_dm;
83            self.smooth = Some(s_new);
84            return Some(s_new);
85        }
86
87        self.seed += minus_dm;
88        self.seed_count += 1;
89        if self.seed_count < self.period {
90            return None;
91        }
92        self.smooth = Some(self.seed);
93        Some(self.seed)
94    }
95
96    fn reset(&mut self) {
97        self.prev = None;
98        self.seed = 0.0;
99        self.seed_count = 0;
100        self.smooth = None;
101    }
102
103    fn warmup_period(&self) -> usize {
104        self.period
105    }
106
107    fn is_ready(&self) -> bool {
108        self.smooth.is_some()
109    }
110
111    fn name(&self) -> &'static str {
112        "MINUS_DM"
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use crate::traits::BatchExt;
120    use approx::assert_relative_eq;
121
122    /// Candle with explicit high/low; open and close are pinned to `cl`.
123    fn c(h: f64, l: f64, cl: f64) -> Candle {
124        Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
125    }
126
127    #[test]
128    fn rejects_zero_period() {
129        assert!(matches!(MinusDm::new(0), Err(Error::PeriodZero)));
130    }
131
132    #[test]
133    fn accessors_report_config() {
134        let dm = MinusDm::new(7).unwrap();
135        assert_eq!(dm.period(), 7);
136        assert_eq!(dm.name(), "MINUS_DM");
137        assert_eq!(dm.warmup_period(), 7);
138        assert!(!dm.is_ready());
139    }
140
141    #[test]
142    fn seeds_then_smooths_a_constant_minus_dm() {
143        // Low falls by 1 each bar (down = +1); high falls by 0.5 each bar, so the
144        // up-move is negative and -DM equals the down-move (1.0) on every bar.
145        let candles: Vec<Candle> = (0..5)
146            .map(|i| {
147                c(
148                    20.0 - 0.5 * f64::from(i),
149                    18.0 - f64::from(i),
150                    19.0 - f64::from(i),
151                )
152            })
153            .collect();
154        let mut dm = MinusDm::new(3).unwrap();
155        let out: Vec<Option<f64>> = dm.batch(&candles);
156        assert_eq!(out[0], None);
157        assert_eq!(out[1], None);
158        assert_eq!(out[2], None);
159        // Seed = sum of three unit -DM values.
160        assert_relative_eq!(out[3].unwrap(), 3.0, epsilon = 1e-12);
161        // Wilder step: 3 - 3/3 + 1 = 3.
162        assert_relative_eq!(out[4].unwrap(), 3.0, epsilon = 1e-12);
163        assert!(dm.is_ready());
164    }
165
166    #[test]
167    fn up_moves_contribute_zero() {
168        // Strict uptrend: lows rise, so every raw -DM is zero.
169        let candles: Vec<Candle> = (0..6)
170            .map(|i| c(20.0 + f64::from(i), 5.0 + f64::from(i), 12.0 + f64::from(i)))
171            .collect();
172        let mut dm = MinusDm::new(3).unwrap();
173        let last = dm.batch(&candles).into_iter().flatten().last().unwrap();
174        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
175    }
176
177    #[test]
178    fn reset_restores_initial_state() {
179        let candles: Vec<Candle> = (0..5)
180            .map(|i| {
181                c(
182                    20.0 - 0.5 * f64::from(i),
183                    18.0 - f64::from(i),
184                    19.0 - f64::from(i),
185                )
186            })
187            .collect();
188        let mut dm = MinusDm::new(3).unwrap();
189        let _ = dm.batch(&candles);
190        assert!(dm.is_ready());
191        dm.reset();
192        assert!(!dm.is_ready());
193        assert_eq!(dm.update(candles[0]), None);
194    }
195}