wickra-core 0.4.1

Core streaming-first technical indicators engine for the Wickra library
Documentation
//! Jurik Moving Average (JMA).

use crate::error::{Error, Result};
use crate::traits::Indicator;

/// Mark Jurik's adaptive moving average. The original algorithm is proprietary
/// and Jurik Research has never published the full source. This implementation
/// follows the widely-used three-stage filter reconstruction circulated since
/// the 1999 TASC article on the indicator — the same form used by most
/// open-source ports (`TradingView` Pine, `pandas-ta`, various MQL ports):
///
/// ```text
/// beta        = 0.45 * (period - 1) / (0.45 * (period - 1) + 2)
/// alpha       = beta ^ power
/// phase_ratio = clamp(phase / 100 + 1.5, 0.5, 2.5)
///
/// e0_t = (1 - alpha) * x_t + alpha * e0_{t-1}
/// e1_t = (x_t - e0_t) * (1 - beta) + beta * e1_{t-1}
/// e2_t = (e0_t + phase_ratio * e1_t - JMA_{t-1}) * (1 - alpha)^2 + alpha^2 * e2_{t-1}
/// JMA_t = JMA_{t-1} + e2_t
/// ```
///
/// The state is seeded by setting `e0 = JMA = first input`, so a constant
/// input stream is reproduced exactly from the first output onward.
///
/// # Parameters
///
/// - `period`: smoothing length (default 14).
/// - `phase`: phase shift in `[-100, 100]`. Values outside this range are
///   clamped to the boundary `phase_ratio` so the constructor never fails on
///   a finite `phase`.
/// - `power`: kernel exponent in `1..=4` (default 2 matches the popular
///   reconstruction).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Jma};
///
/// let mut jma = Jma::new(14, 0.0, 2).unwrap();
/// let mut last = None;
/// for i in 0..40 {
///     last = jma.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Jma {
    period: usize,
    phase: f64,
    power: u32,
    beta: f64,
    alpha: f64,
    phase_ratio: f64,
    e0: f64,
    e1: f64,
    e2: f64,
    output: Option<f64>,
}

impl Jma {
    /// # Errors
    /// - [`Error::PeriodZero`] if `period == 0`.
    /// - [`Error::InvalidPeriod`] if `phase` is non-finite or `power` is
    ///   outside `1..=4`.
    pub fn new(period: usize, phase: f64, power: u32) -> Result<Self> {
        if period == 0 {
            return Err(Error::PeriodZero);
        }
        if !phase.is_finite() {
            return Err(Error::InvalidPeriod {
                message: "JMA phase must be a finite value",
            });
        }
        if !(1..=4).contains(&power) {
            return Err(Error::InvalidPeriod {
                message: "JMA power must be in 1..=4",
            });
        }
        let len = period as f64 - 1.0;
        let beta = 0.45 * len / (0.45 * len + 2.0);
        let alpha = beta.powi(i32::try_from(power).expect("power is in 1..=4"));
        let phase_ratio = (phase / 100.0 + 1.5).clamp(0.5, 2.5);
        Ok(Self {
            period,
            phase,
            power,
            beta,
            alpha,
            phase_ratio,
            e0: 0.0,
            e1: 0.0,
            e2: 0.0,
            output: None,
        })
    }

    /// Construct JMA with the popular defaults `(period = 14, phase = 0, power = 2)`.
    pub fn classic() -> Self {
        Self::new(14, 0.0, 2).expect("classic JMA parameters are valid")
    }

    /// Configured `(period, phase, power)`.
    pub const fn params(&self) -> (usize, f64, u32) {
        (self.period, self.phase, self.power)
    }
}

impl Indicator for Jma {
    type Input = f64;
    type Output = f64;

    fn update(&mut self, input: f64) -> Option<f64> {
        if !input.is_finite() {
            return self.output;
        }
        let Some(prev_jma) = self.output else {
            // Seed e0 and JMA to the first input so a flat series is
            // reproduced exactly.
            self.e0 = input;
            self.output = Some(input);
            return self.output;
        };
        self.e0 = (1.0 - self.alpha) * input + self.alpha * self.e0;
        self.e1 = (input - self.e0) * (1.0 - self.beta) + self.beta * self.e1;
        let one_minus_alpha = 1.0 - self.alpha;
        self.e2 =
            (self.e0 + self.phase_ratio * self.e1 - prev_jma) * one_minus_alpha * one_minus_alpha
                + self.alpha * self.alpha * self.e2;
        let next = prev_jma + self.e2;
        self.output = Some(next);
        Some(next)
    }

    fn reset(&mut self) {
        self.e0 = 0.0;
        self.e1 = 0.0;
        self.e2 = 0.0;
        self.output = None;
    }

