Skip to main content

wickra_core/indicators/
alma.rs

1//! Arnaud Legoux Moving Average (ALMA).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Arnaud Legoux Moving Average — a Gaussian-weighted moving average.
9///
10/// Each output is a weighted sum of the last `period` inputs:
11///
12/// ```text
13/// w[i] = exp(-(i - m)^2 / (2 * s^2))   for i in 0..period
14/// m    = offset * (period - 1)
15/// s    = period / sigma
16/// ALMA = sum(price[i] * w[i]) / sum(w[i])
17/// ```
18///
19/// The Gaussian is centred on the relative index `offset * (period - 1)`, so
20/// `offset = 0.85` puts the peak near the newest sample (responsive), while
21/// `offset = 0.5` centres the peak in the middle of the window (smooth).
22/// `sigma` controls how concentrated the Gaussian is: larger `sigma` ->
23/// narrower kernel, smaller `sigma` -> broader (closer to SMA).
24///
25/// Reference: Arnaud Legoux and Dimitrios Kouzis-Loukas, 2009.
26///
27/// # Defaults
28///
29/// The community-standard parameters are `period = 9`, `offset = 0.85`,
30/// `sigma = 6.0`. The first output lands after exactly `period` inputs.
31///
32/// # Example
33///
34/// ```
35/// use wickra_core::{Alma, Indicator};
36///
37/// let mut alma = Alma::new(9, 0.85, 6.0).unwrap();
38/// let mut last = None;
39/// for i in 0..40 {
40///     last = alma.update(100.0 + f64::from(i));
41/// }
42/// assert!(last.is_some());
43/// ```
44#[derive(Debug, Clone)]
45pub struct Alma {
46    period: usize,
47    offset: f64,
48    sigma: f64,
49    /// Pre-computed, normalised weights (sum to 1). `weights[0]` is the oldest
50    /// sample in the window, `weights[period - 1]` the newest.
51    weights: Vec<f64>,
52    window: VecDeque<f64>,
53    current: Option<f64>,
54}
55
56impl Alma {
57    /// Construct a new ALMA with the given period, offset and sigma.
58    ///
59    /// # Errors
60    ///
61    /// - [`Error::PeriodZero`] if `period == 0`.
62    /// - [`Error::InvalidPeriod`] if `offset` is outside `[0.0, 1.0]` or
63    ///   `sigma <= 0.0` or either of `offset` / `sigma` is non-finite.
64    pub fn new(period: usize, offset: f64, sigma: f64) -> Result<Self> {
65        if period == 0 {
66            return Err(Error::PeriodZero);
67        }
68        if period > crate::error::MAX_PERIOD {
69            return Err(Error::InvalidPeriod {
70                message: crate::error::PERIOD_ABOVE_MAX,
71            });
72        }
73        if !offset.is_finite() || !(0.0..=1.0).contains(&offset) {
74            return Err(Error::InvalidPeriod {
75                message: "ALMA offset must be a finite value in [0, 1]",
76            });
77        }
78        if !sigma.is_finite() || sigma <= 0.0 {
79            return Err(Error::InvalidPeriod {
80                message: "ALMA sigma must be a finite positive value",
81            });
82        }
83        let m = offset * (period as f64 - 1.0);
84        let s = period as f64 / sigma;
85        let denom = 2.0 * s * s;
86        // The raw Gaussian weights sum to a strictly positive value because
87        // every term is `exp(_) > 0`, so the normalisation below cannot divide
88        // by zero.
89        let mut raw: Vec<f64> = (0..period)
90            .map(|i| (-((i as f64 - m).powi(2)) / denom).exp())
91            .collect();
92        let sum: f64 = raw.iter().sum();
93        for w in &mut raw {
94            *w /= sum;
95        }
96        Ok(Self {
97            period,
98            offset,
99            sigma,
100            weights: raw,
101            window: VecDeque::with_capacity(period),
102            current: None,
103        })
104    }
105
106    /// Construct ALMA with the community-standard parameters
107    /// `(period = 9, offset = 0.85, sigma = 6.0)`.
108    pub fn classic() -> Self {
109        Self::new(9, 0.85, 6.0).expect("classic ALMA parameters are valid")
110    }
111
112    /// Configured period.
113    pub const fn period(&self) -> usize {
114        self.period
115    }
116
117    /// Configured offset.
118    pub const fn offset(&self) -> f64 {
119        self.offset
120    }
121
122    /// Configured sigma.
123    pub const fn sigma(&self) -> f64 {
124        self.sigma
125    }
126}
127
128impl Indicator for Alma {
129    type Input = f64;
130    type Output = f64;
131
132    #[inline]
133    fn update(&mut self, input: f64) -> Option<f64> {
134        if !input.is_finite() {
135            return None;
136        }
137        if self.window.len() == self.period {
138            self.window.pop_front();
139        }
140        self.window.push_back(input);
141        if self.window.len() < self.period {
142            return None;
143        }
144        let mut acc = 0.0;
145        for (w, p) in self.weights.iter().zip(self.window.iter()) {
146            acc += w * p;
147        }
148        self.current = Some(acc);
149        Some(acc)
150    }
151
152    fn reset(&mut self) {
153        self.window.clear();
154        self.current = None;
155    }
156
157    #[inline]
158    fn warmup_period(&self) -> usize {
159        self.period
160    }
161
162    #[inline]
163    fn is_ready(&self) -> bool {
164        self.current.is_some()
165    }
166
167    #[inline]
168    fn name(&self) -> &'static str {
169        "ALMA"
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use crate::traits::BatchExt;
177    use approx::assert_relative_eq;
178
179    #[test]
180    fn rejects_zero_period() {
181        assert!(matches!(Alma::new(0, 0.85, 6.0), Err(Error::PeriodZero)));
182    }
183
184    #[test]
185    fn rejects_invalid_offset() {
186        assert!(matches!(
187            Alma::new(9, -0.1, 6.0),
188            Err(Error::InvalidPeriod { .. })
189        ));
190        assert!(matches!(
191            Alma::new(9, 1.1, 6.0),
192            Err(Error::InvalidPeriod { .. })
193        ));
194        assert!(matches!(
195            Alma::new(9, f64::NAN, 6.0),
196            Err(Error::InvalidPeriod { .. })
197        ));
198    }
199
200    #[test]
201    fn rejects_invalid_sigma() {
202        assert!(matches!(
203            Alma::new(9, 0.85, 0.0),
204            Err(Error::InvalidPeriod { .. })
205        ));
206        assert!(matches!(
207            Alma::new(9, 0.85, -1.0),
208            Err(Error::InvalidPeriod { .. })
209        ));
210        assert!(matches!(
211            Alma::new(9, 0.85, f64::INFINITY),
212            Err(Error::InvalidPeriod { .. })
213        ));
214    }
215
216    #[test]
217    fn accessors_and_metadata() {
218        let alma = Alma::new(9, 0.85, 6.0).unwrap();
219        assert_eq!(alma.period(), 9);
220        assert_eq!(alma.warmup_period(), 9);
221        assert_eq!(alma.name(), "ALMA");
222        assert!((alma.offset() - 0.85).abs() < 1e-12);
223        assert!((alma.sigma() - 6.0).abs() < 1e-12);
224        // Weights are normalised by construction.
225        let sum: f64 = alma.weights.iter().sum();
226        assert_relative_eq!(sum, 1.0, epsilon = 1e-12);
227    }
228
229    #[test]
230    fn classic_factory() {
231        let a = Alma::classic();
232        assert_eq!(a.period(), 9);
233        assert!((a.offset() - 0.85).abs() < 1e-12);
234        assert!((a.sigma() - 6.0).abs() < 1e-12);
235    }
236
237    #[test]
238    fn constant_series_yields_the_constant() {
239        // Normalised weights sum to 1, so any constant is reproduced exactly.
240        let mut alma = Alma::new(9, 0.85, 6.0).unwrap();
241        let out = alma.batch(&[42.0_f64; 40]);
242        for v in out.iter().skip(8).flatten() {
243            assert_relative_eq!(*v, 42.0, epsilon = 1e-12);
244        }
245    }
246
247    #[test]
248    fn warmup_emits_first_value_at_period() {
249        let mut alma = Alma::new(5, 0.85, 6.0).unwrap();
250        for i in 0..4 {
251            assert_eq!(alma.update(f64::from(i)), None);
252        }
253        assert!(alma.update(4.0).is_some());
254    }
255
256    #[test]
257    fn reference_value_period_3() {
258        // ALMA(period=3, offset=0.85, sigma=6) on [10, 20, 30].
259        // m = 0.85 * 2 = 1.7;  s = 3 / 6 = 0.5;  2*s^2 = 0.5.
260        // Independently compute the normalised Gaussian weights and the
261        // expected weighted sum, then check the indicator output matches.
262        // Computing the expectation here (rather than pinning a printed
263        // constant) keeps the test stable across libm `exp` implementations.
264        let mut alma = Alma::new(3, 0.85, 6.0).unwrap();
265        alma.update(10.0);
266        alma.update(20.0);
267        let v = alma.update(30.0).expect("ALMA emits after period");
268
269        let w0 = (-((0.0_f64 - 1.7).powi(2)) / 0.5).exp();
270        let w1 = (-((1.0_f64 - 1.7).powi(2)) / 0.5).exp();
271        let w2 = (-((2.0_f64 - 1.7).powi(2)) / 0.5).exp();
272        let s = w0 + w1 + w2;
273        let expected = (10.0 * w0 + 20.0 * w1 + 30.0 * w2) / s;
274
275        // The weighted sum is heavily skewed toward the newest sample so the
276        // output must sit close to but below the latest input (30).
277        assert!(v > 25.0 && v < 30.0, "ALMA(3) on [10,20,30] = {v}");
278        assert_relative_eq!(v, expected, epsilon = 1e-12);
279    }
280
281    #[test]
282    fn offset_zero_centres_on_oldest_sample() {
283        // With offset = 0 the Gaussian peaks at index 0, so ALMA leans toward
284        // the oldest sample in the window and away from the newest.
285        let mut alma = Alma::new(5, 0.0, 6.0).unwrap();
286        let series: Vec<f64> = (1..=5).map(f64::from).collect();
287        let mut last = None;
288        for p in &series {
289            last = alma.update(*p);
290        }
291        let v = last.unwrap();
292        let mean = series.iter().sum::<f64>() / series.len() as f64;
293        // Oldest sample is 1.0, mean is 3.0; an offset-0 ALMA should sit
294        // strictly below the mean.
295        assert!(v < mean, "{v} should be less than {mean}");
296    }
297
298    #[test]
299    fn offset_one_centres_on_newest_sample() {
300        // Symmetric to the above: offset = 1 leans toward the newest sample.
301        let mut alma = Alma::new(5, 1.0, 6.0).unwrap();
302        let series: Vec<f64> = (1..=5).map(f64::from).collect();
303        let mut last = None;
304        for p in &series {
305            last = alma.update(*p);
306        }
307        let v = last.unwrap();
308        let mean = series.iter().sum::<f64>() / series.len() as f64;
309        assert!(v > mean, "{v} should exceed {mean}");
310    }
311
312    #[test]
313    fn batch_equals_streaming() {
314        let prices: Vec<f64> = (1..=100)
315            .map(|i| (f64::from(i) * 0.2).sin() * 5.0 + f64::from(i) * 0.1)
316            .collect();
317        let mut a = Alma::new(9, 0.85, 6.0).unwrap();
318        let mut b = Alma::new(9, 0.85, 6.0).unwrap();
319        assert_eq!(
320            a.batch(&prices),
321            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
322        );
323    }
324
325    #[test]
326    fn reset_clears_state() {
327        let mut alma = Alma::new(9, 0.85, 6.0).unwrap();
328        alma.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
329        assert!(alma.is_ready());
330        alma.reset();
331        assert!(!alma.is_ready());
332        assert_eq!(alma.update(1.0), None);
333    }
334
335    #[test]
336    fn ignores_non_finite_input() {
337        let mut alma = Alma::new(5, 0.85, 6.0).unwrap();
338        alma.batch(&(1..=5).map(f64::from).collect::<Vec<_>>());
339        alma.update(6.0).unwrap();
340        // Non-finite inputs leave the window/current untouched.
341        assert_eq!(alma.update(f64::NAN), None);
342        assert_eq!(alma.update(f64::INFINITY), None);
343    }
344}