Skip to main content

quantwave_core/indicators/
smoothing.rs

1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
2use crate::traits::Next;
3use crate::utils::RingBuffer as VecDeque;
4use serde::{Deserialize, Serialize};
5
6/// Simple Moving Average (SMA)
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct SMA {
9    period: usize,
10    window: VecDeque<f64>,
11    sum: f64,
12}
13
14impl SMA {
15    pub fn new(period: usize) -> Self {
16        Self {
17            period,
18            window: VecDeque::with_capacity(period),
19            sum: 0.0,
20        }
21    }
22}
23
24impl From<usize> for SMA {
25    fn from(period: usize) -> Self {
26        Self::new(period)
27    }
28}
29
30impl Next<f64> for SMA {
31    type Output = f64;
32
33    fn next(&mut self, input: f64) -> Self::Output {
34        self.window.push_back(input);
35        self.sum += input;
36
37        if self.window.len() > self.period
38            && let Some(oldest) = self.window.pop_front()
39        {
40            self.sum -= oldest;
41        }
42
43        // A single NaN/Inf sample poisons the incremental running sum forever
44        // under naive `sum -= oldest`: NaN propagates through every `+=`/`-=`
45        // it touches, and subtracting the same NaN back out when it ages out
46        // of the window never un-poisons it (see quantwave-1jqv Phase 0
47        // audit's `nan_passthrough` finding on plain `sma`). The
48        // mathematically correct SMA is undefined only while the bad sample
49        // is actually inside the window -- once it's aged out, the window no
50        // longer contains it and the true sum is finite again. Recompute
51        // directly from the window (same cost model WMA already pays every
52        // call in this file) whenever the running sum is contaminated, so the
53        // indicator self-heals exactly when the window does.
54        if self.sum.is_nan() || self.sum.is_infinite() {
55            self.sum = self.window.iter().sum();
56        }
57
58        self.sum / self.window.len() as f64
59    }
60}
61
62/// Exponential Moving Average (EMA)
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct EMA {
65    _period: usize,
66    alpha: f64,
67    current_ema: Option<f64>,
68}
69
70impl EMA {
71    pub fn new(period: usize) -> Self {
72        Self {
73            _period: period,
74            alpha: 2.0 / (period as f64 + 1.0),
75            current_ema: None,
76        }
77    }
78}
79
80impl From<usize> for EMA {
81    fn from(period: usize) -> Self {
82        Self::new(period)
83    }
84}
85
86impl Next<f64> for EMA {
87    type Output = f64;
88
89    fn next(&mut self, input: f64) -> Self::Output {
90        match self.current_ema {
91            Some(prev_ema) => {
92                let ema = self.alpha * input + (1.0 - self.alpha) * prev_ema;
93                self.current_ema = Some(ema);
94                ema
95            }
96            None => {
97                self.current_ema = Some(input);
98                input
99            }
100        }
101    }
102}
103
104/// Weighted Moving Average (WMA)
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct WMA {
107    period: usize,
108    window: VecDeque<f64>,
109}
110
111impl WMA {
112    pub fn new(period: usize) -> Self {
113        Self {
114            period,
115            window: VecDeque::with_capacity(period),
116        }
117    }
118}
119
120impl From<usize> for WMA {
121    fn from(period: usize) -> Self {
122        Self::new(period)
123    }
124}
125
126impl Next<f64> for WMA {
127    type Output = f64;
128
129    fn next(&mut self, input: f64) -> Self::Output {
130        self.window.push_back(input);
131        if self.window.len() > self.period {
132            self.window.pop_front();
133        }
134
135        let mut weight_sum = 0.0;
136        let mut weighted_val_sum = 0.0;
137
138        for (i, &val) in self.window.iter().enumerate() {
139            let weight = (i + 1) as f64;
140            weighted_val_sum += val * weight;
141            weight_sum += weight;
142        }
143
144        if weight_sum == 0.0 {
145            0.0
146        } else {
147            weighted_val_sum / weight_sum
148        }
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::test_utils::{assert_indicator_parity, load_gold_standard};
156
157    #[test]
158    fn test_sma_gold_standard() {
159        let case = load_gold_standard("sma_5");
160        let sma = SMA::new(3); // The expected values in JSON are for SMA(3)
161        assert_indicator_parity(sma, &case.input, &case.expected);
162    }
163
164    /// Regression for quantwave-1jqv Phase 0 audit: a single NaN sample must
165    /// only poison the output while it is inside the window, not forever.
166    /// Before the self-heal recompute, `sum -= oldest` never cleared the NaN
167    /// out of the running sum once it had been subtracted back out, so the
168    /// indicator stayed NaN for the rest of the series.
169    #[test]
170    fn test_sma_self_heals_after_nan_ages_out_of_window() {
171        let mut sma = SMA::new(3);
172        assert_eq!(sma.next(1.0), 1.0);
173        assert_eq!(sma.next(2.0), 1.5);
174        // NaN enters the window: [1.0, 2.0, NaN] -> undefined, correctly NaN.
175        assert!(sma.next(f64::NAN).is_nan());
176        // NaN still inside the window: [2.0, NaN, 4.0] -> still undefined.
177        assert!(sma.next(4.0).is_nan());
178        // NaN still inside the window: [NaN, 4.0, 5.0] -> still undefined.
179        assert!(sma.next(5.0).is_nan());
180        // NaN has aged out of the window: [4.0, 5.0, 6.0] -> must recover to
181        // the true mean, not remain NaN forever.
182        let recovered = sma.next(6.0);
183        assert!(
184            !recovered.is_nan(),
185            "SMA should self-heal once the NaN ages out of the window, got {recovered}"
186        );
187        approx::assert_relative_eq!(recovered, 5.0);
188        // And normal operation continues correctly afterward.
189        approx::assert_relative_eq!(sma.next(9.0), 20.0 / 3.0);
190    }
191
192    #[test]
193    fn test_ema_basic() {
194        let mut ema = EMA::new(3);
195        assert_eq!(ema.next(10.0), 10.0);
196        approx::assert_relative_eq!(ema.next(12.0), 11.0); // alpha = 0.5. 0.5*12 + 0.5*10 = 11
197    }
198
199    #[test]
200    fn test_wma_basic() {
201        let mut wma = WMA::new(3);
202        assert_eq!(wma.next(1.0), 1.0);
203        approx::assert_relative_eq!(wma.next(2.0), 1.6666666666, epsilon = 1e-6); // (1*1 + 2*2)/3 = 5/3 = 1.666
204        approx::assert_relative_eq!(wma.next(3.0), 2.3333333333, epsilon = 1e-6); // (1*1 + 2*2 + 3*3)/6 = 14/6 = 2.333
205        approx::assert_relative_eq!(wma.next(4.0), 3.3333333333, epsilon = 1e-6); // (2*1 + 3*2 + 4*3)/6 = (2+6+12)/6 = 20/6 = 3.333
206    }
207}
208
209pub const SMA_METADATA: IndicatorMetadata = IndicatorMetadata {
210    name: "Simple Moving Average",
211    description: "The Simple Moving Average calculates the unweighted mean of the previous N data points.",
212    usage: "Use as the foundational smoothing module providing SMA, EMA, WMA, and SMMA implementations that power higher-level indicators across the library.",
213    keywords: &["moving-average", "smoothing", "classic", "ema"],
214    ehlers_summary: "The core smoothing algorithms — SMA, EMA, WMA — are the building blocks of nearly all technical indicators. EMA applies exponential decay weighting (alpha = 2/(n+1)), SMA applies uniform weighting over N bars, and WMA applies linearly increasing weights emphasizing more recent bars.",
215    params: &[ParamDef {
216        name: "period",
217        default: "14",
218        description: "Smoothing period",
219    }],
220    formula_source: "https://www.investopedia.com/terms/s/sma.asp",
221    formula_latex: r#"
222\[
223SMA = \frac{1}{n} \sum_{i=1}^{n} P_i
224\]
225"#,
226    gold_standard_file: "sma.json",
227    category: "Classic",
228};
229
230pub const EMA_METADATA: IndicatorMetadata = IndicatorMetadata {
231    name: "Exponential Moving Average",
232    description: "The Exponential Moving Average gives more weight to recent prices.",
233    usage: "Use as the foundational smoothing module providing SMA, EMA, WMA, and SMMA implementations that power higher-level indicators across the library.",
234    keywords: &["moving-average", "smoothing", "classic", "ema"],
235    ehlers_summary: "The core smoothing algorithms — SMA, EMA, WMA — are the building blocks of nearly all technical indicators. EMA applies exponential decay weighting (alpha = 2/(n+1)), SMA applies uniform weighting over N bars, and WMA applies linearly increasing weights emphasizing more recent bars.",
236    params: &[ParamDef {
237        name: "period",
238        default: "14",
239        description: "Smoothing period",
240    }],
241    formula_source: "https://www.investopedia.com/terms/e/ema.asp",
242    formula_latex: r#"
243\[
244EMA = P_t \times \alpha + EMA_{t-1} \times (1 - \alpha)
245\]
246"#,
247    gold_standard_file: "ema.json",
248    category: "Classic",
249};
250
251pub const WMA_METADATA: IndicatorMetadata = IndicatorMetadata {
252    name: "Weighted Moving Average",
253    description: "The Weighted Moving Average assigns linearly decreasing weights.",
254    usage: "Use as the foundational smoothing module providing SMA, EMA, WMA, and SMMA implementations that power higher-level indicators across the library.",
255    keywords: &["moving-average", "smoothing", "classic", "ema"],
256    ehlers_summary: "The core smoothing algorithms — SMA, EMA, WMA — are the building blocks of nearly all technical indicators. EMA applies exponential decay weighting (alpha = 2/(n+1)), SMA applies uniform weighting over N bars, and WMA applies linearly increasing weights emphasizing more recent bars.",
257    params: &[ParamDef {
258        name: "period",
259        default: "14",
260        description: "Smoothing period",
261    }],
262    formula_source: "https://www.investopedia.com/articles/technical/060401.asp",
263    formula_latex: r#"
264\[
265WMA = \frac{P_1 \times n + P_2 \times (n-1) + \dots}{n + (n-1) + \dots + 1}
266\]
267"#,
268    gold_standard_file: "wma.json",
269    category: "Classic",
270};