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        Ok(Self {
57            period,
58            seed: VecDeque::with_capacity(period),
59            seed_sum: 0.0,
60            current: None,
61        })
62    }
63
64    /// Configured period.
65    pub const fn period(&self) -> usize {
66        self.period
67    }
68
69    /// Current value if available.
70    pub const fn value(&self) -> Option<f64> {
71        self.current
72    }
73}
74
75impl Indicator for McGinleyDynamic {
76    type Input = f64;
77    type Output = f64;
78
79    fn update(&mut self, input: f64) -> Option<f64> {
80        if !input.is_finite() {
81            return self.current;
82        }
83        if let Some(prev) = self.current {
84            // The recurrence divides by `(price / prev)^4`; if either side is
85            // zero or negative the formula blows up, so we hold the previous
86            // value as a defensive fallback against degenerate price series.
87            if prev <= 0.0 || input <= 0.0 {
88                return self.current;
89            }
90            let ratio = input / prev;
91            let divisor = K * (self.period as f64) * ratio.powi(4);
92            let next = prev + (input - prev) / divisor;
93            self.current = Some(next);
94        } else {
95            self.seed.push_back(input);
96            self.seed_sum += input;
97            if self.seed.len() == self.period {
98                self.current = Some(self.seed_sum / self.period as f64);
99            }
100        }
101        self.current
102    }
103
104    fn reset(&mut self) {
105        self.seed.clear();
106        self.seed_sum = 0.0;
107        self.current = None;
108    }
109
110    fn warmup_period(&self) -> usize {
111        self.period
112    }
113
114    fn is_ready(&self) -> bool {
115        self.current.is_some()
116    }
117
118    fn name(&self) -> &'static str {
119        "McGinleyDynamic"
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use crate::traits::BatchExt;
127    use approx::assert_relative_eq;
128
129    #[test]
130    fn rejects_zero_period() {
131        assert!(matches!(McGinleyDynamic::new(0), Err(Error::PeriodZero)));
132    }
133
134    #[test]
135    fn accessors_and_metadata() {
136        let mut md = McGinleyDynamic::new(10).unwrap();
137        assert_eq!(md.period(), 10);
138        assert_eq!(md.warmup_period(), 10);
139        assert_eq!(md.name(), "McGinleyDynamic");
140        assert_eq!(md.value(), None);
141        for i in 1..=10 {
142            md.update(f64::from(i));
143        }
144        assert!(md.value().is_some());
145    }
146
147    #[test]
148    fn constant_series_yields_the_constant() {
149        // ratio = 1, so the recurrence collapses to MD + 0 / divisor = MD.
150        let mut md = McGinleyDynamic::new(5).unwrap();
151        let out = md.batch(&[42.0_f64; 30]);
152        for v in out.iter().skip(4).flatten() {
153            assert_relative_eq!(*v, 42.0, epsilon = 1e-12);
154        }
155    }
156
157    #[test]
158    fn warmup_emits_first_value_at_period() {
159        let mut md = McGinleyDynamic::new(3).unwrap();
160        // Seed = SMA([10, 20, 30]) = 20.0.
161        assert_eq!(md.update(10.0), None);
162        assert_eq!(md.update(20.0), None);
163        assert_eq!(md.update(30.0), Some(20.0));
164    }
165
166    #[test]
167    fn reference_value_recurrence() {
168        // Period 3, seed = SMA([10, 20, 30]) = 20.0. Then on price = 40.0:
169        //   ratio   = 40 / 20 = 2
170        //   divisor = 0.6 * 3 * 2^4 = 0.6 * 3 * 16 = 28.8
171        //   next    = 20 + (40 - 20) / 28.8 = 20.694444...
172        let mut md = McGinleyDynamic::new(3).unwrap();
173        md.batch(&[10.0_f64, 20.0, 30.0]);
174        let v = md.update(40.0).unwrap();
175        let expected = 20.0 + 20.0 / (0.6 * 3.0 * 16.0);
176        assert_relative_eq!(v, expected, epsilon = 1e-12);
177    }
178
179    #[test]
180    fn batch_equals_streaming() {
181        let prices: Vec<f64> = (1..=80)
182            .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
183            .collect();
184        let mut a = McGinleyDynamic::new(10).unwrap();
185        let mut b = McGinleyDynamic::new(10).unwrap();
186        assert_eq!(
187            a.batch(&prices),
188            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
189        );
190    }
191
192    #[test]
193    fn reset_clears_state() {
194        let mut md = McGinleyDynamic::new(5).unwrap();
195        md.batch(&(1..=30).map(f64::from).collect::<Vec<_>>());
196        assert!(md.is_ready());
197        md.reset();
198        assert!(!md.is_ready());
199        assert_eq!(md.update(1.0), None);
200    }
201
202    #[test]
203    fn ignores_non_finite_input() {
204        let mut md = McGinleyDynamic::new(3).unwrap();
205        md.batch(&[10.0_f64, 20.0, 30.0]);
206        let before = md.value().unwrap();
207        assert_eq!(md.update(f64::NAN), Some(before));
208        assert_eq!(md.update(f64::INFINITY), Some(before));
209    }
210
211    #[test]
212    fn holds_value_when_input_is_non_positive() {
213        // Defensive: a zero or negative price would make the (price/prev)^4
214        // divisor zero or otherwise blow up; the recurrence holds steady.
215        let mut md = McGinleyDynamic::new(3).unwrap();
216        md.batch(&[10.0_f64, 20.0, 30.0]);
217        let before = md.value().unwrap();
218        assert_eq!(md.update(0.0), Some(before));
219        assert_eq!(md.update(-5.0), Some(before));
220        // Once a positive price arrives the recurrence resumes normally.
221        let after = md.update(40.0).unwrap();
222        assert!(after > before);
223    }
224}