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.
This commit is contained in:
kingchenc
2026-05-24 21:45:33 +02:00
parent e043a0dd9b
commit 733afd9064
13 changed files with 286 additions and 5 deletions
+5
View File
@@ -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)`).
+2 -2
View File
@@ -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 |
@@ -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]));
+2 -1
View File
@@ -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
+34
View File
@@ -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<Self> {
Ok(Self {
inner: wc::Cfo::new(clamp_period(period)).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
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,
@@ -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",
+49
View File
@@ -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<Self> {
Ok(Self {
inner: wc::Cfo::new(period).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
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::<PyKama>()?;
m.add_class::<PyApo>()?;
m.add_class::<PyAoHist>()?;
m.add_class::<PyCfo>()?;
m.add_class::<PyCci>()?;
m.add_class::<PyRoc>()?;
m.add_class::<PyWilliamsR>()?;
@@ -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))
@@ -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,)),
+173
View File
@@ -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<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);
}
}
+2
View File
@@ -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};
+1 -1
View File
@@ -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,
+2 -1
View File
@@ -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<f64>| {
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);