Skip to main content

wickra_core/indicators/
mcginley_dynamic.rs

1//! `McGinley` Dynamic — self-adjusting moving average.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// John `McGinley`'s "Dynamic" — a self-adjusting moving average that speeds up
9/// in downtrends and slows down in uptrends to track price more closely than
10/// a fixed-period MA.
11///
12/// The recurrence is
13///
14/// ```text
15/// MD_t = MD_{t-1} + (price_t - MD_{t-1}) / (K * period * (price_t / MD_{t-1})^4)
16/// ```
17///
18/// where `K = 0.6` is `McGinley`'s original constant. The fourth-power ratio
19/// term shrinks the divisor when price falls below the indicator (faster
20/// catch-up) and inflates it when price runs above (more smoothing). The
21/// indicator is seeded with the simple average of the first `period` inputs.
22///
23/// Reference: John R. `McGinley` Jr., *Technical Analysis of Stocks &
24/// Commodities*, 1990.
25///
26/// # Example
27///
28/// ```
29/// use wickra_core::{Indicator, McGinleyDynamic};
30///
31/// let mut md = McGinleyDynamic::new(10).unwrap();
32/// let mut last = None;
33/// for i in 0..40 {
34///     last = md.update(100.0 + f64::from(i));
35/// }
36/// assert!(last.is_some());
37/// ```
38#[derive(Debug, Clone)]
39pub struct McGinleyDynamic {
40    period: usize,
41    seed: VecDeque<f64>,
42    seed_sum: f64,
43    current: Option<f64>,
44}
45
46/// `McGinley`'s original constant `K` in the recurrence denominator.
47const K: f64 = 0.6;
48
49impl McGinleyDynamic {
50    /// # Errors
51    /// Returns [`Error::PeriodZero`] if `period == 0`.
52    pub fn new(period: usize) -> Result<Self> {
53        if period == 0 {
54            return Err(Error::PeriodZero);
55        }
56        if period > crate::error::MAX_PERIOD {
57            return Err(Error::InvalidPeriod {
58                message: crate::error::PERIOD_ABOVE_MAX,
59            });
60        }
61        Ok(Self {
62            period,
63            seed: VecDeque::with_capacity(period),
64            seed_sum: 0.0,
65            current: None,
66        })
67    }
68
69    /// Configured period.
70    pub const fn period(&self) -> usize {
71        self.period
72    }
73
74    /// Current value if available.
75    pub const fn value(&self) -> Option<f64> {
76        self.current
77    }
78}
79
80impl Indicator for McGinleyDynamic {
81    type Input = f64;
82    type Output = f64;
83
84    #[inline]
85    fn update(&mut self, input: f64) -> Option<f64> {
86        if !input.is_finite() {
87            return None;
88        }
89        if let Some(prev) = self.current {
90            // The recurrence divides by `(price / prev)^4`; if either side is
91            // zero or negative the formula blows up, so we hold the previous
92            // value as a defensive fallback against degenerate price series.
93            if prev <= 0.0 || input <= 0.0 {
94                return self.current;
95            }
96            let ratio = input / prev;
97            let divisor = K * (self.period as f64) * ratio.powi(4);
98            let next = prev + (input - prev) / divisor;
99            self.current = Some(next);
100        } else {
101            self.seed.push_back(input);
102            self.seed_sum += input;
103            if self.seed.len() == self.period {
104                self.current = Some(self.seed_sum / self.period as f64);
105            }
106        }
107        self.current
108    }
109
110    fn reset(&mut self) {
111        self.seed.clear();
112        self.seed_sum = 0.0;
113        self.current = None;
114    }
115
116    #[inline]
117    fn warmup_period(&self) -> usize {
118        self.period
119    }
120
121    #[inline]
122    fn is_ready(&self) -> bool {
123        self.current.is_some()
124    }
125
126    #[inline]
127    fn name(&self) -> &'static str {
128        "McGinleyDynamic"
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use crate::traits::BatchExt;
136    use approx::assert_relative_eq;
137
138    #[test]
139    fn rejects_zero_period() {
140        assert!(matches!(McGinleyDynamic::new(0), Err(Error::PeriodZero)));
141    }
142
143    #[test]
144    fn accessors_and_metadata() {
145        let mut md = McGinleyDynamic::new(10).unwrap();
146        assert_eq!(md.period(), 10);
147        assert_eq!(md.warmup_period(), 10);
148        assert_eq!(md.name(), "McGinleyDynamic");
149        assert_eq!(md.value(), None);
150        for i in 1..=10 {
151            md.update(f64::from(i));
152        }
153        assert!(md.value().is_some());
154    }
155
156    #[test]
157    fn constant_series_yields_the_constant() {
158        // ratio = 1, so the recurrence collapses to MD + 0 / divisor = MD.
159        let mut md = McGinleyDynamic::new(5).unwrap();
160        let out = md.batch(&[42.0_f64; 30]);
161        for v in out.iter().skip(4).flatten() {
162            assert_relative_eq!(*v, 42.0, epsilon = 1e-12);
163        }
164    }
165
166    #[test]
167    fn warmup_emits_first_value_at_period() {
168        let mut md = McGinleyDynamic::new(3).unwrap();
169        // Seed = SMA([10, 20, 30]) = 20.0.
170        assert_eq!(md.update(10.0), None);
171        assert_eq!(md.update(20.0), None);
172        assert_eq!(md.update(30.0), Some(20.0));
173    }
174
175    #[test]
176    fn reference_value_recurrence() {
177        // Period 3, seed = SMA([10, 20, 30]) = 20.0. Then on price = 40.0:
178        //   ratio   = 40 / 20 = 2
179        //   divisor = 0.6 * 3 * 2^4 = 0.6 * 3 * 16 = 28.8
180        //   next    = 20 + (40 - 20) / 28.8 = 20.694444...
181        let mut md = McGinleyDynamic::new(3).unwrap();
182        md.batch(&[10.0_f64, 20.0, 30.0]);
183        let v = md.update(40.0).unwrap();
184        let expected = 20.0 + 20.0 / (0.6 * 3.0 * 16.0);
185        assert_relative_eq!(v, expected, epsilon = 1e-12);
186    }
187
188    #[test]
189    fn batch_equals_streaming() {
190        let prices: Vec<f64> = (1..=80)
191            .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
192            .collect();
193        let mut a = McGinleyDynamic::new(10).unwrap();
194        let mut b = McGinleyDynamic::new(10).unwrap();
195        assert_eq!(
196            a.batch(&prices),
197            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
198        );
199    }
200
201    #[test]
202    fn reset_clears_state() {
203        let mut md = McGinleyDynamic::new(5).unwrap();
204        md.batch(&(1..=30).map(f64::from).collect::<Vec<_>>());
205        assert!(md.is_ready());
206        md.reset();
207        assert!(!md.is_ready());
208        assert_eq!(md.update(1.0), None);
209    }
210
211    #[test]
212    fn ignores_non_finite_input() {
213        let mut md = McGinleyDynamic::new(3).unwrap();
214        md.batch(&[10.0_f64, 20.0, 30.0]);
215        md.value().unwrap();
216        assert_eq!(md.update(f64::NAN), None);
217        assert_eq!(md.update(f64::INFINITY), None);
218    }
219
220    #[test]
221    fn holds_value_when_input_is_non_positive() {
222        // Defensive: a zero or negative price would make the (price/prev)^4
223        // divisor zero or otherwise blow up; the recurrence holds steady.
224        let mut md = McGinleyDynamic::new(3).unwrap();
225        md.batch(&[10.0_f64, 20.0, 30.0]);
226        let before = md.value().unwrap();
227        assert_eq!(md.update(0.0), Some(before));
228        assert_eq!(md.update(-5.0), Some(before));
229        // Once a positive price arrives the recurrence resumes normally.
230        let after = md.update(40.0).unwrap();
231        assert!(after > before);
232    }
233}