Skip to main content

wickra_core/indicators/
jma.rs

1//! Jurik Moving Average (JMA).
2
3use crate::error::{Error, Result};
4use crate::traits::Indicator;
5
6/// Mark Jurik's adaptive moving average. The original algorithm is proprietary
7/// and Jurik Research has never published the full source. This implementation
8/// follows the widely-used three-stage filter reconstruction circulated since
9/// the 1999 TASC article on the indicator — the same form used by most
10/// open-source ports (`TradingView` Pine, `pandas-ta`, various MQL ports):
11///
12/// ```text
13/// beta        = 0.45 * (period - 1) / (0.45 * (period - 1) + 2)
14/// alpha       = beta ^ power
15/// phase_ratio = clamp(phase / 100 + 1.5, 0.5, 2.5)
16///
17/// e0_t = (1 - alpha) * x_t + alpha * e0_{t-1}
18/// e1_t = (x_t - e0_t) * (1 - beta) + beta * e1_{t-1}
19/// e2_t = (e0_t + phase_ratio * e1_t - JMA_{t-1}) * (1 - alpha)^2 + alpha^2 * e2_{t-1}
20/// JMA_t = JMA_{t-1} + e2_t
21/// ```
22///
23/// The state is seeded by setting `e0 = JMA = first input`, so a constant
24/// input stream is reproduced exactly from the first output onward.
25///
26/// # Parameters
27///
28/// - `period`: smoothing length (default 14).
29/// - `phase`: phase shift in `[-100, 100]`. Values outside this range are
30///   clamped to the boundary `phase_ratio` so the constructor never fails on
31///   a finite `phase`.
32/// - `power`: kernel exponent in `1..=4` (default 2 matches the popular
33///   reconstruction).
34///
35/// # Example
36///
37/// ```
38/// use wickra_core::{Indicator, Jma};
39///
40/// let mut jma = Jma::new(14, 0.0, 2).unwrap();
41/// let mut last = None;
42/// for i in 0..40 {
43///     last = jma.update(100.0 + f64::from(i));
44/// }
45/// assert!(last.is_some());
46/// ```
47#[derive(Debug, Clone)]
48pub struct Jma {
49    period: usize,
50    phase: f64,
51    power: u32,
52    beta: f64,
53    alpha: f64,
54    phase_ratio: f64,
55    e0: f64,
56    e1: f64,
57    e2: f64,
58    output: Option<f64>,
59}
60
61impl Jma {
62    /// # Errors
63    /// - [`Error::PeriodZero`] if `period == 0`.
64    /// - [`Error::InvalidPeriod`] if `phase` is non-finite or `power` is
65    ///   outside `1..=4`.
66    pub fn new(period: usize, phase: f64, power: u32) -> Result<Self> {
67        if period == 0 {
68            return Err(Error::PeriodZero);
69        }
70        if period > crate::error::MAX_PERIOD {
71            return Err(Error::InvalidPeriod {
72                message: crate::error::PERIOD_ABOVE_MAX,
73            });
74        }
75        if !phase.is_finite() {
76            return Err(Error::InvalidPeriod {
77                message: "JMA phase must be a finite value",
78            });
79        }
80        if !(1..=4).contains(&power) {
81            return Err(Error::InvalidPeriod {
82                message: "JMA power must be in 1..=4",
83            });
84        }
85        let len = period as f64 - 1.0;
86        let beta = 0.45 * len / (0.45 * len + 2.0);
87        let alpha = beta.powi(i32::try_from(power).expect("power is in 1..=4"));
88        let phase_ratio = (phase / 100.0 + 1.5).clamp(0.5, 2.5);
89        Ok(Self {
90            period,
91            phase,
92            power,
93            beta,
94            alpha,
95            phase_ratio,
96            e0: 0.0,
97            e1: 0.0,
98            e2: 0.0,
99            output: None,
100        })
101    }
102
103    /// Construct JMA with the popular defaults `(period = 14, phase = 0, power = 2)`.
104    pub fn classic() -> Self {
105        Self::new(14, 0.0, 2).expect("classic JMA parameters are valid")
106    }
107
108    /// Configured `(period, phase, power)`.
109    pub const fn params(&self) -> (usize, f64, u32) {
110        (self.period, self.phase, self.power)
111    }
112}
113
114impl Indicator for Jma {
115    type Input = f64;
116    type Output = f64;
117
118    #[inline]
119    fn update(&mut self, input: f64) -> Option<f64> {
120        if !input.is_finite() {
121            return None;
122        }
123        let Some(prev_jma) = self.output else {
124            // Seed e0 and JMA to the first input so a flat series is
125            // reproduced exactly.
126            self.e0 = input;
127            self.output = Some(input);
128            return self.output;
129        };
130        self.e0 = (1.0 - self.alpha) * input + self.alpha * self.e0;
131        self.e1 = (input - self.e0) * (1.0 - self.beta) + self.beta * self.e1;
132        let one_minus_alpha = 1.0 - self.alpha;
133        self.e2 =
134            (self.e0 + self.phase_ratio * self.e1 - prev_jma) * one_minus_alpha * one_minus_alpha
135                + self.alpha * self.alpha * self.e2;
136        let next = prev_jma + self.e2;
137        self.output = Some(next);
138        Some(next)
139    }
140
141    fn reset(&mut self) {
142        self.e0 = 0.0;
143        self.e1 = 0.0;
144        self.e2 = 0.0;
145        self.output = None;
146    }
147
148    #[inline]
149    fn warmup_period(&self) -> usize {
150        1
151    }
152
153    #[inline]
154    fn is_ready(&self) -> bool {
155        self.output.is_some()
156    }
157
158    #[inline]
159    fn name(&self) -> &'static str {
160        "JMA"
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use crate::traits::BatchExt;
168    use approx::assert_relative_eq;
169
170    #[test]
171    fn rejects_zero_period() {
172        assert!(matches!(Jma::new(0, 0.0, 2), Err(Error::PeriodZero)));
173    }
174
175    #[test]
176    fn rejects_non_finite_phase() {
177        assert!(matches!(
178            Jma::new(14, f64::NAN, 2),
179            Err(Error::InvalidPeriod { .. })
180        ));
181        assert!(matches!(
182            Jma::new(14, f64::INFINITY, 2),
183            Err(Error::InvalidPeriod { .. })
184        ));
185    }
186
187    #[test]
188    fn rejects_invalid_power() {
189        assert!(matches!(
190            Jma::new(14, 0.0, 0),
191            Err(Error::InvalidPeriod { .. })
192        ));
193        assert!(matches!(
194            Jma::new(14, 0.0, 5),
195            Err(Error::InvalidPeriod { .. })
196        ));
197    }
198
199    #[test]
200    fn accessors_and_metadata() {
201        let jma = Jma::new(14, 0.0, 2).unwrap();
202        assert_eq!(jma.params(), (14, 0.0, 2));
203        assert_eq!(jma.warmup_period(), 1);
204        assert_eq!(jma.name(), "JMA");
205    }
206
207    #[test]
208    fn classic_factory() {
209        let jma = Jma::classic();
210        assert_eq!(jma.params(), (14, 0.0, 2));
211    }
212
213    #[test]
214    fn constant_series_yields_the_constant() {
215        // Seeding e0 = JMA = first input means the recurrence stays exactly
216        // on the constant from the very first sample.
217        let mut jma = Jma::new(14, 0.0, 2).unwrap();
218        let out = jma.batch(&[42.0_f64; 60]);
219        for x in out.iter().flatten() {
220            assert_relative_eq!(*x, 42.0, epsilon = 1e-12);
221        }
222    }
223
224    #[test]
225    fn extreme_phase_is_clamped() {
226        // phase outside [-100, 100] must produce a finite JMA series (phase
227        // ratio clamps to [0.5, 2.5]) rather than blow up the recurrence.
228        let mut a = Jma::new(14, 250.0, 2).unwrap();
229        let mut b = Jma::new(14, -250.0, 2).unwrap();
230        let prices: Vec<f64> = (1..=40).map(f64::from).collect();
231        for &p in &prices {
232            let va = a.update(p).unwrap();
233            let vb = b.update(p).unwrap();
234            assert!(va.is_finite(), "JMA(phase=+250) emitted {va}");
235            assert!(vb.is_finite(), "JMA(phase=-250) emitted {vb}");
236        }
237    }
238
239    #[test]
240    fn pure_uptrend_tracks_close() {
241        // Monotonic uptrend, period 5, power 2 — after enough samples the
242        // smoothed JMA sits close to the latest input.
243        let mut jma = Jma::new(5, 0.0, 2).unwrap();
244        let prices: Vec<f64> = (1..=80).map(f64::from).collect();
245        let out = jma.batch(&prices);
246        let last = out.last().unwrap().unwrap();
247        let latest = *prices.last().unwrap();
248        assert!(
249            (latest - last).abs() < 5.0,
250            "JMA on a long clean uptrend should track close: {last} vs {latest}"
251        );
252    }
253
254    #[test]
255    fn batch_equals_streaming() {
256        let prices: Vec<f64> = (1..=80)
257            .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
258            .collect();
259        let mut a = Jma::new(14, 0.0, 2).unwrap();
260        let mut b = Jma::new(14, 0.0, 2).unwrap();
261        assert_eq!(
262            a.batch(&prices),
263            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
264        );
265    }
266
267    #[test]
268    fn reset_clears_state() {
269        let mut jma = Jma::new(14, 0.0, 2).unwrap();
270        jma.batch(&(1..=30).map(f64::from).collect::<Vec<_>>());
271        assert!(jma.is_ready());
272        jma.reset();
273        assert!(!jma.is_ready());
274        assert_eq!(jma.e0, 0.0);
275    }
276
277    #[test]
278    fn ignores_non_finite_input() {
279        let mut jma = Jma::new(14, 0.0, 2).unwrap();
280        jma.batch(&(1..=15).map(f64::from).collect::<Vec<_>>());
281        jma.update(16.0).unwrap();
282        assert_eq!(jma.update(f64::NAN), None);
283        assert_eq!(jma.update(f64::INFINITY), None);
284    }
285
286    #[test]
287    fn period_one_is_pass_through() {
288        // beta = 0, alpha = 0 -> e2 collapses to (input - prev) and the
289        // recurrence reduces to JMA_t = input.
290        let mut jma = Jma::new(1, 0.0, 2).unwrap();
291        assert_eq!(jma.update(5.0), Some(5.0));
292        assert_relative_eq!(jma.update(10.0).unwrap(), 10.0, epsilon = 1e-12);
293        assert_relative_eq!(jma.update(7.0).unwrap(), 7.0, epsilon = 1e-12);
294    }
295}