wickra_core/indicators/
center_of_gravity.rs1#![allow(clippy::manual_midpoint)]
3
4use std::collections::VecDeque;
5
6use crate::error::{Error, Result};
7use crate::traits::Indicator;
8
9#[derive(Debug, Clone)]
37pub struct CenterOfGravity {
38 period: usize,
39 window: VecDeque<f64>,
40 last_value: Option<f64>,
41}
42
43impl CenterOfGravity {
44 pub fn new(period: usize) -> Result<Self> {
50 if period == 0 {
51 return Err(Error::PeriodZero);
52 }
53 Ok(Self {
54 period,
55 window: VecDeque::with_capacity(period),
56 last_value: None,
57 })
58 }
59
60 pub const fn period(&self) -> usize {
62 self.period
63 }
64
65 pub const fn value(&self) -> Option<f64> {
67 self.last_value
68 }
69}
70
71impl Indicator for CenterOfGravity {
72 type Input = f64;
73 type Output = f64;
74
75 fn update(&mut self, input: f64) -> Option<f64> {
76 if !input.is_finite() {
77 return self.last_value;
78 }
79 if self.window.len() == self.period {
80 self.window.pop_front();
81 }
82 self.window.push_back(input);
83 if self.window.len() < self.period {
84 return None;
85 }
86 let mut num = 0.0;
88 let mut den = 0.0;
89 for (k, p) in self.window.iter().rev().enumerate() {
90 let w = 1.0 + k as f64;
91 num += w * p;
92 den += p;
93 }
94 let v = if den.abs() > f64::EPSILON {
95 -num / den + (self.period as f64 + 1.0) / 2.0
96 } else {
97 0.0
98 };
99 self.last_value = Some(v);
100 Some(v)
101 }
102
103 fn reset(&mut self) {
104 self.window.clear();
105 self.last_value = None;
106 }
107
108 fn warmup_period(&self) -> usize {
109 self.period
110 }
111
112 fn is_ready(&self) -> bool {
113 self.last_value.is_some()
114 }
115
116 fn name(&self) -> &'static str {
117 "CenterOfGravity"
118 }
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124 use crate::traits::BatchExt;
125 use approx::assert_relative_eq;
126
127 #[test]
128 fn new_rejects_zero_period() {
129 assert!(matches!(CenterOfGravity::new(0), Err(Error::PeriodZero)));
130 }
131
132 #[test]
133 fn accessors_and_metadata() {
134 let mut cg = CenterOfGravity::new(10).unwrap();
135 assert_eq!(cg.period(), 10);
136 assert_eq!(cg.warmup_period(), 10);
137 assert_eq!(cg.name(), "CenterOfGravity");
138 assert!(!cg.is_ready());
139 for i in 1..=10 {
140 cg.update(f64::from(i));
141 }
142 assert!(cg.is_ready());
143 assert!(cg.value().is_some());
144 }
145
146 #[test]
147 fn constant_series_yields_zero() {
148 let mut cg = CenterOfGravity::new(5).unwrap();
151 let out = cg.batch(&[7.0_f64; 30]);
152 for x in out.iter().skip(5).flatten() {
153 assert_relative_eq!(*x, 0.0, epsilon = 1e-12);
154 }
155 }
156
157 #[test]
158 fn batch_equals_streaming() {
159 let prices: Vec<f64> = (1..=50).map(f64::from).collect();
160 let mut a = CenterOfGravity::new(10).unwrap();
161 let mut b = CenterOfGravity::new(10).unwrap();
162 let batch = a.batch(&prices);
163 let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
164 assert_eq!(batch, streamed);
165 }
166
167 #[test]
168 fn ignores_non_finite_input() {
169 let mut cg = CenterOfGravity::new(5).unwrap();
170 cg.batch(&(1..=10).map(f64::from).collect::<Vec<_>>());
171 let before = cg.value();
172 assert!(before.is_some());
173 assert_eq!(cg.update(f64::NAN), before);
174 }
175
176 #[test]
177 fn reset_clears_state() {
178 let mut cg = CenterOfGravity::new(5).unwrap();
179 cg.batch(&(1..=10).map(f64::from).collect::<Vec<_>>());
180 assert!(cg.is_ready());
181 cg.reset();
182 assert!(!cg.is_ready());
183 }
184
185 #[test]
186 fn warmup_returns_none_until_seed() {
187 let mut cg = CenterOfGravity::new(4).unwrap();
188 assert_eq!(cg.update(1.0), None);
189 assert_eq!(cg.update(2.0), None);
190 assert_eq!(cg.update(3.0), None);
191 assert!(cg.update(4.0).is_some());
192 }
193
194 #[test]
195 fn zero_window_uses_zero_fallback() {
196 let mut cg = CenterOfGravity::new(5).unwrap();
199 let out = cg.batch(&[0.0_f64; 10]);
200 for x in out.iter().skip(5).flatten() {
201 assert_relative_eq!(*x, 0.0, epsilon = 1e-12);
202 }
203 }
204}