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 !phase.is_finite() {
71            return Err(Error::InvalidPeriod {
72                message: "JMA phase must be a finite value",
73            });
74        }
75        if !(1..=4).contains(&power) {
76            return Err(Error::InvalidPeriod {
77                message: "JMA power must be in 1..=4",
78            });
79        }
80        let len = period as f64 - 1.0;
81        let beta = 0.45 * len / (0.45 * len + 2.0);
82        let alpha = beta.powi(i32::try_from(power).expect("power is in 1..=4"));
83        let phase_ratio = (phase / 100.0 + 1.5).clamp(0.5, 2.5);
84        Ok(Self {
85            period,
86            phase,
87            power,
88            beta,
89            alpha,
90            phase_ratio,
91            e0: 0.0,
92            e1: 0.0,
93            e2: 0.0,
94            output: None,
95        })
96    }
97
98    /// Construct JMA with the popular defaults `(period = 14, phase = 0, power = 2)`.
99    pub fn classic() -> Self {
100        Self::new(14, 0.0, 2).expect("classic JMA parameters are valid")
101    }
102
103    /// Configured `(period, phase, power)`.
104    pub const fn params(&self) -> (usize, f64, u32) {
105        (self.period, self.phase, self.power)
106    }
107}
108
109impl Indicator for Jma {
110    type Input = f64;
111    type Output = f64;
112
113    fn update(&mut self, input: f64) -> Option<f64> {
114        if !input.is_finite() {
115            return self.output;
116        }
117        let Some(prev_jma) = self.output else {
118            // Seed e0 and JMA to the first input so a flat series is
119            // reproduced exactly.
120            self.e0 = input;
121            self.output = Some(input);
122            return self.output;
123        };
124        self.e0 = (1.0 - self.alpha) * input + self.alpha * self.e0;
125        self.e1 = (input - self.e0) * (1.0 - self.beta) + self.beta * self.e1;
126        let one_minus_alpha = 1.0 - self.alpha;
127        self.e2 =
128            (self.e0 + self.phase_ratio * self.e1 - prev_jma) * one_minus_alpha * one_minus_alpha
129                + self.alpha * self.alpha * self.e2;
130        let next = prev_jma + self.e2;
131        self.output = Some(next);
132        Some(next)
133    }
134
135    fn reset(&mut self) {
136        self.e0 = 0.0;
137        self.e1 = 0.0;
138        self.e2 = 0.0;
139        self.output = None;
140    }
141
142    fn warmup_period(&self) -> usize {
143        1
144    }
145
146    fn is_ready(&self) -> bool {
147        self.output.is_some()
148    }
149
150    fn name(&self) -> &'static str {
151        "JMA"
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use crate::traits::BatchExt;
159    use approx::assert_relative_eq;
160
161    #[test]
162    fn rejects_zero_period() {
163        assert!(matches!(Jma::new(0, 0.0, 2), Err(Error::PeriodZero)));
164    }
165
166    #[test]
167    fn rejects_non_finite_phase() {
168        assert!(matches!(
169            Jma::new(14, f64::NAN, 2),
170            Err(Error::InvalidPeriod { .. })
171        ));
172        assert!(matches!(
173            Jma::new(14, f64::INFINITY, 2),
174            Err(Error::InvalidPeriod { .. })
175        ));
176    }
177
178    #[test]
179    fn rejects_invalid_power() {
180        assert!(matches!(
181            Jma::new(14, 0.0, 0),
182            Err(Error::InvalidPeriod { .. })
183        ));
184        assert!(matches!(
185            Jma::new(14, 0.0, 5),
186            Err(Error::InvalidPeriod { .. })
187        ));
188    }
189
190    #[test]
191    fn accessors_and_metadata() {
192        let jma = Jma::new(14, 0.0, 2).unwrap();
193        assert_eq!(jma.params(), (14, 0.0, 2));
194        assert_eq!(jma.warmup_period(), 1);
195        assert_eq!(jma.name(), "JMA");
196    }
197
198    #[test]
199    fn classic_factory() {
200        let jma = Jma::classic();
201        assert_eq!(jma.params(), (14, 0.0, 2));
202    }
203
204    #[test]
205    fn constant_series_yields_the_constant() {
206        // Seeding e0 = JMA = first input means the recurrence stays exactly
207        // on the constant from the very first sample.
208        let mut jma = Jma::new(14, 0.0, 2).unwrap();
209        let out = jma.batch(&[42.0_f64; 60]);
210        for x in out.iter().flatten() {
211            assert_relative_eq!(*x, 42.0, epsilon = 1e-12);
212        }
213    }
214
215    #[test]
216    fn extreme_phase_is_clamped() {
217        // phase outside [-100, 100] must produce a finite JMA series (phase
218        // ratio clamps to [0.5, 2.5]) rather than blow up the recurrence.
219        let mut a = Jma::new(14, 250.0, 2).unwrap();
220        let mut b = Jma::new(14, -250.0, 2).unwrap();
221        let prices: Vec<f64> = (1..=40).map(f64::from).collect();
222        for &p in &prices {
223            let va = a.update(p).unwrap();
224            let vb = b.update(p).unwrap();
225            assert!(va.is_finite(), "JMA(phase=+250) emitted {va}");
226            assert!(vb.is_finite(), "JMA(phase=-250) emitted {vb}");
227        }
228    }
229
230    #[test]
231    fn pure_uptrend_tracks_close() {
232        // Monotonic uptrend, period 5, power 2 — after enough samples the
233        // smoothed JMA sits close to the latest input.
234        let mut jma = Jma::new(5, 0.0, 2).unwrap();
235        let prices: Vec<f64> = (1..=80).map(f64::from).collect();
236        let out = jma.batch(&prices);
237        let last = out.last().unwrap().unwrap();
238        let latest = *prices.last().unwrap();
239        assert!(
240            (latest - last).abs() < 5.0,
241            "JMA on a long clean uptrend should track close: {last} vs {latest}"
242        );
243    }
244
245    #[test]
246    fn batch_equals_streaming() {
247        let prices: Vec<f64> = (1..=80)
248            .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
249            .collect();
250        let mut a = Jma::new(14, 0.0, 2).unwrap();
251        let mut b = Jma::new(14, 0.0, 2).unwrap();
252        assert_eq!(
253            a.batch(&prices),
254            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
255        );
256    }
257
258    #[test]
259    fn reset_clears_state() {
260        let mut jma = Jma::new(14, 0.0, 2).unwrap();
261        jma.batch(&(1..=30).map(f64::from).collect::<Vec<_>>());
262        assert!(jma.is_ready());
263        jma.reset();
264        assert!(!jma.is_ready());
265        assert_eq!(jma.e0, 0.0);
266    }
267
268    #[test]
269    fn ignores_non_finite_input() {
270        let mut jma = Jma::new(14, 0.0, 2).unwrap();
271        jma.batch(&(1..=15).map(f64::from).collect::<Vec<_>>());
272        let before = jma.update(16.0).unwrap();
273        assert_eq!(jma.update(f64::NAN), Some(before));
274        assert_eq!(jma.update(f64::INFINITY), Some(before));
275    }
276
277    #[test]
278    fn period_one_is_pass_through() {
279        // beta = 0, alpha = 0 -> e2 collapses to (input - prev) and the
280        // recurrence reduces to JMA_t = input.
281        let mut jma = Jma::new(1, 0.0, 2).unwrap();
282        assert_eq!(jma.update(5.0), Some(5.0));
283        assert_relative_eq!(jma.update(10.0).unwrap(), 10.0, epsilon = 1e-12);
284        assert_relative_eq!(jma.update(7.0).unwrap(), 7.0, epsilon = 1e-12);
285    }
286}