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:
@@ -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)`).
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,)),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user