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        Ok(Self {
54            period,
55            window: VecDeque::with_capacity(period),
56            last_value: None,
57        })
58    }
59
60    /// Configured period.
61    pub const fn period(&self) -> usize {
62        self.period
63    }
64
65    /// Current value if available.
66    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        // Most recent has weight 1; oldest has weight `period`.
87        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        // num = sum k * p, den = period * p, ratio = (period + 1) / 2,
149        // so cg = - (period+1)/2 + (period+1)/2 = 0.
150        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        // den == sum(prices) == 0 when the rolling window is all zeros, which
197        // exercises the protective fallback in the divisor guard.
198        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}