quantwave_core/indicators/
smoothing.rs1use crate::indicators::metadata::{IndicatorMetadata, ParamDef};
2use crate::traits::Next;
3use crate::utils::RingBuffer as VecDeque;
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
8pub enum WarmupPolicy {
9 #[default]
11 Partial,
12 Nan,
14}
15
16#[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 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#[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#[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); assert_indicator_parity(sma, &case.input, &case.expected);
200 }
201
202 #[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 assert!(sma.next(f64::NAN).is_nan());
214 assert!(sma.next(4.0).is_nan());
216 assert!(sma.next(5.0).is_nan());
218 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 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); }
236
237 #[test]
238 fn test_ema_recovers_after_nan() {
239 let mut ema = EMA::new(3); 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 assert!(ema.next(f64::NAN).is_nan());
245 let resumed = ema.next(5.0); 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 approx::assert_relative_eq!(ema.next(3.0), 2.0);
266 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); approx::assert_relative_eq!(wma.next(3.0), 2.3333333333, epsilon = 1e-6); approx::assert_relative_eq!(wma.next(4.0), 3.3333333333, epsilon = 1e-6); }
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};