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        let forecast = self.linreg.update(input)?;
64        // Hold the previous value if the close is zero — the percentage form
65        // is undefined and a return of inf would propagate badly.
66        if input == 0.0 {
67            return self.current;
68        }
69        let value = 100.0 * (input - forecast) / input;
70        self.current = Some(value);
71        Some(value)
72    }
73
74    fn reset(&mut self) {
75        self.linreg.reset();
76        self.current = None;
77    }
78
79    fn warmup_period(&self) -> usize {
80        self.period
81    }
82
83    fn is_ready(&self) -> bool {
84        self.current.is_some()
85    }
86
87    fn name(&self) -> &'static str {
88        "CFO"
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use crate::traits::BatchExt;
96    use approx::assert_relative_eq;
97
98    #[test]
99    fn rejects_zero_period() {
100        assert!(matches!(Cfo::new(0), Err(Error::PeriodZero)));
101    }
102
103    #[test]
104    fn accessors_and_metadata() {
105        let cfo = Cfo::new(14).unwrap();
106        assert_eq!(cfo.period(), 14);
107        assert_eq!(cfo.warmup_period(), 14);
108        assert_eq!(cfo.name(), "CFO");
109    }
110
111    #[test]
112    fn constant_series_yields_zero() {
113        // LinReg of a constant series equals the constant, so close − forecast
114        // is 0 and CFO is 0.
115        let mut cfo = Cfo::new(5).unwrap();
116        let out = cfo.batch(&[42.0_f64; 30]);
117        for v in out.iter().skip(4).flatten() {
118            assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
119        }
120    }
121
122    #[test]
123    fn perfect_linear_series_yields_zero() {
124        // LinReg of a perfectly linear input fits the line exactly, so the
125        // close lands on the forecast and CFO = 0.
126        let mut cfo = Cfo::new(5).unwrap();
127        let prices: Vec<f64> = (1..=20).map(|i| f64::from(i) * 2.0).collect();
128        let out = cfo.batch(&prices);
129        for v in out.iter().skip(4).flatten() {
130            assert_relative_eq!(*v, 0.0, epsilon = 1e-9);
131        }
132    }
133
134    #[test]
135    fn warmup_emits_first_value_at_period() {
136        let mut cfo = Cfo::new(3).unwrap();
137        for i in 1..=2 {
138            assert_eq!(cfo.update(f64::from(i)), None);
139        }
140        assert!(cfo.update(3.0).is_some());
141    }
142
143    #[test]
144    fn batch_equals_streaming() {
145        let prices: Vec<f64> = (1..=80)
146            .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
147            .collect();
148        let mut a = Cfo::new(14).unwrap();
149        let mut b = Cfo::new(14).unwrap();
150        assert_eq!(
151            a.batch(&prices),
152            prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
153        );
154    }
155
156    #[test]
157    fn reset_clears_state() {
158        let mut cfo = Cfo::new(5).unwrap();
159        cfo.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
160        assert!(cfo.is_ready());
161        cfo.reset();
162        assert!(!cfo.is_ready());
163        assert_eq!(cfo.update(1.0), None);
164    }
165
166    #[test]
167    fn zero_close_holds_value() {
168        let mut cfo = Cfo::new(3).unwrap();
169        cfo.batch(&[1.0_f64, 2.0, 3.0]);
170        let before = cfo.current;
171        assert_eq!(cfo.update(0.0), before);
172    }
173}