Skip to main content

wickra_core/indicators/
minus_di.rs

1//! Minus Directional Indicator (-DI), 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 Indicator (`MINUS_DI`).
9///
10/// `-DI = 100 · smoothed(-DM) / smoothed(TR)`, where both the minus directional
11/// movement and the true range are Wilder-smoothed over `period` bars. It is the
12/// bearish half of the directional system that drives [`Adx`](crate::Adx);
13/// readings above [`PlusDi`](crate::PlusDi) mark a down-trending regime.
14///
15/// The first `period` raw values seed the two running sums; from then on each
16/// applies the Wilder recursion `smoothed − smoothed / period + raw`. Because a
17/// bar's directional movement and true range both need the previous bar, the
18/// first value is emitted after `period + 1` candles. When the smoothed true
19/// range is zero (a perfectly flat market) the indicator returns `0`.
20///
21/// # Example
22///
23/// ```
24/// use wickra_core::{Candle, Indicator, MinusDi};
25///
26/// let mut indicator = MinusDi::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 MinusDi {
38    period: usize,
39    prev: Option<Candle>,
40    dm_seed: f64,
41    tr_seed: f64,
42    seed_count: usize,
43    dm_smooth: Option<f64>,
44    tr_smooth: Option<f64>,
45}
46
47impl MinusDi {
48    /// # Errors
49    /// Returns [`Error::PeriodZero`] if `period == 0`.
50    pub fn new(period: usize) -> Result<Self> {
51        if period == 0 {
52            return Err(Error::PeriodZero);
53        }
54        Ok(Self {
55            period,
56            prev: None,
57            dm_seed: 0.0,
58            tr_seed: 0.0,
59            seed_count: 0,
60            dm_smooth: None,
61            tr_smooth: None,
62        })
63    }
64
65    /// Configured period.
66    pub const fn period(&self) -> usize {
67        self.period
68    }
69}
70
71impl Indicator for MinusDi {
72    type Input = Candle;
73    type Output = f64;
74
75    fn update(&mut self, candle: Candle) -> Option<f64> {
76        let Some(prev) = self.prev else {
77            self.prev = Some(candle);
78            return None;
79        };
80        self.prev = Some(candle);
81
82        let (_, minus_dm) = directional_movement(&prev, &candle);
83        let tr = candle.true_range(Some(prev.close));
84        let n = self.period as f64;
85
86        let (dm_v, tr_v) = if let (Some(d), Some(t)) = (self.dm_smooth, self.tr_smooth) {
87            let d_new = d - d / n + minus_dm;
88            let t_new = t - t / n + tr;
89            self.dm_smooth = Some(d_new);
90            self.tr_smooth = Some(t_new);
91            (d_new, t_new)
92        } else {
93            self.dm_seed += minus_dm;
94            self.tr_seed += tr;
95            self.seed_count += 1;
96            if self.seed_count < self.period {
97                return None;
98            }
99            self.dm_smooth = Some(self.dm_seed);
100            self.tr_smooth = Some(self.tr_seed);
101            (self.dm_seed, self.tr_seed)
102        };
103
104        let di = if tr_v == 0.0 {
105            0.0
106        } else {
107            100.0 * dm_v / tr_v
108        };
109        Some(di)
110    }
111
112    fn reset(&mut self) {
113        self.prev = None;
114        self.dm_seed = 0.0;
115        self.tr_seed = 0.0;
116        self.seed_count = 0;
117        self.dm_smooth = None;
118        self.tr_smooth = None;
119    }
120
121    fn warmup_period(&self) -> usize {
122        self.period
123    }
124
125    fn is_ready(&self) -> bool {
126        self.dm_smooth.is_some()
127    }
128
129    fn name(&self) -> &'static str {
130        "MINUS_DI"
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use crate::traits::BatchExt;
138    use approx::assert_relative_eq;
139
140    fn c(h: f64, l: f64, cl: f64) -> Candle {
141        Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
142    }
143
144    #[test]
145    fn rejects_zero_period() {
146        assert!(matches!(MinusDi::new(0), Err(Error::PeriodZero)));
147    }
148
149    #[test]
150    fn accessors_report_config() {
151        let di = MinusDi::new(7).unwrap();
152        assert_eq!(di.period(), 7);
153        assert_eq!(di.name(), "MINUS_DI");
154        assert_eq!(di.warmup_period(), 7);
155        assert!(!di.is_ready());
156    }
157
158    #[test]
159    fn downtrend_drives_minus_di_high() {
160        // Strict downtrend: -DM dominates, so -DI is large and bounded by 100.
161        let candles: Vec<Candle> = (0..12)
162            .map(|i| {
163                let base = 140.0 - f64::from(i) * 2.0;
164                c(base + 0.5, base - 1.0, base - 0.5)
165            })
166            .collect();
167        let mut di = MinusDi::new(3).unwrap();
168        let out: Vec<Option<f64>> = di.batch(&candles);
169        assert_eq!(out[0], None);
170        assert!(out[3].is_some());
171        let last = out.into_iter().flatten().last().unwrap();
172        assert!(last > 0.0 && last <= 100.0);
173        assert!(di.is_ready());
174    }
175
176    #[test]
177    fn flat_market_returns_zero() {
178        let candles: Vec<Candle> = (0..6).map(|_| c(50.0, 50.0, 50.0)).collect();
179        let mut di = MinusDi::new(3).unwrap();
180        let last = di.batch(&candles).into_iter().flatten().last().unwrap();
181        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
182    }
183
184    #[test]
185    fn reset_restores_initial_state() {
186        let candles: Vec<Candle> = (0..6)
187            .map(|i| {
188                let base = 140.0 - f64::from(i) * 2.0;
189                c(base + 0.5, base - 1.0, base - 0.5)
190            })
191            .collect();
192        let mut di = MinusDi::new(3).unwrap();
193        let _ = di.batch(&candles);
194        assert!(di.is_ready());
195        di.reset();
196        assert!(!di.is_ready());
197        assert_eq!(di.update(candles[0]), None);
198    }
199}