Skip to main content

wickra_core/indicators/
geometric_ma.rs

1//! Geometric Moving Average (GMA).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::rolling_moments::RollingSum;
7use crate::traits::Indicator;
8
9/// Geometric Moving Average — the rolling geometric mean of the last `period`
10/// inputs.
11///
12/// ```text
13/// GMA = (Π value_i)^(1/period) = exp( (1/period) · Σ ln(value_i) )
14/// ```
15///
16/// The geometric mean is the natural average for *multiplicative* quantities
17/// such as prices and growth factors: averaging in log-space weights relative
18/// (percentage) moves symmetrically, so a `+10%` followed by a `−10%` move
19/// pulls the average below the start, exactly as compounded returns do. It is
20/// always less than or equal to the arithmetic mean of the same window.
21///
22/// Maintained incrementally in O(1): the running sum of natural logs is updated
23/// by adding the newcomer's log and subtracting the departing value's log as
24/// the window slides.
25///
26/// The geometric mean is only defined for **strictly positive** inputs. A
27/// non-finite or non-positive input is ignored (it leaves the window unchanged
28/// and returns the current value), mirroring the non-finite handling of the
29/// other moving averages.
30///
31/// # Example
32///
33/// ```
34/// use wickra_core::{Indicator, GeometricMa};
35///
36/// let mut indicator = GeometricMa::new(5).unwrap();
37/// let mut last = None;
38/// for i in 0..80 {
39///     last = indicator.update(100.0 + f64::from(i));
40/// }
41/// assert!(last.is_some());
42/// ```
43#[derive(Debug, Clone)]
44pub struct GeometricMa {
45    period: usize,
46    /// Natural logs of the values currently in the window (oldest at front).
47    logs: VecDeque<f64>,
48    sum_logs: RollingSum,
49}
50
51impl GeometricMa {
52    /// Construct a new geometric moving average over `period` inputs.
53    ///
54    /// # Errors
55    ///
56    /// Returns [`Error::PeriodZero`] if `period == 0`.
57    pub fn new(period: usize) -> Result<Self> {
58        if period == 0 {
59            return Err(Error::PeriodZero);
60        }
61        if period > crate::error::MAX_PERIOD {
62            return Err(Error::InvalidPeriod {
63                message: crate::error::PERIOD_ABOVE_MAX,
64            });
65        }
66        Ok(Self {
67            period,
68            logs: VecDeque::with_capacity(period),
69            sum_logs: RollingSum::new(),
70        })
71    }
72
73    /// Configured period.
74    pub const fn period(&self) -> usize {
75        self.period
76    }
77
78    /// Current value if the window is full.
79    pub fn value(&self) -> Option<f64> {
80        if self.logs.len() == self.period {
81            Some((self.sum_logs.value() / self.period as f64).exp())
82        } else {
83            None
84        }
85    }
86}
87
88impl Indicator for GeometricMa {
89    type Input = f64;
90    type Output = f64;
91
92    #[inline]
93    fn update(&mut self, input: f64) -> Option<f64> {
94        if !input.is_finite() || input <= 0.0 {
95            return None;
96        }
97        if self.logs.len() == self.period {
98            let oldest = self.logs.pop_front().expect("window non-empty");
99            self.sum_logs.evict(oldest);
100        }
101        let ln = input.ln();
102        self.logs.push_back(ln);
103        self.sum_logs.push(ln);
104        if self.sum_logs.needs_reseed(self.period) {
105            self.sum_logs.reseed(self.logs.iter().copied());
106        }
107        self.value()
108    }
109
110    fn reset(&mut self) {
111        self.logs.clear();
112        self.sum_logs.reset();
113    }
114
115    #[inline]
116    fn warmup_period(&self) -> usize {
117        self.period
118    }
119
120    #[inline]
121    fn is_ready(&self) -> bool {
122        self.logs.len() == self.period
123    }
124
125    #[inline]
126    fn name(&self) -> &'static str {
127        "GMA"
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crate::traits::BatchExt;
135    use approx::assert_relative_eq;
136
137    /// Reference implementation: explicit geometric mean over a window.
138    fn gma_naive(prices: &[f64], period: usize) -> Vec<Option<f64>> {
139        prices
140            .iter()
141            .enumerate()
142            .map(|(i, _)| {
143                if i + 1 < period {
144                    None
145                } else {
146                    let window = &prices[i + 1 - period..=i];
147                    let product: f64 = window.iter().product();
148                    Some(product.powf(1.0 / period as f64))
149                }
150            })
151            .collect()
152    }
153
154    #[test]
155    fn new_rejects_zero_period() {
156        assert!(matches!(GeometricMa::new(0), Err(Error::PeriodZero)));
157    }
158
159    /// Cover the const accessor `period` and the Indicator-impl `warmup_period`
160    /// + `name`.
161    #[test]
162    fn accessors_and_metadata() {
163        let gma = GeometricMa::new(7).unwrap();
164        assert_eq!(gma.period(), 7);
165        assert_eq!(gma.warmup_period(), 7);
166        assert_eq!(gma.name(), "GMA");
167    }
168
169    #[test]
170    fn warmup_returns_none() {
171        let mut gma = GeometricMa::new(3).unwrap();
172        assert_eq!(gma.update(1.0), None);
173        assert_eq!(gma.update(4.0), None);
174        // GMA(3) of [1, 4, 2] = (1·4·2)^(1/3) = 8^(1/3) = 2.
175        assert_relative_eq!(gma.update(2.0).unwrap(), 2.0, epsilon = 1e-12);
176    }
177
178    #[test]
179    fn known_value_period_2() {
180        // GMA(2) of [4, 9] = sqrt(36) = 6.
181        let mut gma = GeometricMa::new(2).unwrap();
182        let v = gma.batch(&[4.0, 9.0]);
183        assert_relative_eq!(v[1].unwrap(), 6.0, epsilon = 1e-12);
184    }
185
186    #[test]
187    fn constant_series_returns_the_constant() {
188        let mut gma = GeometricMa::new(5).unwrap();
189        for v in gma.batch(&[42.0; 20]).into_iter().flatten() {
190            assert_relative_eq!(v, 42.0, epsilon = 1e-9);
191        }
192    }
193
194    #[test]
195    fn period_one_is_pass_through() {
196        let mut gma = GeometricMa::new(1).unwrap();
197        assert_relative_eq!(gma.update(5.5).unwrap(), 5.5, epsilon = 1e-12);
198        assert_relative_eq!(gma.update(7.5).unwrap(), 7.5, epsilon = 1e-12);
199    }
200
201    #[test]
202    fn below_or_equal_arithmetic_mean() {
203        // The geometric mean never exceeds the arithmetic mean of the same set.
204        let mut gma = GeometricMa::new(4).unwrap();
205        let prices = [10.0, 20.0, 5.0, 40.0];
206        let g = gma.batch(&prices)[3].unwrap();
207        let arithmetic = prices.iter().sum::<f64>() / 4.0;
208        assert!(
209            g < arithmetic,
210            "geometric {g} should be below arithmetic {arithmetic}"
211        );
212    }
213
214    #[test]
215    fn matches_naive_over_inputs() {
216        let prices: Vec<f64> = (1..=30).map(|i| f64::from(i) * 1.7 + 1.0).collect();
217        let mut gma = GeometricMa::new(7).unwrap();
218        let got = gma.batch(&prices);
219        let want = gma_naive(&prices, 7);
220        for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() {
221            assert_eq!(g.is_some(), w.is_some(), "warmup mismatch at index {i}");
222            if let (Some(a), Some(b)) = (g, w) {
223                assert_relative_eq!(*a, *b, epsilon = 1e-9);
224            }
225        }
226    }
227
228    #[test]
229    fn reset_clears_state() {
230        let mut gma = GeometricMa::new(4).unwrap();
231        gma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
232        assert!(gma.is_ready());
233        gma.reset();
234        assert!(!gma.is_ready());
235        assert_eq!(gma.update(10.0), None);
236    }
237
238    #[test]
239    fn batch_equals_streaming() {
240        let prices: Vec<f64> = (1..=20).map(|i| f64::from(i) * 0.5 + 1.0).collect();
241        let mut a = GeometricMa::new(5).unwrap();
242        let mut b = GeometricMa::new(5).unwrap();
243        assert_eq!(
244            a.batch(&prices),
245            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
246        );
247    }
248
249    #[test]
250    fn ignores_non_finite_and_non_positive_input() {
251        let mut gma = GeometricMa::new(3).unwrap();
252        gma.update(1.0);
253        gma.update(4.0);
254        gma.update(2.0).expect("GMA(3) ready after three inputs");
255        // Non-finite and non-positive inputs are skipped (geometric mean needs
256        // strictly positive values) and the window is left unchanged.
257        assert_eq!(gma.update(f64::NAN), None);
258        assert_eq!(gma.update(0.0), None);
259        assert_eq!(gma.update(-3.0), None);
260        // The window still holds 1, 4, 2 -> next real input slides it to 4, 2, 16.
261        let want = (4.0_f64 * 2.0 * 16.0).powf(1.0 / 3.0);
262        assert_relative_eq!(gma.update(16.0).unwrap(), want, epsilon = 1e-9);
263    }
264
265    proptest::proptest! {
266        #![proptest_config(proptest::test_runner::Config::with_cases(48))]
267        #[test]
268        fn proptest_matches_naive(
269            period in 1usize..15,
270            prices in proptest::collection::vec(0.01_f64..1000.0, 0..120),
271        ) {
272            let mut gma = GeometricMa::new(period).unwrap();
273            let got = gma.batch(&prices);
274            let want = gma_naive(&prices, period);
275            proptest::prop_assert_eq!(got.len(), want.len());
276            for (g, w) in got.iter().zip(want.iter()) {
277                match (g, w) {
278                    (None, None) => {}
279                    (Some(a), Some(b)) => proptest::prop_assert!(
280                        (a - b).abs() <= 1e-6 * b.abs().max(1.0),
281                        "got={a} want={b}"
282                    ),
283                    _ => proptest::prop_assert!(false, "warmup mismatch"),
284                }
285            }
286        }
287    }
288}