Skip to main content

wickra_core/indicators/
plus_dm.rs

1//! Plus 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 Plus Directional Movement (`PLUS_DM`).
9///
10/// The raw plus directional movement of a bar is `max(high − high_prev, 0)` when
11/// the up-move exceeds the down-move `low_prev − low`, 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/// [`PlusDi`](crate::PlusDi).
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, PlusDm};
25///
26/// let mut indicator = PlusDm::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 PlusDm {
38    period: usize,
39    prev: Option<Candle>,
40    seed: f64,
41    seed_count: usize,
42    smooth: Option<f64>,
43}
44
45impl PlusDm {
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        if period > crate::error::MAX_PERIOD {
53            return Err(Error::InvalidPeriod {
54                message: crate::error::PERIOD_ABOVE_MAX,
55            });
56        }
57        Ok(Self {
58            period,
59            prev: None,
60            seed: 0.0,
61            seed_count: 0,
62            smooth: None,
63        })
64    }
65
66    /// Configured period.
67    pub const fn period(&self) -> usize {
68        self.period
69    }
70}
71
72impl Indicator for PlusDm {
73    type Input = Candle;
74    type Output = f64;
75
76    #[inline]
77    fn update(&mut self, candle: Candle) -> Option<f64> {
78        let Some(prev) = self.prev else {
79            self.prev = Some(candle);
80            return None;
81        };
82        self.prev = Some(candle);
83
84        let (plus_dm, _) = directional_movement(&prev, &candle);
85        let n = self.period as f64;
86
87        if let Some(s) = self.smooth {
88            let s_new = s - s / n + plus_dm;
89            self.smooth = Some(s_new);
90            return Some(s_new);
91        }
92
93        self.seed += plus_dm;
94        self.seed_count += 1;
95        if self.seed_count < self.period {
96            return None;
97        }
98        self.smooth = Some(self.seed);
99        Some(self.seed)
100    }
101
102    fn reset(&mut self) {
103        self.prev = None;
104        self.seed = 0.0;
105        self.seed_count = 0;
106        self.smooth = None;
107    }
108
109    #[inline]
110    fn warmup_period(&self) -> usize {
111        self.period + 1
112    }
113
114    #[inline]
115    fn is_ready(&self) -> bool {
116        self.smooth.is_some()
117    }
118
119    #[inline]
120    fn name(&self) -> &'static str {
121        "PLUS_DM"
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use crate::traits::BatchExt;
129    use approx::assert_relative_eq;
130
131    /// Candle with explicit high/low; open and close are pinned to `cl`.
132    fn c(h: f64, l: f64, cl: f64) -> Candle {
133        Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
134    }
135
136    #[test]
137    fn rejects_zero_period() {
138        assert!(matches!(PlusDm::new(0), Err(Error::PeriodZero)));
139    }
140
141    #[test]
142    fn accessors_report_config() {
143        let dm = PlusDm::new(7).unwrap();
144        assert_eq!(dm.period(), 7);
145        assert_eq!(dm.name(), "PLUS_DM");
146        assert_eq!(dm.warmup_period(), 8);
147        assert!(!dm.is_ready());
148    }
149
150    #[test]
151    fn warmup_period_matches_the_first_emitted_value() {
152        // The first candle only seeds `prev`, so seeding starts on bar 2 and the
153        // first value lands on bar `period + 1`. Pin that against the declared
154        // warmup so the two can never drift apart again.
155        let candles: Vec<Candle> = (0..12)
156            .map(|i| {
157                let x = f64::from(i);
158                c(11.0 + x, 9.0 + 0.5 * x, 10.0 + x)
159            })
160            .collect();
161        for period in 1..=5 {
162            let mut dm = PlusDm::new(period).unwrap();
163            let out: Vec<Option<f64>> = dm.batch(&candles);
164            let first = out.iter().position(Option::is_some).unwrap();
165            assert_eq!(first + 1, dm.warmup_period());
166        }
167    }
168
169    #[test]
170    fn seeds_then_smooths_a_constant_plus_dm() {
171        // High rises by 1 each bar (up = +1); low rises by 0.5 each bar, so the
172        // down-move is negative and +DM equals the up-move (1.0) on every bar.
173        let candles: Vec<Candle> = (0..5)
174            .map(|i| {
175                c(
176                    11.0 + f64::from(i),
177                    9.0 + 0.5 * f64::from(i),
178                    10.0 + f64::from(i),
179                )
180            })
181            .collect();
182        let mut dm = PlusDm::new(3).unwrap();
183        let out: Vec<Option<f64>> = dm.batch(&candles);
184        // First candle only sets the previous bar; bars 2-3 seed the sum.
185        assert_eq!(out[0], None);
186        assert_eq!(out[1], None);
187        assert_eq!(out[2], None);
188        // Seed = sum of three unit +DM values.
189        assert_relative_eq!(out[3].unwrap(), 3.0, epsilon = 1e-12);
190        // Wilder step: 3 - 3/3 + 1 = 3.
191        assert_relative_eq!(out[4].unwrap(), 3.0, epsilon = 1e-12);
192        assert!(dm.is_ready());
193    }
194
195    #[test]
196    fn down_moves_contribute_zero() {
197        // Strict downtrend: highs fall, so every raw +DM is zero and the smoothed
198        // total stays at zero.
199        let candles: Vec<Candle> = (0..6)
200            .map(|i| c(20.0 - f64::from(i), 5.0 - f64::from(i), 12.0 - f64::from(i)))
201            .collect();
202        let mut dm = PlusDm::new(3).unwrap();
203        let last = dm.batch(&candles).into_iter().flatten().last().unwrap();
204        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
205    }
206
207    #[test]
208    fn reset_restores_initial_state() {
209        let candles: Vec<Candle> = (0..5)
210            .map(|i| {
211                c(
212                    11.0 + f64::from(i),
213                    9.0 + 0.5 * f64::from(i),
214                    10.0 + f64::from(i),
215                )
216            })
217            .collect();
218        let mut dm = PlusDm::new(3).unwrap();
219        let _ = dm.batch(&candles);
220        assert!(dm.is_ready());
221        dm.reset();
222        assert!(!dm.is_ready());
223        assert_eq!(dm.update(candles[0]), None);
224    }
225}