    fn warmup_period(&self) -> usize {
        1
    }

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

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

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

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

    #[test]
    fn rejects_non_finite_phase() {
        assert!(matches!(
            Jma::new(14, f64::NAN, 2),
            Err(Error::InvalidPeriod { .. })
        ));
        assert!(matches!(
            Jma::new(14, f64::INFINITY, 2),
            Err(Error::InvalidPeriod { .. })
        ));
    }

    #[test]
    fn rejects_invalid_power() {
        assert!(matches!(
            Jma::new(14, 0.0, 0),
            Err(Error::InvalidPeriod { .. })
        ));
        assert!(matches!(
            Jma::new(14, 0.0, 5),
            Err(Error::InvalidPeriod { .. })
        ));
    }

    #[test]
    fn accessors_and_metadata() {
        let jma = Jma::new(14, 0.0, 2).unwrap();
        assert_eq!(jma.params(), (14, 0.0, 2));
        assert_eq!(jma.warmup_period(), 1);
        assert_eq!(jma.name(), "JMA");
    }

    #[test]
    fn classic_factory() {
        let jma = Jma::classic();
        assert_eq!(jma.params(), (14, 0.0, 2));
    }

    #[test]
    fn constant_series_yields_the_constant() {
        // Seeding e0 = JMA = first input means the recurrence stays exactly
        // on the constant from the very first sample.
        let mut jma = Jma::new(14, 0.0, 2).unwrap();
        let out = jma.batch(&[42.0_f64; 60]);
        for x in out.iter().flatten() {
            assert_relative_eq!(*x, 42.0, epsilon = 1e-12);
        }
    }

    #[test]
    fn extreme_phase_is_clamped() {
        // phase outside [-100, 100] must produce a finite JMA series (phase
        // ratio clamps to [0.5, 2.5]) rather than blow up the recurrence.
        let mut a = Jma::new(14, 250.0, 2).unwrap();
        let mut b = Jma::new(14, -250.0, 2).unwrap();
        let prices: Vec<f64> = (1..=40).map(f64::from).collect();
        for &p in &prices {
            let va = a.update(p).unwrap();
            let vb = b.update(p).unwrap();
            assert!(va.is_finite(), "JMA(phase=+250) emitted {va}");
            assert!(vb.is_finite(), "JMA(phase=-250) emitted {vb}");
        }
    }

    #[test]
    fn pure_uptrend_tracks_close() {
        // Monotonic uptrend, period 5, power 2 — after enough samples the
        // smoothed JMA sits close to the latest input.
        let mut jma = Jma::new(5, 0.0, 2).unwrap();
        let prices: Vec<f64> = (1..=80).map(f64::from).collect();
        let out = jma.batch(&prices);
        let last = out.last().unwrap().unwrap();
        let latest = *prices.last().unwrap();
        assert!(
            (latest - last).abs() < 5.0,
            "JMA on a long clean uptrend should track close: {last} vs {latest}"
        );
    }

    #[test]
    fn batch_equals_streaming() {
        let prices: Vec<f64> = (1..=80)
            .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
            .collect();
        let mut a = Jma::new(14, 0.0, 2).unwrap();
        let mut b = Jma::new(14, 0.0, 2).unwrap();
        assert_eq!(
            a.batch(&prices),
            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
        );
    }

    #[test]
    fn reset_clears_state() {
        let mut jma = Jma::new(14, 0.0, 2).unwrap();
        jma.batch(&(1..=30).map(f64::from).collect::<Vec<_>>());
        assert!(jma.is_ready());
        jma.reset();
        assert!(!jma.is_ready());
        assert_eq!(jma.e0, 0.0);
    }

    #[test]
    fn ignores_non_finite_input() {
        let mut jma = Jma::new(14, 0.0, 2).unwrap();
        jma.batch(&(1..=15).map(f64::from).collect::<Vec<_>>());
        let before = jma.update(16.0).unwrap();
        assert_eq!(jma.update(f64::NAN), Some(before));
        assert_eq!(jma.update(f64::INFINITY), Some(before));
    }

    #[test]
    fn period_one_is_pass_through() {
        // beta = 0, alpha = 0 -> e2 collapses to (input - prev) and the
        // recurrence reduces to JMA_t = input.
        let mut jma = Jma::new(1, 0.0, 2).unwrap();
        assert_eq!(jma.update(5.0), Some(5.0));
        assert_relative_eq!(jma.update(10.0).unwrap(), 10.0, epsilon = 1e-12);
        assert_relative_eq!(jma.update(7.0).unwrap(), 7.0, epsilon = 1e-12);
    }
}