wickra_core/indicators/
geometric_ma.rs1use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::indicators::rolling_moments::RollingSum;
7use crate::traits::Indicator;
8
9#[derive(Debug, Clone)]
44pub struct GeometricMa {
45 period: usize,
46 logs: VecDeque<f64>,
48 sum_logs: RollingSum,
49}
50
51impl GeometricMa {
52 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 pub const fn period(&self) -> usize {
75 self.period
76 }
77
78 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 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 #[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 assert_relative_eq!(gma.update(2.0).unwrap(), 2.0, epsilon = 1e-12);
176 }
177
178 #[test]
179 fn known_value_period_2() {
180 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 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 assert_eq!(gma.update(f64::NAN), None);
258 assert_eq!(gma.update(0.0), None);
259 assert_eq!(gma.update(-3.0), None);
260 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}