Skip to main content

quantwave_core/indicators/
pma.rs

1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
2use crate::traits::Next;
3use std::collections::VecDeque;
4
5/// Projected Moving Average (PMA)
6///
7/// Based on John Ehlers' "Removing Moving Average Lag" (TASC March 2025).
8/// Adds the linear regression slope multiplied by half the length to a simple moving average
9/// to compensate for the lag inherent in moving averages.
10/// Returns (PMA, Predict).
11#[derive(Debug, Clone)]
12pub struct ProjectedMovingAverage {
13    length: usize,
14    window: VecDeque<f64>,
15    slope_history: VecDeque<f64>,
16    sum_x: f64,
17    sum_x2: f64,
18}
19
20impl ProjectedMovingAverage {
21    pub fn new(length: usize) -> Self {
22        let mut sum_x = 0.0;
23        let mut sum_x2 = 0.0;
24        for i in 1..=length {
25            let x = i as f64;
26            sum_x += x;
27            sum_x2 += x * x;
28        }
29        Self {
30            length,
31            window: VecDeque::with_capacity(length),
32            slope_history: VecDeque::from(vec![0.0; 3]),
33            sum_x,
34            sum_x2,
35        }
36    }
37}
38
39impl Default for ProjectedMovingAverage {
40    fn default() -> Self {
41        Self::new(20)
42    }
43}
44
45impl Next<f64> for ProjectedMovingAverage {
46    type Output = (f64, f64);
47
48    fn next(&mut self, input: f64) -> Self::Output {
49        self.window.push_front(input);
50        if self.window.len() > self.length {
51            self.window.pop_back();
52        }
53
54        if self.window.len() < self.length {
55            return (input, input);
56        }
57
58        let mut sum_y = 0.0;
59        let mut sum_xy = 0.0;
60
61        for i in 0..self.length {
62            let y = self.window[i];
63            let x = (i + 1) as f64;
64            sum_y += y;
65            sum_xy += x * y;
66        }
67
68        let n = self.length as f64;
69        let denom = n * self.sum_x2 - self.sum_x * self.sum_x;
70        let slope = if denom != 0.0 {
71            -(n * sum_xy - self.sum_x * sum_y) / denom
72        } else {
73            0.0
74        };
75        let sma = sum_y / n;
76        let pma = sma + slope * n / 2.0;
77
78        self.slope_history.pop_back();
79        self.slope_history.push_front(slope);
80
81        let predict = pma + 0.5 * (slope - self.slope_history[2]) * n;
82
83        (pma, predict)
84    }
85}
86
87pub const PROJECTED_MOVING_AVERAGE_METADATA: IndicatorMetadata = IndicatorMetadata {
88    name: "Projected Moving Average",
89    description: "A lag-compensated moving average that uses linear regression slope to project the average forward.",
90    usage: "Use as a predictive moving average that uses linear regression projection to anticipate where price will be rather than where it has been, reducing effective lag.",
91    keywords: &["moving-average", "prediction", "ehlers", "zero-lag"],
92    ehlers_summary: "The Projected Moving Average uses linear regression over the lookback window to project the best-fit line forward to the current bar. This predictive approach shifts the MA output toward the leading edge of price movement, achieving reduced lag compared to conventional MAs of the same period.",
93    params: &[ParamDef {
94        name: "length",
95        default: "20",
96        description: "Calculation length",
97    }],
98    formula_source: "https://github.com/lavs9/quantwave/blob/main/references/traderstipsreference/TRADERS’%20TIPS%20-%20MARCH%202025.html",
99    formula_latex: r#"
100\[
101Slope = -\frac{n \sum xy - \sum x \sum y}{n \sum x^2 - (\sum x)^2}
102\]
103\[
104PMA = SMA + Slope \cdot \frac{n}{2}
105\]
106\[
107Predict = PMA + 0.5 \cdot (Slope - Slope_{t-2}) \cdot n
108\]
109"#,
110    gold_standard_file: "pma.json",
111    category: "Ehlers DSP",
112};
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use crate::traits::Next;
118    use proptest::prelude::*;
119
120    #[test]
121    fn test_pma_basic() {
122        let mut pma = ProjectedMovingAverage::new(20);
123        let inputs = vec![10.0; 40];
124        for input in inputs {
125            let (p, pr) = pma.next(input);
126            assert_eq!(p, 10.0);
127            assert_eq!(pr, 10.0);
128        }
129    }
130
131    proptest! {
132        #[test]
133        fn test_pma_parity(
134            inputs in prop::collection::vec(1.0..100.0, 40..100),
135        ) {
136            let length = 20;
137            let mut pma = ProjectedMovingAverage::new(length);
138            let streaming_results: Vec<(f64, f64)> = inputs.iter().map(|&x| pma.next(x)).collect();
139
140            // Batch implementation
141            let mut batch_results = Vec::with_capacity(inputs.len());
142            let mut slope_hist = vec![0.0; 3];
143            let mut sum_x = 0.0;
144            let mut sum_x2 = 0.0;
145            for i in 1..=length {
146                let x = i as f64;
147                sum_x += x;
148                sum_x2 += x * x;
149            }
150
151            for i in 0..inputs.len() {
152                if i < length - 1 {
153                    batch_results.push((inputs[i], inputs[i]));
154                    continue;
155                }
156
157                let mut sum_y = 0.0;
158                let mut sum_xy = 0.0;
159                for j in 0..length {
160                    let y = inputs[i - j];
161                    let x = (j + 1) as f64;
162                    sum_y += y;
163                    sum_xy += x * y;
164                }
165
166                let n = length as f64;
167                let denom = n * sum_x2 - sum_x * sum_x;
168                let slope = if denom != 0.0 {
169                    -(n * sum_xy - sum_x * sum_y) / denom
170                } else {
171                    0.0
172                };
173                let sma = sum_y / n;
174                let pma_val = sma + slope * n / 2.0;
175
176                slope_hist.insert(0, slope);
177                if slope_hist.len() > 3 {
178                    slope_hist.pop();
179                }
180
181                let predict = pma_val + 0.5 * (slope - slope_hist[2]) * n;
182                batch_results.push((pma_val, predict));
183            }
184
185            for (s, b) in streaming_results.iter().zip(batch_results.iter()) {
186                approx::assert_relative_eq!(s.0, b.0, epsilon = 1e-10);
187                approx::assert_relative_eq!(s.1, b.1, epsilon = 1e-10);
188            }
189        }
190    }
191}