wickra-core 0.4.6

Core streaming-first technical indicators engine for the Wickra library
Documentation
//! Plus Directional Movement (+DM), Wilder-smoothed.

use crate::error::{Error, Result};
use crate::indicators::adx::directional_movement;
use crate::ohlcv::Candle;
use crate::traits::Indicator;

/// Wilder's Plus Directional Movement (`PLUS_DM`).
///
/// The raw plus directional movement of a bar is `max(high − high_prev, 0)` when
/// the up-move exceeds the down-move `low_prev − low`, and `0` otherwise. This
/// indicator returns the Wilder-smoothed running total of that raw `+DM` over
/// `period` bars, the same accumulation that feeds [`Adx`](crate::Adx) and
/// [`PlusDi`](crate::PlusDi).
///
/// The first `period` raw values seed the sum; from then on each update applies
/// the Wilder recursion `smoothed − smoothed / period + raw`. Because a bar's
/// directional movement needs the previous bar, the first value is emitted after
/// `period + 1` candles.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, PlusDm};
///
/// let mut indicator = PlusDm::new(5).unwrap();
/// let mut last = None;
/// for i in 0..40 {
///     let base = 100.0 + f64::from(i);
///     let candle =
///         Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
///     last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct PlusDm {
    period: usize,
    prev: Option<Candle>,
    seed: f64,
    seed_count: usize,
    smooth: Option<f64>,
}

impl PlusDm {
    /// # Errors
    /// Returns [`Error::PeriodZero`] if `period == 0`.
    pub fn new(period: usize) -> Result<Self> {
        if period == 0 {
            return Err(Error::PeriodZero);
        }
        Ok(Self {
            period,
            prev: None,
            seed: 0.0,
            seed_count: 0,
            smooth: None,
        })
    }

    /// Configured period.
    pub const fn period(&self) -> usize {
        self.period
    }
}

impl Indicator for PlusDm {
    type Input = Candle;
    type Output = f64;

    fn update(&mut self, candle: Candle) -> Option<f64> {
        let Some(prev) = self.prev else {
            self.prev = Some(candle);
            return None;
        };
        self.prev = Some(candle);

        let (plus_dm, _) = directional_movement(&prev, &candle);
        let n = self.period as f64;

        if let Some(s) = self.smooth {
            let s_new = s - s / n + plus_dm;
            self.smooth = Some(s_new);
            return Some(s_new);
        }

        self.seed += plus_dm;
        self.seed_count += 1;
        if self.seed_count < self.period {
            return None;
        }
        self.smooth = Some(self.seed);
        Some(self.seed)
    }

    fn reset(&mut self) {
        self.prev = None;
        self.seed = 0.0;
        self.seed_count = 0;
        self.smooth = None;
    }

    fn warmup_period(&self) -> usize {
        self.period
    }

    fn is_ready(&self) -> bool {
        self.smooth.is_some()
    }

    fn name(&self) -> &'static str {
        "PLUS_DM"
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::traits::BatchExt;
    use approx::assert_relative_eq;

    /// Candle with explicit high/low; open and close are pinned to `cl`.
    fn c(h: f64, l: f64, cl: f64) -> Candle {
        Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
    }

    #[test]
    fn rejects_zero_period() {
        assert!(matches!(PlusDm::new(0), Err(Error::PeriodZero)));
    }

    #[test]
    fn accessors_report_config() {
        let dm = PlusDm::new(7).unwrap();
        assert_eq!(dm.period(), 7);
        assert_eq!(dm.name(), "PLUS_DM");
        assert_eq!(dm.warmup_period(), 7);
        assert!(!dm.is_ready());
    }

    #[test]
    fn seeds_then_smooths_a_constant_plus_dm() {
        // High rises by 1 each bar (up = +1); low rises by 0.5 each bar, so the
        // down-move is negative and +DM equals the up-move (1.0) on every bar.
        let candles: Vec<Candle> = (0..5)
            .map(|i| {
                c(
                    11.0 + f64::from(i),
                    9.0 + 0.5 * f64::from(i),
                    10.0 + f64::from(i),
                )
            })
            .collect();
        let mut dm = PlusDm::new(3).unwrap();
        let out: Vec<Option<f64>> = dm.batch(&candles);
        // First candle only sets the previous bar; bars 2-3 seed the sum.
        assert_eq!(out[0], None);
        assert_eq!(out[1], None);
        assert_eq!(out[2], None);
        // Seed = sum of three unit +DM values.
        assert_relative_eq!(out[3].unwrap(), 3.0, epsilon = 1e-12);
        // Wilder step: 3 - 3/3 + 1 = 3.
        assert_relative_eq!(out[4].unwrap(), 3.0, epsilon = 1e-12);
        assert!(dm.is_ready());
    }

    #[test]
    fn down_moves_contribute_zero() {
        // Strict downtrend: highs fall, so every raw +DM is zero and the smoothed
        // total stays at zero.
        let candles: Vec<Candle> = (0..6)
            .map(|i| c(20.0 - f64::from(i), 5.0 - f64::from(i), 12.0 - f64::from(i)))
            .collect();
        let mut dm = PlusDm::new(3).unwrap();
        let last = dm.batch(&candles).into_iter().flatten().last().unwrap();
        assert_relative_eq!(last, 0.0, epsilon = 1e-12);
    }

    #[test]
    fn reset_restores_initial_state() {
        let candles: Vec<Candle> = (0..5)
            .map(|i| {
                c(
                    11.0 + f64::from(i),
                    9.0 + 0.5 * f64::from(i),
                    10.0 + f64::from(i),
                )
            })
            .collect();
        let mut dm = PlusDm::new(3).unwrap();
        let _ = dm.batch(&candles);
        assert!(dm.is_ready());
        dm.reset();
        assert!(!dm.is_ready());
        assert_eq!(dm.update(candles[0]), None);
    }
}