F5: add PPO, DPO and Coppock Curve price oscillators

Completes the F5 family (Price oscillators) end to end:

- Rust core: ppo.rs (Percentage Price Oscillator — MACD as a percentage
  of the slow EMA), dpo.rs (Detrended Price Oscillator — shifted price
  minus its SMA), coppock.rs (Coppock Curve — WMA of two summed ROCs).
  Each with a full Indicator impl, runnable doctest and reference /
  constant-series / warmup / reset / batch==streaming / non-finite tests.
- Python: PyPpo / PyDpo / PyCoppock PyO3 classes + module registration
  + .pyi stubs (defaults PPO=(12,26), DPO=20, Coppock=(14,11,10)).
- Node: DpoNode via the scalar macro, explicit PpoNode and CoppockNode;
  index.d.ts and index.js updated.
- WASM: WasmDpo / WasmPpo / WasmCoppock via the scalar macro.
- Wiki: Indicator-Ppo/Dpo/Coppock.md plus rows in Indicators-Overview.md
  and entries in Home.md.

cargo fmt + clippy (core/wickra/data/wasm/node) clean; 300 core tests,
25 data tests and 42 doctests green.
This commit is contained in:
kingchenc
2026-05-22 18:09:10 +02:00
parent e24e7726ce
commit 54148cad5b
15 changed files with 1385 additions and 4 deletions
+4 -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, T3, VWMA, MOM, CMO, TSI, PMO, StochRSI, UltimateOscillator, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, AwesomeOscillator, Aroon, KAMA } = nativeBinding
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, T3, VWMA, MOM, CMO, TSI, PMO, StochRSI, UltimateOscillator, PPO, DPO, Coppock, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, AwesomeOscillator, Aroon, KAMA } = nativeBinding
module.exports.version = version
module.exports.SMA = SMA
@@ -333,6 +333,9 @@ module.exports.TSI = TSI
module.exports.PMO = PMO
module.exports.StochRSI = StochRSI
module.exports.UltimateOscillator = UltimateOscillator
module.exports.PPO = PPO
module.exports.DPO = DPO
module.exports.Coppock = Coppock
module.exports.MACD = MACD
module.exports.BollingerBands = BollingerBands
module.exports.ATR = ATR
+76
View File
@@ -107,6 +107,7 @@ node_scalar_indicator!(TrimaNode, "TRIMA", wc::Trima);
node_scalar_indicator!(ZlemaNode, "ZLEMA", wc::Zlema);
node_scalar_indicator!(MomNode, "MOM", wc::Mom);
node_scalar_indicator!(CmoNode, "CMO", wc::Cmo);
node_scalar_indicator!(DpoNode, "DPO", wc::Dpo);
// ============================== MACD ==============================
@@ -1238,6 +1239,81 @@ impl UltimateOscillatorNode {
}
}
// ============================== PPO ==============================
#[napi(js_name = "PPO")]
pub struct PpoNode {
inner: wc::Ppo,
}
#[napi]
impl PpoNode {
#[napi(constructor)]
pub fn new(fast: u32, slow: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::Ppo::new(fast as usize, slow as usize).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
}
}
// ============================== Coppock ==============================
#[napi(js_name = "Coppock")]
pub struct CoppockNode {
inner: wc::Coppock,
}
#[napi]
impl CoppockNode {
#[napi(constructor)]
pub fn new(roc_long: u32, roc_short: u32, wma_period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::Coppock::new(roc_long as usize, roc_short as usize, wma_period as usize)
.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 = "VWMA")]
pub struct VwmaNode {
inner: wc::Vwma,
@@ -76,6 +76,46 @@ class TRIMA:
@property
def value(self) -> Optional[float]: ...
class PPO:
def __init__(self, fast: int = 12, slow: int = 26) -> None: ...
def update(self, value: float) -> Optional[float]: ...
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def periods(self) -> Tuple[int, int]: ...
@property
def value(self) -> Optional[float]: ...
class DPO:
def __init__(self, period: int = 20) -> None: ...
def update(self, value: float) -> Optional[float]: ...
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def period(self) -> int: ...
@property
def shift(self) -> int: ...
@property
def value(self) -> Optional[float]: ...
class Coppock:
def __init__(
self, roc_long: int = 14, roc_short: int = 11, wma_period: int = 10
) -> None: ...
def update(self, value: float) -> Optional[float]: ...
def batch(self, prices: NDArray[np.float64]) -> NDArray[np.float64]: ...
def reset(self) -> None: ...
def is_ready(self) -> bool: ...
def warmup_period(self) -> int: ...
@property
def periods(self) -> Tuple[int, int, int]: ...
@property
def value(self) -> Optional[float]: ...
class StochRSI:
def __init__(self, rsi_period: int = 14, stoch_period: int = 14) -> None: ...
def update(self, value: float) -> Optional[float]: ...
+165
View File
@@ -1519,6 +1519,168 @@ impl PyAroon {
}
}
// ============================== PPO ==============================
#[pyclass(name = "PPO", module = "wickra._wickra")]
#[derive(Clone)]
struct PyPpo {
inner: wc::Ppo,
}
#[pymethods]
impl PyPpo {
#[new]
#[pyo3(signature = (fast=12, slow=26))]
fn new(fast: usize, slow: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::Ppo::new(fast, slow).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 slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(slice)).into_pyarray_bound(py))
}
#[getter]
fn periods(&self) -> (usize, usize) {
self.inner.periods()
}
#[getter]
fn value(&self) -> Option<f64> {
self.inner.value()
}
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 {
let (f, s) = self.inner.periods();
format!("PPO(fast={f}, slow={s})")
}
}
// ============================== DPO ==============================
#[pyclass(name = "DPO", module = "wickra._wickra")]
#[derive(Clone)]
struct PyDpo {
inner: wc::Dpo,
}
#[pymethods]
impl PyDpo {
#[new]
#[pyo3(signature = (period=20))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::Dpo::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 slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(slice)).into_pyarray_bound(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
#[getter]
fn shift(&self) -> usize {
self.inner.shift()
}
#[getter]
fn value(&self) -> Option<f64> {
self.inner.value()
}
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!("DPO(period={})", self.inner.period())
}
}
// ============================== Coppock ==============================
#[pyclass(name = "Coppock", module = "wickra._wickra")]
#[derive(Clone)]
struct PyCoppock {
inner: wc::Coppock,
}
#[pymethods]
impl PyCoppock {
#[new]
#[pyo3(signature = (roc_long=14, roc_short=11, wma_period=10))]
fn new(roc_long: usize, roc_short: usize, wma_period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::Coppock::new(roc_long, roc_short, wma_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 slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(slice)).into_pyarray_bound(py))
}
#[getter]
fn periods(&self) -> (usize, usize, usize) {
self.inner.periods()
}
#[getter]
fn value(&self) -> Option<f64> {
self.inner.value()
}
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 {
let (l, s, w) = self.inner.periods();
format!("Coppock(roc_long={l}, roc_short={s}, wma_period={w})")
}
}
// ============================== StochRSI ==============================
#[pyclass(name = "StochRSI", module = "wickra._wickra")]
@@ -2180,5 +2342,8 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyPmo>()?;
m.add_class::<PyStochRsi>()?;
m.add_class::<PyUltimateOscillator>()?;
m.add_class::<PyPpo>()?;
m.add_class::<PyDpo>()?;
m.add_class::<PyCoppock>()?;
Ok(())
}
+3
View File
@@ -84,6 +84,9 @@ wasm_scalar_indicator!(WasmCmo, "CMO", wc::Cmo, period: usize);
wasm_scalar_indicator!(WasmTsi, "TSI", wc::Tsi, long: usize, short: usize);
wasm_scalar_indicator!(WasmPmo, "PMO", wc::Pmo, smoothing1: usize, smoothing2: usize);
wasm_scalar_indicator!(WasmStochRsi, "StochRSI", wc::StochRsi, rsi_period: usize, stoch_period: usize);
wasm_scalar_indicator!(WasmDpo, "DPO", wc::Dpo, period: usize);
wasm_scalar_indicator!(WasmPpo, "PPO", wc::Ppo, fast: usize, slow: usize);
wasm_scalar_indicator!(WasmCoppock, "Coppock", wc::Coppock, roc_long: usize, roc_short: usize, wma_period: usize);
// ---------- KAMA (three params) ----------