From 733afd90642ee5c0cf43a6bfadf64e6de5f341dd Mon Sep 17 00:00:00 2001 From: kingchenc Date: Sun, 24 May 2026 21:45:33 +0200 Subject: [PATCH] feat(cfo): add Chande Forecast Oscillator 100 * (close - LinReg(close, period)) / close. Positive when close overshoots the linear forecast, negative when it undershoots. Holds the previous value if the close is zero (percentage form undefined). Single param period (default 14). Touchpoints: cfo.rs + mod.rs + lib.rs re-export, PyCfo + __init__.py + test_new_indicators SCALAR + test_known_values linear reference, CfoNode + index.d.ts/index.js + indicators.test.js factory + reference, WasmCfo via scalar macro, scalar-fuzz target, README + CHANGELOG. --- CHANGELOG.md | 5 + README.md | 4 +- bindings/node/__tests__/indicators.test.js | 7 + bindings/node/index.js | 3 +- bindings/node/src/lib.rs | 34 ++++ bindings/python/python/wickra/__init__.py | 2 + bindings/python/src/lib.rs | 49 ++++++ bindings/python/tests/test_known_values.py | 6 + bindings/python/tests/test_new_indicators.py | 1 + crates/wickra-core/src/indicators/cfo.rs | 173 +++++++++++++++++++ crates/wickra-core/src/indicators/mod.rs | 2 + crates/wickra-core/src/lib.rs | 2 +- fuzz/fuzz_targets/indicator_update.rs | 3 +- 13 files changed, 286 insertions(+), 5 deletions(-) create mode 100644 crates/wickra-core/src/indicators/cfo.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 268b9c78..f9066ec6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Family 03 — MACD & Price Oscillators.** `CFO` (Chande Forecast + Oscillator): `100 · (close − LinReg(close, period)) / close`. Positive + when the close overshoots the linear forecast, negative when it + undershoots. Holds the previous value if the close is zero. Default + period 14. Exposed in all four bindings. - **Family 03 — MACD & Price Oscillators.** `AwesomeOscillatorHistogram`: `AO − SMA(AO, sma_period)`. A configurable variant of the existing `AcceleratorOscillator` (which fixes `(fast, slow, sma) = (5, 34, 5)`). diff --git a/README.md b/README.md index 2782c372..43664042 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ python -m benchmarks.compare_libraries ## Indicators -73 streaming-first indicators across eight families. Every one passes the +74 streaming-first indicators across eight families. Every one passes the `batch == streaming` equivalence test, reference-value tests, and reset semantics tests. @@ -118,7 +118,7 @@ semantics tests. | Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA | | Momentum Oscillators | RSI (Wilder), Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator | | Trend & Directional | MACD, ADX (+DI/-DI), Aroon, TRIX, Aroon Oscillator, Vortex, Mass Index, Choppiness Index, Vertical Horizontal Filter | -| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram | +| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO | | Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility | | Trailing Stops | Parabolic SAR, SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop | | Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement | diff --git a/bindings/node/__tests__/indicators.test.js b/bindings/node/__tests__/indicators.test.js index ef222372..fa0b5482 100644 --- a/bindings/node/__tests__/indicators.test.js +++ b/bindings/node/__tests__/indicators.test.js @@ -48,6 +48,7 @@ const scalarFactories = { StochRSI: () => new wickra.StochRSI(14, 14), PPO: () => new wickra.PPO(12, 26), APO: () => new wickra.APO(12, 26), + CFO: () => new wickra.CFO(14), DPO: () => new wickra.DPO(20), Coppock: () => new wickra.Coppock(14, 11, 10), StdDev: () => new wickra.StdDev(20), @@ -271,6 +272,12 @@ test('AwesomeOscillatorHistogram on a flat median converges to zero', () => { for (let i = 6; i < n; i++) assert.ok(Math.abs(out[i]) < 1e-12); }); +test('CFO(5) on a perfectly linear series yields zero', () => { + const prices = Array.from({ length: 20 }, (_, i) => (i + 1) * 2); + const out = new wickra.CFO(5).batch(prices); + for (let i = 4; i < 20; i++) assert.ok(Math.abs(out[i]) < 1e-9); +}); + test('APO(3, 5) on a flat series converges to zero', () => { const out = new wickra.APO(3, 5).batch(Array(30).fill(42)); for (let i = 0; i < 4; i++) assert.ok(Number.isNaN(out[i])); diff --git a/bindings/node/index.js b/bindings/node/index.js index a6193c16..58882c90 100644 --- a/bindings/node/index.js +++ b/bindings/node/index.js @@ -310,7 +310,7 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, APO, AwesomeOscillatorHistogram, T3, TSI, PMO, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA } = nativeBinding +const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, APO, AwesomeOscillatorHistogram, CFO, T3, TSI, PMO, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA } = nativeBinding module.exports.version = version module.exports.SMA = SMA @@ -351,6 +351,7 @@ module.exports.Aroon = Aroon module.exports.KAMA = KAMA module.exports.APO = APO module.exports.AwesomeOscillatorHistogram = AwesomeOscillatorHistogram +module.exports.CFO = CFO module.exports.T3 = T3 module.exports.TSI = TSI module.exports.PMO = PMO diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index 8baa693e..f13a23ab 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -1120,6 +1120,40 @@ impl AwesomeOscillatorHistogramNode { } } +#[napi(js_name = "CFO")] +pub struct CfoNode { + inner: wc::Cfo, +} +#[napi] +impl CfoNode { + #[napi(constructor)] + pub fn new(period: u32) -> napi::Result { + Ok(Self { + inner: wc::Cfo::new(clamp_period(period)).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + flatten(self.inner.batch(&prices)) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + #[napi(js_name = "APO")] pub struct ApoNode { inner: wc::Apo, diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py index e4c09291..4d242988 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -58,6 +58,7 @@ from ._wickra import ( UltimateOscillator, APO, AwesomeOscillatorHistogram, + CFO, PPO, DPO, Coppock, @@ -141,6 +142,7 @@ __all__ = [ "UltimateOscillator", "APO", "AwesomeOscillatorHistogram", + "CFO", "PPO", "DPO", "Coppock", diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index d7635ad0..93738b32 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -874,6 +874,54 @@ impl PyAoHist { } } +// ============================== CFO ============================== + +#[pyclass(name = "CFO", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyCfo { + inner: wc::Cfo, +} + +#[pymethods] +impl PyCfo { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Cfo::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let s = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("CFO(period={})", self.inner.period()) + } +} + // ============================== APO ============================== #[pyclass(name = "APO", module = "wickra._wickra", skip_from_py_object)] @@ -4603,6 +4651,7 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/bindings/python/tests/test_known_values.py b/bindings/python/tests/test_known_values.py index 792b9963..3d2e286c 100644 --- a/bindings/python/tests/test_known_values.py +++ b/bindings/python/tests/test_known_values.py @@ -76,6 +76,12 @@ def test_awesome_oscillator_histogram_flat_series_converges_to_zero(): np.testing.assert_allclose(out[6:], 0.0, atol=1e-12) +def test_cfo_perfect_linear_series_yields_zero(): + # LinReg of a perfectly linear series fits exactly, so CFO = 0 after warmup. + out = ta.CFO(5).batch(np.arange(1.0, 21.0, dtype=np.float64) * 2.0) + np.testing.assert_allclose(out[4:], 0.0, atol=1e-9) + + def test_apo_constant_series_converges_to_zero(): # Both EMAs reproduce a constant exactly, so APO = 0 after warmup. out = ta.APO(3, 5).batch(np.full(30, 42.0, dtype=np.float64)) diff --git a/bindings/python/tests/test_new_indicators.py b/bindings/python/tests/test_new_indicators.py index 0f42d16a..3783d28d 100644 --- a/bindings/python/tests/test_new_indicators.py +++ b/bindings/python/tests/test_new_indicators.py @@ -52,6 +52,7 @@ SCALAR = [ (ta.StochRSI, (14, 14)), (ta.PPO, (12, 26)), (ta.APO, (12, 26)), + (ta.CFO, (14,)), (ta.DPO, (20,)), (ta.Coppock, (14, 11, 10)), (ta.StdDev, (20,)), diff --git a/crates/wickra-core/src/indicators/cfo.rs b/crates/wickra-core/src/indicators/cfo.rs new file mode 100644 index 00000000..6c2168f4 --- /dev/null +++ b/crates/wickra-core/src/indicators/cfo.rs @@ -0,0 +1,173 @@ +//! 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, +} + +impl Cfo { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + 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 { + 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 = (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 = (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::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut cfo = Cfo::new(5).unwrap(); + cfo.batch(&(1..=20).map(f64::from).collect::>()); + 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); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index 22826311..e163eec5 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -18,6 +18,7 @@ mod balance_of_power; mod bollinger; mod bollinger_bandwidth; mod cci; +mod cfo; mod chaikin_oscillator; mod chaikin_volatility; mod chande_kroll_stop; @@ -92,6 +93,7 @@ pub use balance_of_power::BalanceOfPower; pub use bollinger::{BollingerBands, BollingerOutput}; pub use bollinger_bandwidth::BollingerBandwidth; pub use cci::Cci; +pub use cfo::Cfo; pub use chaikin_oscillator::ChaikinOscillator; pub use chaikin_volatility::ChaikinVolatility; pub use chande_kroll_stop::{ChandeKrollStop, ChandeKrollStopOutput}; diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index ec07344a..458e42e6 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -46,7 +46,7 @@ pub use error::{Error, Result}; pub use indicators::{ AcceleratorOscillator, Adl, Adx, AdxOutput, Apo, Aroon, AroonOscillator, AroonOutput, Atr, AtrTrailingStop, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, BollingerBands, - BollingerBandwidth, BollingerOutput, Cci, ChaikinMoneyFlow, ChaikinOscillator, + BollingerBandwidth, BollingerOutput, Cci, Cfo, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex, Cmo, Coppock, Dema, Donchian, DonchianOutput, Dpo, EaseOfMovement, Ema, ForceIndex, HistoricalVolatility, Hma, Kama, Keltner, KeltnerOutput, diff --git a/fuzz/fuzz_targets/indicator_update.rs b/fuzz/fuzz_targets/indicator_update.rs index f74b9df4..6fe9f91c 100644 --- a/fuzz/fuzz_targets/indicator_update.rs +++ b/fuzz/fuzz_targets/indicator_update.rs @@ -15,7 +15,7 @@ use libfuzzer_sys::fuzz_target; use wickra_core::{ - Apo, BatchExt, BollingerBands, Cmo, Coppock, Dema, Dpo, Ema, HistoricalVolatility, Hma, + Apo, BatchExt, BollingerBands, Cfo, Cmo, Coppock, Dema, Dpo, Ema, HistoricalVolatility, Hma, Indicator, Kama, LinRegAngle, LinRegSlope, LinearRegression, MacdIndicator, Mom, Pmo, Ppo, Roc, Rsi, Sma, Smma, StdDev, StochRsi, T3, Tema, Trima, Trix, Tsi, UlcerIndex, VerticalHorizontalFilter, Wma, @@ -63,6 +63,7 @@ fuzz_target!(|data: Vec| { drive(|| Dpo::new(14).unwrap(), &data); drive(|| Ppo::new(12, 26).unwrap(), &data); drive(|| Apo::new(12, 26).unwrap(), &data); + drive(|| Cfo::new(14).unwrap(), &data); drive(|| Coppock::new(14, 11, 10).unwrap(), &data); drive(|| StdDev::new(14).unwrap(), &data); drive(|| UlcerIndex::new(14).unwrap(), &data);