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/// Policy governing indicator warm-up output before the full window length is seen.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
8pub enum WarmupPolicy {
9    /// Emit partial-window / first-value seeded outputs from bar 0 (quantwave default).
10    #[default]
11    Partial,
12    /// Emit `f64::NAN` until the full period window is satisfied (TA-Lib convention).
13    Nan,
14}
15
16/// Simple Moving Average (SMA)
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct SMA {
19    period: usize,
20    window: VecDeque<f64>,
21    sum: f64,
22    warmup: WarmupPolicy,
23}
24
25impl SMA {
26    pub fn new(period: usize) -> Self {
27        Self::with_warmup(period, WarmupPolicy::Partial)
28    }
29
30    pub fn with_warmup(period: usize, warmup: WarmupPolicy) -> Self {
31        Self {
32            period,
33            window: VecDeque::with_capacity(period),
34            sum: 0.0,
35            warmup,
36        }
37    }
38}
39
40impl From<usize> for SMA {
41    fn from(period: usize) -> Self {
42        Self::new(period)
43    }
44}
45
46impl Next<f64> for SMA {
47    type Output = f64;
48
49    fn next(&mut self, input: f64) -> Self::Output {
50        self.window.push_back(input);
51        self.sum += input;
52
53        if self.window.len() > self.period
54            && let Some(oldest) = self.window.pop_front()
55        {
56            self.sum -= oldest;
57        }
58
59        // A single NaN/Inf sample poisons the incremental running sum forever
60        // under naive `sum -= oldest`: NaN propagates through every `+=`/`-=`
61        // it touches, and subtracting the same NaN back out when it ages out
62        // of the window never un-poisons it (see quantwave-1jqv Phase 0
63        // audit's `nan_passthrough` finding on plain `sma`). Recompute
64        // directly from the window whenever contaminated, so the indicator
65        // self-heals exactly when the window does.
66        if self.sum.is_nan() || self.sum.is_infinite() {
67            self.sum = self.window.iter().sum();
68        }
69
70        if self.warmup == WarmupPolicy::Nan && self.window.len() < self.period {
71            return f64::NAN;
72        }
73
74        self.sum / self.window.len() as f64
75    }
76}
77
78/// Exponential Moving Average (EMA)
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct EMA {
81    period: usize,
82    alpha: f64,
83    current_ema: Option<f64>,
84    warmup: WarmupPolicy,
85    warmup_window: VecDeque<f64>,
86}
87
88impl EMA {
89    pub fn new(period: usize) -> Self {
90        Self::with_warmup(period, WarmupPolicy::Partial)
91    }
92
93    pub fn with_warmup(period: usize, warmup: WarmupPolicy) -> Self {
94        Self {
95            period,
96            alpha: 2.0 / (period as f64 + 1.0),
97            current_ema: None,
98            warmup,
99            warmup_window: VecDeque::with_capacity(period),
100        }
101    }
102}
103
104impl From<usize> for EMA {
105    fn from(period: usize) -> Self {
106        Self::new(period)
107    }
108}
109
110impl Next<f64> for EMA {
111    type Output = f64;
112
113    fn next(&mut self, input: f64) -> Self::Output {
114        if input.is_nan() {
115            return f64::NAN;
116        }
117
118        if self.warmup == WarmupPolicy::Nan && self.current_ema.is_none() {
119            self.warmup_window.push_back(input);
120            if self.warmup_window.len() < self.period {
121                return f64::NAN;
122            }
123            let seed: f64 = self.warmup_window.iter().sum::<f64>() / self.period as f64;
124            self.current_ema = Some(seed);
125            return seed;
126        }
127
128        match self.current_ema {
129            Some(prev_ema) => {
130                let ema = self.alpha * input + (1.0 - self.alpha) * prev_ema;
131                self.current_ema = Some(ema);
132                ema
133            }
134            None => {
135                self.current_ema = Some(input);
136                input
137            }
138        }
139    }
140}
141
142/// Weighted Moving Average (WMA)
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct WMA {
145    period: usize,
146    window: VecDeque<f64>,
147}
148
149impl WMA {
150    pub fn new(period: usize) -> Self {
151        Self {
152            period,
153            window: VecDeque::with_capacity(period),
154        }
155    }
156}
157
158impl From<usize> for WMA {
159    fn from(period: usize) -> Self {
160        Self::new(period)
161    }
162}
163
164impl Next<f64> for WMA {
165    type Output = f64;
166
167    fn next(&mut self, input: f64) -> Self::Output {
168        self.window.push_back(input);
169        if self.window.len() > self.period {
170            self.window.pop_front();
171        }
172
173        let mut weight_sum = 0.0;
174        let mut weighted_val_sum = 0.0;
175
176        for (i, &val) in self.window.iter().enumerate() {
177            let weight = (i + 1) as f64;
178            weighted_val_sum += val * weight;
179            weight_sum += weight;
180        }
181
182        if weight_sum == 0.0 {
183            0.0
184        } else {
185            weighted_val_sum / weight_sum
186        }
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use crate::test_utils::{assert_indicator_parity, load_gold_standard};
194
195    #[test]
196    fn test_sma_gold_standard() {
197        let case = load_gold_standard("sma_5");
198        let sma = SMA::new(3); // The expected values in JSON are for SMA(3)
199        assert_indicator_parity(sma, &case.input, &case.expected);
200    }
201
202    /// Regression for quantwave-1jqv Phase 0 audit: a single NaN sample must
203    /// only poison the output while it is inside the window, not forever.
204    /// Before the self-heal recompute, `sum -= oldest` never cleared the NaN
205    /// out of the running sum once it had been subtracted back out, so the
206    /// indicator stayed NaN for the rest of the series.
207    #[test]
208    fn test_sma_self_heals_after_nan_ages_out_of_window() {
209        let mut sma = SMA::new(3);
210        assert_eq!(sma.next(1.0), 1.0);
211        assert_eq!(sma.next(2.0), 1.5);
212        // NaN enters the window: [1.0, 2.0, NaN] -> undefined, correctly NaN.
213        assert!(sma.next(f64::NAN).is_nan());
214        // NaN still inside the window: [2.0, NaN, 4.0] -> still undefined.
215        assert!(sma.next(4.0).is_nan());
216        // NaN still inside the window: [NaN, 4.0, 5.0] -> still undefined.
217        assert!(sma.next(5.0).is_nan());
218        // NaN has aged out of the window: [4.0, 5.0, 6.0] -> must recover to
219        // the true mean, not remain NaN forever.
220        let recovered = sma.next(6.0);
221        assert!(
222            !recovered.is_nan(),
223            "SMA should self-heal once the NaN ages out of the window, got {recovered}"
224        );
225        approx::assert_relative_eq!(recovered, 5.0);
226        // And normal operation continues correctly afterward.
227        approx::assert_relative_eq!(sma.next(9.0), 20.0 / 3.0);
228    }
229
230    #[test]
231    fn test_ema_basic() {
232        let mut ema = EMA::new(3);
233        assert_eq!(ema.next(10.0), 10.0);
234        approx::assert_relative_eq!(ema.next(12.0), 11.0); // alpha = 0.5. 0.5*12 + 0.5*10 = 11
235    }
236
237    #[test]
238    fn test_ema_recovers_after_nan() {
239        let mut ema = EMA::new(3); // alpha = 0.5
240        assert_eq!(ema.next(1.0), 1.0);
241        assert_eq!(ema.next(2.0), 1.5);
242        assert_eq!(ema.next(3.0), 2.25);
243        // NaN enters: must return NaN, but not permanently poison current_ema
244        assert!(ema.next(f64::NAN).is_nan());
245        // Next finite sample: must resume smoothing from the last valid EMA (2.25)
246        let resumed = ema.next(5.0); // alpha * 5.0 + (1 - alpha) * 2.25 = 2.5 + 1.125 = 3.625
247        approx::assert_relative_eq!(resumed, 3.625);
248    }
249
250    #[test]
251    fn test_sma_nan_warmup() {
252        let mut sma = SMA::with_warmup(3, WarmupPolicy::Nan);
253        assert!(sma.next(1.0).is_nan());
254        assert!(sma.next(2.0).is_nan());
255        approx::assert_relative_eq!(sma.next(3.0), 2.0);
256        approx::assert_relative_eq!(sma.next(4.0), 3.0);
257    }
258
259    #[test]
260    fn test_ema_nan_warmup() {
261        let mut ema = EMA::with_warmup(3, WarmupPolicy::Nan);
262        assert!(ema.next(1.0).is_nan());
263        assert!(ema.next(2.0).is_nan());
264        // Bar 3: seeded with SMA(1, 2, 3) = 2.0
265        approx::assert_relative_eq!(ema.next(3.0), 2.0);
266        // Bar 4: alpha = 0.5 -> 0.5 * 4.0 + 0.5 * 2.0 = 3.0
267        approx::assert_relative_eq!(ema.next(4.0), 3.0);
268    }
269
270    #[test]
271    fn test_wma_basic() {
272        let mut wma = WMA::new(3);
273        assert_eq!(wma.next(1.0), 1.0);
274        approx::assert_relative_eq!(wma.next(2.0), 1.6666666666, epsilon = 1e-6); // (1*1 + 2*2)/3 = 5/3 = 1.666
275        approx::assert_relative_eq!(wma.next(3.0), 2.3333333333, epsilon = 1e-6); // (1*1 + 2*2 + 3*3)/6 = 14/6 = 2.333
276        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
277    }
278}
279
280pub const SMA_METADATA: IndicatorMetadata = IndicatorMetadata {
281    name: "Simple Moving Average",
282    description: "The Simple Moving Average calculates the unweighted mean of the previous N data points.",
283    usage: "Use as the foundational smoothing module providing SMA, EMA, WMA, and SMMA implementations that power higher-level indicators across the library.",
284    keywords: &["moving-average", "smoothing", "classic", "ema"],
285    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.",
286    params: &[
287        ParamDef {
288            name: "period",
289            default: "14",
290            description: "Smoothing period",
291        },
292        ParamDef {
293            name: "warmup",
294            default: "partial",
295            description: "Warm-up behavior ('partial' or 'nan')",
296        },
297    ],
298    formula_source: "https://www.investopedia.com/terms/s/sma.asp",
299    formula_latex: r#"
300\[
301SMA = \frac{1}{n} \sum_{i=1}^{n} P_i
302\]
303"#,
304    gold_standard_file: "sma.json",
305    category: "Classic",
306};
307
308pub const EMA_METADATA: IndicatorMetadata = IndicatorMetadata {
309    name: "Exponential Moving Average",
310    description: "The Exponential Moving Average gives more weight to recent prices.",
311    usage: "Use as the foundational smoothing module providing SMA, EMA, WMA, and SMMA implementations that power higher-level indicators across the library.",
312    keywords: &["moving-average", "smoothing", "classic", "ema"],
313    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.",
314    params: &[
315        ParamDef {
316            name: "period",
317            default: "14",
318            description: "Smoothing period",
319        },
320        ParamDef {
321            name: "warmup",
322            default: "partial",
323            description: "Warm-up behavior ('partial' or 'nan')",
324        },
325    ],
326    formula_source: "https://www.investopedia.com/terms/e/ema.asp",
327    formula_latex: r#"
328\[
329EMA = P_t \times \alpha + EMA_{t-1} \times (1 - \alpha)
330\]
331"#,
332    gold_standard_file: "ema.json",
333    category: "Classic",
334};
335
336pub const WMA_METADATA: IndicatorMetadata = IndicatorMetadata {
337    name: "Weighted Moving Average",
338    description: "The Weighted Moving Average assigns linearly decreasing weights.",
339    usage: "Use as the foundational smoothing module providing SMA, EMA, WMA, and SMMA implementations that power higher-level indicators across the library.",
340    keywords: &["moving-average", "smoothing", "classic", "ema"],
341    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.",
342    params: &[ParamDef {
343        name: "period",
344        default: "14",
345        description: "Smoothing period",
346    }],
347    formula_source: "https://www.investopedia.com/articles/technical/060401.asp",
348    formula_latex: r#"
349\[
350WMA = \frac{P_1 \times n + P_2 \times (n-1) + \dots}{n + (n-1) + \dots + 1}
351\]
352"#,
353    gold_standard_file: "wma.json",
354    category: "Classic",
355};