Skip to main content

wickra_core/indicators/
sine_weighted_ma.rs

1//! Sine-Weighted Moving Average (SWMA).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Sine-Weighted Moving Average — a windowed average whose weights follow one
9/// half-cycle of a sine wave.
10///
11/// Over the last `period` inputs the weight of the value at position
12/// `i = 0, 1, …, period − 1` (oldest to newest) is
13///
14/// ```text
15/// w_i = sin(π · (i + 1) / (period + 1))
16/// SWMA = Σ (w_i · value_i) / Σ w_i
17/// ```
18///
19/// The window is symmetric: weights rise to a peak in the middle of the window
20/// and fall off at both ends, so the central observations dominate while the
21/// extremes are de-emphasised. Every weight is strictly positive because the
22/// argument `(i + 1) / (period + 1)` lies in the open interval `(0, 1)`, so the
23/// normaliser is always non-zero.
24///
25/// Each `update` is O(`period`): the fixed weight vector is dotted with the
26/// trailing window, mirroring the way [`Alma`](crate::Alma) recomputes its
27/// Gaussian weights. `period == 1` collapses to a pass-through
28/// (`w_0 = sin(π/2) = 1`).
29///
30/// # Example
31///
32/// ```
33/// use wickra_core::{Indicator, SineWeightedMa};
34///
35/// let mut indicator = SineWeightedMa::new(5).unwrap();
36/// let mut last = None;
37/// for i in 0..80 {
38///     last = indicator.update(100.0 + f64::from(i));
39/// }
40/// assert!(last.is_some());
41/// ```
42#[derive(Debug, Clone)]
43pub struct SineWeightedMa {
44    period: usize,
45    window: VecDeque<f64>,
46    /// Sine weights for positions `0..period` (oldest to newest), constant in
47    /// `period`.
48    weights: Vec<f64>,
49    weights_total: f64,
50}
51
52impl SineWeightedMa {
53    /// Construct a new sine-weighted moving average over `period` inputs.
54    ///
55    /// # Errors
56    ///
57    /// Returns [`Error::PeriodZero`] if `period == 0`.
58    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    /// Configured period.
81    pub const fn period(&self) -> usize {
82        self.period
83    }
84
85    /// Current value if the window is full.
86    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    /// Reference implementation: explicit sine-weighted average over a window.
144    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    /// Cover the const accessor `period` and the Indicator-impl `warmup_period`
171    /// + `name`.
172    #[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        // SWMA(3): weights sin(pi/4), sin(pi/2), sin(3pi/4) = [√½, 1, √½].
186        // Over [1,2,3]: (√½·1 + 1·2 + √½·3) / (√½ + 1 + √½).
187        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        // For a perfectly linear window the symmetric weighting reproduces the
196        // arithmetic centre of the window.
197        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        // The window still holds 1, 2, 3 -> next real input slides it to 2, 3, 4.
253        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}