wickra_core/indicators/
geometric_ma.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8#[derive(Debug, Clone)]
43pub struct GeometricMa {
44 period: usize,
45 logs: VecDeque<f64>,
47 sum_logs: f64,
48}
49
50impl GeometricMa {
51 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 pub const fn period(&self) -> usize {
69 self.period
70 }
71
72 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 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 #[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 assert_relative_eq!(gma.update(2.0).unwrap(), 2.0, epsilon = 1e-12);
163 }
164
165 #[test]
166 fn known_value_period_2() {
167 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 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 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 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}