174 lines
4.6 KiB
Rust
174 lines
4.6 KiB
Rust
//! Chande Forecast Oscillator (CFO).
|
||||
|
|
|
|||
|
|
use crate::error::{Error, Result};
|
|||
|
|
use crate::indicators::linreg::LinearRegression;
|
|||
|
|
use crate::traits::Indicator;
|
|||
|
|
|
|||
|
|
/// Tushar Chande's Forecast Oscillator — the percentage difference between
|
|||
|
|
/// the close and the endpoint of an `n`-bar linear-regression forecast of the
|
|||
|
|
/// close.
|
|||
|
|
///
|
|||
|
|
/// ```text
|
|||
|
|
/// CFO_t = 100 · (close_t − LinearRegression(close, period)_t) / close_t
|
|||
|
|
/// ```
|
|||
|
|
///
|
|||
|
|
/// Positive readings mean the close is *above* the linear forecast (price has
|
|||
|
|
/// overshot trend); negative readings mean it sits below. Wraps the existing
|
|||
|
|
/// `LinearRegression` so the warmup matches.
|
|||
|
|
///
|
|||
|
|
/// # Example
|
|||
|
|
///
|
|||
|
|
/// ```
|
|||
|
|
/// use wickra_core::{Cfo, Indicator};
|
|||
|
|
///
|
|||
|
|
/// let mut cfo = Cfo::new(14).unwrap();
|
|||
|
|
/// let mut last = None;
|
|||
|
|
/// for i in 0..40 {
|
|||
|
|
/// last = cfo.update(100.0 + f64::from(i));
|
|||
|
|
/// }
|
|||
|
|
/// assert!(last.is_some());
|
|||
|
|
/// ```
|
|||
|
|
#[derive(Debug, Clone)]
|
|||
|
|
pub struct Cfo {
|
|||
|
|
period: usize,
|
|||
|
|
linreg: LinearRegression,
|
|||
|
|
current: Option<f64>,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl Cfo {
|
|||
|
|
/// # Errors
|
|||
|
|
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
|||
|
|
pub fn new(period: usize) -> Result<Self> {
|
|||
|
|
if period == 0 {
|
|||
|
|
return Err(Error::PeriodZero);
|
|||
|
|
}
|
|||
|
|
Ok(Self {
|
|||
|
|
period,
|
|||
|
|
linreg: LinearRegression::new(period)?,
|
|||
|
|
current: None,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Configured period.
|
|||
|
|
pub const fn period(&self) -> usize {
|
|||
|
|
self.period
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl Indicator for Cfo {
|
|||
|
|
type Input = f64;
|
|||
|
|
type Output = f64;
|
|||
|
|
|
|||
|
|
fn update(&mut self, input: f64) -> Option<f64> {
|
|||
|
|
let forecast = self.linreg.update(input)?;
|
|||
|
|
// Hold the previous value if the close is zero — the percentage form
|
|||
|
|
// is undefined and a return of inf would propagate badly.
|
|||
|
|
if input == 0.0 {
|
|||
|
|
return self.current;
|
|||
|
|
}
|
|||
|
|
let value = 100.0 * (input - forecast) / input;
|
|||
|
|
self.current = Some(value);
|
|||
|
|
Some(value)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn reset(&mut self) {
|
|||
|
|
self.linreg.reset();
|
|||
|
|
self.current = None;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn warmup_period(&self) -> usize {
|
|||
|
|
self.period
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn is_ready(&self) -> bool {
|
|||
|
|
self.current.is_some()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn name(&self) -> &'static str {
|
|||
|
|
"CFO"
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[cfg(test)]
|
|||
|
|
mod tests {
|
|||
|
|
use super::*;
|
|||
|
|
use crate::traits::BatchExt;
|
|||
|
|
use approx::assert_relative_eq;
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn rejects_zero_period() {
|
|||
|
|
assert!(matches!(Cfo::new(0), Err(Error::PeriodZero)));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn accessors_and_metadata() {
|
|||
|
|
let cfo = Cfo::new(14).unwrap();
|
|||
|
|
assert_eq!(cfo.period(), 14);
|
|||
|
|
assert_eq!(cfo.warmup_period(), 14);
|
|||
|
|
assert_eq!(cfo.name(), "CFO");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn constant_series_yields_zero() {
|
|||
|
|
// LinReg of a constant series equals the constant, so close − forecast
|
|||
|
|
// is 0 and CFO is 0.
|
|||
|
|
let mut cfo = Cfo::new(5).unwrap();
|
|||
|
|
let out = cfo.batch(&[42.0_f64; 30]);
|
|||
|
|
for v in out.iter().skip(4).flatten() {
|
|||
|
|
assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn perfect_linear_series_yields_zero() {
|
|||
|
|
// LinReg of a perfectly linear input fits the line exactly, so the
|
|||
|
|
// close lands on the forecast and CFO = 0.
|
|||
|
|
let mut cfo = Cfo::new(5).unwrap();
|
|||
|
|
let prices: Vec<f64> = (1..=20).map(|i| f64::from(i) * 2.0).collect();
|
|||
|
|
let out = cfo.batch(&prices);
|
|||
|
|
for v in out.iter().skip(4).flatten() {
|
|||
|
|
assert_relative_eq!(*v, 0.0, epsilon = 1e-9);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn warmup_emits_first_value_at_period() {
|
|||
|
|
let mut cfo = Cfo::new(3).unwrap();
|
|||
|
|
for i in 1..=2 {
|
|||
|
|
assert_eq!(cfo.update(f64::from(i)), None);
|
|||
|
|
}
|
|||
|
|
assert!(cfo.update(3.0).is_some());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn batch_equals_streaming() {
|
|||
|
|
let prices: Vec<f64> = (1..=80)
|
|||
|
|
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
|
|||
|
|
.collect();
|
|||
|
|
let mut a = Cfo::new(14).unwrap();
|
|||
|
|
let mut b = Cfo::new(14).unwrap();
|
|||
|
|
assert_eq!(
|
|||
|
|
a.batch(&prices),
|
|||
|
|
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn reset_clears_state() {
|
|||
|
|
let mut cfo = Cfo::new(5).unwrap();
|
|||
|
|
cfo.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
|||
|
|
assert!(cfo.is_ready());
|
|||
|
|
cfo.reset();
|
|||
|
|
assert!(!cfo.is_ready());
|
|||
|
|
assert_eq!(cfo.update(1.0), None);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn zero_close_holds_value() {
|
|||
|
|
let mut cfo = Cfo::new(3).unwrap();
|
|||
|
|
cfo.batch(&[1.0_f64, 2.0, 3.0]);
|
|||
|
|
let before = cfo.current;
|
|||
|
|
assert_eq!(cfo.update(0.0), before);
|
|||
|
|
}
|
|||
|
|
}
|