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