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