Skip to main content

wickra_core/indicators/
center_of_gravity.rs

1//! Ehlers Center of Gravity Oscillator.
2#![allow(clippy::manual_midpoint)]
3
4use std::collections::VecDeque;
5
6use crate::error::{Error, Result};
7use crate::traits::Indicator;
8
9/// Ehlers' Center of Gravity (CG) oscillator.
10///
11/// Treats the most recent `period` prices as masses and reports the
12/// weighted "center" of that mass distribution, negated so positive readings
13/// correspond to recent strength:
14///
15/// ```text
16/// num = sum_{k=0..period-1} (1 + k) * price[t - k]
17/// den = sum_{k=0..period-1} price[t - k]
18/// cg  = - num / den + (period + 1) / 2
19/// ```
20///
21/// The constant offset centres the oscillator around zero. From Ehlers,
22/// *Cybernetic Analysis for Stocks and Futures* (2004, ch. 7).
23///
24/// # Example
25///
26/// ```
27/// use wickra_core::{Indicator, CenterOfGravity};
28///
29/// let mut cg = CenterOfGravity::new(10).unwrap();
30/// let mut last = None;
31/// for i in 0..30 {
32///     last = cg.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0);
33/// }
34/// assert!(last.is_some());
35/// ```
36#[derive(Debug, Clone)]
37pub struct CenterOfGravity {
38    period: usize,
39    window: VecDeque<f64>,
40    last_value: Option<f64>,
41}
42
43impl CenterOfGravity {
44    /// Construct with the rolling window length.
45    ///
46    /// # Errors
47    ///
48    /// Returns [`Error::PeriodZero`] if `period == 0`.
49    pub fn new(period: usize) -> Result<Self> {
50        if period == 0 {
51            return Err(Error::PeriodZero);
52        }
53        if period > crate::error::MAX_PERIOD {
54            return Err(Error::InvalidPeriod {
55                message: crate::error::PERIOD_ABOVE_MAX,
56            });
57        }
58        Ok(Self {
59            period,
60            window: VecDeque::with_capacity(period),
61            last_value: None,
62        })
63    }
64
65    /// Configured period.
66    pub const fn period(&self) -> usize {
67        self.period
68    }
69
70    /// Current value if available.
71    pub const fn value(&self) -> Option<f64> {
72        self.last_value
73    }
74}
75
76impl Indicator for CenterOfGravity {
77    type Input = f64;
78    type Output = f64;
79
80    #[inline]
81    fn update(&mut self, input: f64) -> Option<f64> {
82        if !input.is_finite() {
83            return None;
84        }
85        if self.window.len() == self.period {
86            self.window.pop_front();
87        }
88        self.window.push_back(input);
89        if self.window.len() < self.period {
90            return None;
91        }
92        // Most recent has weight 1; oldest has weight `period`.
93        let mut num = 0.0;
94        let mut den = 0.0;
95        for (k, p) in self.window.iter().rev().enumerate() {
96            let w = 1.0 + k as f64;
97            num += w * p;
98            den += p;
99        }
100        let v = if den.abs() > f64::EPSILON {
101            -num / den + (self.period as f64 + 1.0) / 2.0
102        } else {
103            0.0
104        };
105        self.last_value = Some(v);
106        Some(v)
107    }
108
109    fn reset(&mut self) {
110        self.window.clear();
111        self.last_value = None;
112    }
113
114    #[inline]
115    fn warmup_period(&self) -> usize {
116        self.period
117    }
118
119    #[inline]
120    fn is_ready(&self) -> bool {
121        self.last_value.is_some()
122    }
123
124    #[inline]
125    fn name(&self) -> &'static str {
126        "CenterOfGravity"
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::traits::BatchExt;
134    use approx::assert_relative_eq;
135
136    #[test]
137    fn new_rejects_zero_period() {
138        assert!(matches!(CenterOfGravity::new(0), Err(Error::PeriodZero)));
139    }
140
141    #[test]
142    fn accessors_and_metadata() {
143        let mut cg = CenterOfGravity::new(10).unwrap();
144        assert_eq!(cg.period(), 10);
145        assert_eq!(cg.warmup_period(), 10);
146        assert_eq!(cg.name(), "CenterOfGravity");
147        assert!(!cg.is_ready());
148        for i in 1..=10 {
149            cg.update(f64::from(i));
150        }
151        assert!(cg.is_ready());
152        assert!(cg.value().is_some());
153    }
154
155    #[test]
156    fn constant_series_yields_zero() {
157        // num = sum k * p, den = period * p, ratio = (period + 1) / 2,
158        // so cg = - (period+1)/2 + (period+1)/2 = 0.
159        let mut cg = CenterOfGravity::new(5).unwrap();
160        let out = cg.batch(&[7.0_f64; 30]);
161        for x in out.iter().skip(5).flatten() {
162            assert_relative_eq!(*x, 0.0, epsilon = 1e-12);
163        }
164    }
165
166    #[test]
167    fn batch_equals_streaming() {
168        let prices: Vec<f64> = (1..=50).map(f64::from).collect();
169        let mut a = CenterOfGravity::new(10).unwrap();
170        let mut b = CenterOfGravity::new(10).unwrap();
171        let batch = a.batch(&prices);
172        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
173        assert_eq!(batch, streamed);
174    }
175
176    #[test]
177    fn ignores_non_finite_input() {
178        let mut cg = CenterOfGravity::new(5).unwrap();
179        cg.batch(&(1..=10).map(f64::from).collect::<Vec<_>>());
180        let before = cg.value();
181        assert!(before.is_some());
182        assert_eq!(cg.update(f64::NAN), None);
183    }
184
185    #[test]
186    fn reset_clears_state() {
187        let mut cg = CenterOfGravity::new(5).unwrap();
188        cg.batch(&(1..=10).map(f64::from).collect::<Vec<_>>());
189        assert!(cg.is_ready());
190        cg.reset();
191        assert!(!cg.is_ready());
192    }
193
194    #[test]
195    fn warmup_returns_none_until_seed() {
196        let mut cg = CenterOfGravity::new(4).unwrap();
197        assert_eq!(cg.update(1.0), None);
198        assert_eq!(cg.update(2.0), None);
199        assert_eq!(cg.update(3.0), None);
200        assert!(cg.update(4.0).is_some());
201    }
202
203    #[test]
204    fn zero_window_uses_zero_fallback() {
205        // den == sum(prices) == 0 when the rolling window is all zeros, which
206        // exercises the protective fallback in the divisor guard.
207        let mut cg = CenterOfGravity::new(5).unwrap();
208        let out = cg.batch(&[0.0_f64; 10]);
209        for x in out.iter().skip(5).flatten() {
210            assert_relative_eq!(*x, 0.0, epsilon = 1e-12);
211        }
212    }
213}