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