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
@@ -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,)),