wickra_core/indicators/
wma.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8#[derive(Debug, Clone)]
26pub struct Wma {
27 period: usize,
28 window: VecDeque<f64>,
29 weight_sum: f64, value_sum: f64, weights_total: f64,
32}
33
34impl Wma {
35 pub fn new(period: usize) -> Result<Self> {
41 if period == 0 {
42 return Err(Error::PeriodZero);
43 }
44 if period > crate::error::MAX_PERIOD {
45 return Err(Error::InvalidPeriod {
46 message: crate::error::PERIOD_ABOVE_MAX,
47 });
48 }
49 let n = period as f64;
50 let weights_total = n * (n + 1.0) / 2.0;
51 Ok(Self {
52 period,
53 window: VecDeque::with_capacity(period),
54 weight_sum: 0.0,
55 value_sum: 0.0,
56 weights_total,
57 })
58 }
59
60 pub const fn period(&self) -> usize {
62 self.period
63 }
64
65 pub fn value(&self) -> Option<f64> {
67 if self.window.len() == self.period {
68 Some(self.weight_sum / self.weights_total)
69 } else {
70 None
71 }
72 }
73}
74
75impl Indicator for Wma {
76 type Input = f64;
77 type Output = f64;
78
79 #[inline]
80 fn update(&mut self, input: f64) -> Option<f64> {
81 if !input.is_finite() {
82 return None;
83 }
84 if self.window.len() < self.period {
85 self.window.push_back(input);
88 self.value_sum += input;
89 if self.window.len() == self.period {
90 self.weight_sum = self
91 .window
92 .iter()
93 .enumerate()
94 .map(|(i, v)| (i as f64 + 1.0) * v)
95 .sum();
96 }
97 return self.value();
98 }
99 let oldest = self.window.pop_front().expect("window non-empty");
105 self.weight_sum = self.weight_sum - self.value_sum + self.period as f64 * input;
106 self.value_sum = self.value_sum - oldest + input;
107 self.window.push_back(input);
108 self.value()
109 }
110
111 fn reset(&mut self) {
112 self.window.clear();
113 self.weight_sum = 0.0;
114 self.value_sum = 0.0;
115 }
116
117 #[inline]
118 fn warmup_period(&self) -> usize {
119 self.period
120 }
121
122 #[inline]
123 fn is_ready(&self) -> bool {
124 self.window.len() == self.period
125 }
126
127 #[inline]
128 fn name(&self) -> &'static str {
129 "WMA"
130 }
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136 use crate::traits::BatchExt;
137 use approx::assert_relative_eq;
138
139 fn wma_naive(prices: &[f64], period: usize) -> Vec<Option<f64>> {
141 let weights_total = (period as f64) * (period as f64 + 1.0) / 2.0;
142 prices
143 .iter()
144 .enumerate()
145 .map(|(i, _)| {
146 if i + 1 < period {
147 None
148 } else {
149 let window = &prices[i + 1 - period..=i];
150 let s: f64 = window
151 .iter()
152 .enumerate()
153 .map(|(j, p)| (j as f64 + 1.0) * p)
154 .sum();
155 Some(s / weights_total)
156 }
157 })
158 .collect()
159 }
160
161 #[test]
162 fn new_rejects_zero_period() {
163 assert!(matches!(Wma::new(0), Err(Error::PeriodZero)));
164 }
165
166 #[test]
170 fn accessors_and_metadata() {
171 let wma = Wma::new(7).unwrap();
172 assert_eq!(wma.period(), 7);
173 assert_eq!(wma.warmup_period(), 7);
174 assert_eq!(wma.name(), "WMA");
175 }
176
177 #[test]
178 fn warmup_returns_none() {
179 let mut wma = Wma::new(3).unwrap();
180 assert_eq!(wma.update(1.0), None);
181 assert_eq!(wma.update(2.0), None);
182 assert_relative_eq!(wma.update(3.0).unwrap(), 14.0 / 6.0, epsilon = 1e-12);
185 }
186
187 #[test]
188 fn known_values_period_4() {
189 let mut wma = Wma::new(4).unwrap();
192 let v = wma.batch(&[1.0, 2.0, 3.0, 4.0]);
193 assert_relative_eq!(v[3].unwrap(), 3.0, epsilon = 1e-12);
194 }
195
196 #[test]
197 fn matches_naive_over_random_inputs() {
198 let prices: Vec<f64> = (1..=30).map(|i| f64::from(i) * 1.7 - 5.0).collect();
199 let mut wma = Wma::new(7).unwrap();
200 let got = wma.batch(&prices);
201 let want = wma_naive(&prices, 7);
202 for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() {
203 assert_eq!(g.is_some(), w.is_some(), "warmup mismatch at index {i}");
205 if let (Some(a), Some(b)) = (g, w) {
206 assert_relative_eq!(*a, *b, epsilon = 1e-9);
207 }
208 }
209 }
210
211 #[test]
212 fn period_one_is_pass_through() {
213 let mut wma = Wma::new(1).unwrap();
214 assert_relative_eq!(wma.update(5.5).unwrap(), 5.5, epsilon = 1e-12);
215 assert_relative_eq!(wma.update(7.5).unwrap(), 7.5, epsilon = 1e-12);
216 }
217
218 #[test]
219 fn reset_clears_state() {
220 let mut wma = Wma::new(4).unwrap();
221 wma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
222 assert!(wma.is_ready());
223 wma.reset();
224 assert!(!wma.is_ready());
225 assert_eq!(wma.update(10.0), None);
226 }
227
228 #[test]
229 fn batch_equals_streaming() {
230 let prices: Vec<f64> = (1..=20).map(|i| f64::from(i) * 0.5).collect();
231 let mut a = Wma::new(5).unwrap();
232 let mut b = Wma::new(5).unwrap();
233 assert_eq!(
234 a.batch(&prices),
235 prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
236 );
237 }
238
239 #[test]
240 fn ignores_non_finite_input_but_keeps_state() {
241 let mut wma = Wma::new(3).unwrap();
242 wma.update(1.0);
243 wma.update(2.0);
244 wma.update(3.0).expect("WMA(3) ready after three inputs");
245 assert_eq!(wma.update(f64::NAN), None);
247 assert_eq!(wma.update(f64::INFINITY), None);
248 assert_relative_eq!(
250 wma.update(4.0).unwrap(),
251 (2.0 * 1.0 + 3.0 * 2.0 + 4.0 * 3.0) / 6.0,
252 epsilon = 1e-12
253 );
254 }
255
256 proptest::proptest! {
257 #![proptest_config(proptest::test_runner::Config::with_cases(48))]
258 #[test]
259 fn proptest_matches_naive(
260 period in 1usize..15,
261 prices in proptest::collection::vec(-500.0_f64..500.0, 0..120),
262 ) {
263 let mut wma = Wma::new(period).unwrap();
264 let got = wma.batch(&prices);
265 let want = wma_naive(&prices, period);
266 proptest::prop_assert_eq!(got.len(), want.len());
267 for (g, w) in got.iter().zip(want.iter()) {
268 match (g, w) {
269 (None, None) => {}
270 (Some(a), Some(b)) => proptest::prop_assert!(
271 (a - b).abs() < 1e-7,
272 "got={a} want={b}"
273 ),
274 _ => proptest::prop_assert!(false, "warmup mismatch"),
275 }
276 }
277 }
278 }
279}