Skip to main content

wickra_core/indicators/
cfo.rs

1//! Chande Forecast Oscillator (CFO).
2
3use crate::error::{Error, Result};
4use crate::indicators::linreg::LinearRegression;
5use crate::traits::Indicator;
6
7/// Tushar Chande's Forecast Oscillator — the percentage difference between
8/// the close and the endpoint of an `n`-bar linear-regression forecast of the
9/// close.
10///
11/// ```text
12/// CFO_t = 100 · (close_t − LinearRegression(close, period)_t) / close_t
13/// ```
14///
15/// Positive readings mean the close is *above* the linear forecast (price has
16/// overshot trend); negative readings mean it sits below. Wraps the existing
17/// `LinearRegression` so the warmup matches.
18///
19/// # Example
20///
21/// ```
22/// use wickra_core::{Cfo, Indicator};
23///
24/// let mut cfo = Cfo::new(14).unwrap();
25/// let mut last = None;
26/// for i in 0..40 {
27///     last = cfo.update(100.0 + f64::from(i));
28/// }
29/// assert!(last.is_some());
30/// ```
31#[derive(Debug, Clone)]
32pub struct Cfo {
33    period: usize,
34    linreg: LinearRegression,
35    current: Option<f64>,
36}
37
38impl Cfo {
39    /// # Errors
40    /// Returns [`Error::PeriodZero`] if `period == 0`.
41    pub fn new(period: usize) -> Result<Self> {
42        if period == 0 {
43            return Err(Error::PeriodZero);
44        }
45        Ok(Self {
46            period,
47            linreg: LinearRegression::new(period)?,
48            current: None,
49        })
50    }
51
52    /// Configured period.
53    pub const fn period(&self) -> usize {
54        self.period
55    }
56}
57
58impl Indicator for Cfo {
59    type Input = f64;
60    type Output = f64;
61
62    fn update(&mut self, input: f64) -> Option<f64> {
63        if !input.is_finite() {
64            return None;
65        }
66        let forecast = self.linreg.update(input)?;
67        // Hold the previous value if the close is zero — the percentage form
68        // is undefined and a return of inf would propagate badly.
69        if input == 0.0 {
70            return self.current;
71        }
72        let value = 100.0 * (input - forecast) / input;
73        self.current = Some(value);
74        Some(value)
75    }
76
77    fn reset(&mut self) {
78        self.linreg.reset();
79        self.current = None;
80    }
81
82    fn warmup_period(&self) -> usize {
83        self.period
84    }
85
86    fn is_ready(&self) -> bool {
87        self.current.is_some()
88    }
89
90    fn name(&self) -> &'static str {
91        "CFO"
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::traits::BatchExt;
99    use approx::assert_relative_eq;
100
101    #[test]
102    fn rejects_zero_period() {
103        assert!(matches!(Cfo::new(0), Err(Error::PeriodZero)));
104    }
105
106    #[test]
107    fn accessors_and_metadata() {
108        let cfo = Cfo::new(14).unwrap();
109        assert_eq!(cfo.period(), 14);
110        assert_eq!(cfo.warmup_period(), 14);
111        assert_eq!(cfo.name(), "CFO");
112    }
113
114    #[test]
115    fn constant_series_yields_zero() {
116        // LinReg of a constant series equals the constant, so close − forecast
117        // is 0 and CFO is 0.
118        let mut cfo = Cfo::new(5).unwrap();
119        let out = cfo.batch(&[42.0_f64; 30]);
120        for v in out.iter().skip(4).flatten() {
121            assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
122        }
123    }
124
125    #[test]
126    fn perfect_linear_series_yields_zero() {
127        // LinReg of a perfectly linear input fits the line exactly, so the
128        // close lands on the forecast and CFO = 0.
129        let mut cfo = Cfo::new(5).unwrap();
130        let prices: Vec<f64> = (1..=20).map(|i| f64::from(i) * 2.0).collect();
131        let out = cfo.batch(&prices);
132        for v in out.iter().skip(4).flatten() {
133            assert_relative_eq!(*v, 0.0, epsilon = 1e-9);
134        }
135    }
136
137    #[test]
138    fn warmup_emits_first_value_at_period() {
139        let mut cfo = Cfo::new(3).unwrap();
140        for i in 1..=2 {
141            assert_eq!(cfo.update(f64::from(i)), None);
142        }
143        assert!(cfo.update(3.0).is_some());
144    }
145
146    #[test]
147    fn batch_equals_streaming() {
148        let prices: Vec<f64> = (1..=80)
149            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
150            .collect();
151        let mut a = Cfo::new(14).unwrap();
152        let mut b = Cfo::new(14).unwrap();
153        assert_eq!(
154            a.batch(&prices),
155            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
156        );
157    }
158
159    #[test]
160    fn reset_clears_state() {
161        let mut cfo = Cfo::new(5).unwrap();
162        cfo.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
163        assert!(cfo.is_ready());
164        cfo.reset();
165        assert!(!cfo.is_ready());
166        assert_eq!(cfo.update(1.0), None);
167    }
168
169    #[test]
170    fn zero_close_holds_value() {
171        let mut cfo = Cfo::new(3).unwrap();
172        cfo.batch(&[1.0_f64, 2.0, 3.0]);
173        let before = cfo.current;
174        assert_eq!(cfo.update(0.0), before);
175    }
176}