From 54148cad5b5b0da4993491eecc90ed6f7870a04d Mon Sep 17 00:00:00 2001 From: kingchenc Date: Fri, 22 May 2026 18:09:10 +0200 Subject: [PATCH] F5: add PPO, DPO and Coppock Curve price oscillators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- bindings/node/index.js | 5 +- bindings/node/src/lib.rs | 76 +++++++ bindings/python/python/wickra/__init__.pyi | 40 ++++ bindings/python/src/lib.rs | 165 ++++++++++++++ bindings/wasm/src/lib.rs | 3 + crates/wickra-core/src/indicators/coppock.rs | 198 +++++++++++++++++ crates/wickra-core/src/indicators/dpo.rs | 208 ++++++++++++++++++ crates/wickra-core/src/indicators/mod.rs | 6 + crates/wickra-core/src/indicators/ppo.rs | 205 +++++++++++++++++ crates/wickra-core/src/lib.rs | 4 +- docs/wiki/Home.md | 3 + docs/wiki/Indicators-Overview.md | 5 +- .../indicators/momentum/Indicator-Coppock.md | 154 +++++++++++++ .../wiki/indicators/momentum/Indicator-Dpo.md | 162 ++++++++++++++ .../wiki/indicators/momentum/Indicator-Ppo.md | 155 +++++++++++++ 15 files changed, 1385 insertions(+), 4 deletions(-) create mode 100644 crates/wickra-core/src/indicators/coppock.rs create mode 100644 crates/wickra-core/src/indicators/dpo.rs create mode 100644 crates/wickra-core/src/indicators/ppo.rs create mode 100644 docs/wiki/indicators/momentum/Indicator-Coppock.md create mode 100644 docs/wiki/indicators/momentum/Indicator-Dpo.md create mode 100644 docs/wiki/indicators/momentum/Indicator-Ppo.md diff --git a/bindings/node/index.js b/bindings/node/index.js index 2474e4b2..16a9977f 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, 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 diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index 8d91b254..9a820503 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -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 { + 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 { + 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 + } +} + +// ============================== 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 { + 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 { + 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 = "VWMA")] pub struct VwmaNode { inner: wc::Vwma, diff --git a/bindings/python/python/wickra/__init__.pyi b/bindings/python/python/wickra/__init__.pyi index 15739908..9a0a908b 100644 --- a/bindings/python/python/wickra/__init__.pyi +++ b/bindings/python/python/wickra/__init__.pyi @@ -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]: ... diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 49e78946..89b316c0 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -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 { + Ok(Self { + inner: wc::Ppo::new(fast, slow).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 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 { + 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 { + Ok(Self { + inner: wc::Dpo::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 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 { + 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 { + Ok(Self { + inner: wc::Coppock::new(roc_long, roc_short, wma_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 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 { + 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::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; Ok(()) } diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 62ee94a8..aee0f701 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -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) ---------- diff --git a/crates/wickra-core/src/indicators/coppock.rs b/crates/wickra-core/src/indicators/coppock.rs new file mode 100644 index 00000000..82fe75d0 --- /dev/null +++ b/crates/wickra-core/src/indicators/coppock.rs @@ -0,0 +1,198 @@ +//! Coppock Curve. + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +use super::{Roc, Wma}; + +/// Coppock Curve — Edwin Coppock's long-term momentum indicator. +/// +/// The Coppock Curve is a weighted moving average of the sum of two rates of +/// change: +/// +/// ```text +/// Coppock = WMA( ROC(long) + ROC(short), wma_period ) +/// ``` +/// +/// Coppock designed it (1962) as a long-horizon buy signal for stock indices: +/// on a monthly chart with the conventional `(long = 14, short = 11, +/// wma_period = 10)`, a turn upward from below zero has historically marked +/// the start of a new bull phase. The two ROCs blend a slightly longer and a +/// slightly shorter momentum horizon; the WMA smooths the result. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Coppock}; +/// +/// let mut indicator = Coppock::new(14, 11, 10).unwrap(); +/// let mut last = None; +/// for i in 0..120 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Coppock { + roc_long_period: usize, + roc_short_period: usize, + wma_period: usize, + roc_long: Roc, + roc_short: Roc, + wma: Wma, + current: Option, +} + +impl Coppock { + /// Construct a new Coppock Curve with the two ROC periods and the WMA period. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if any period is `0`. + pub fn new(roc_long_period: usize, roc_short_period: usize, wma_period: usize) -> Result { + if roc_long_period == 0 || roc_short_period == 0 || wma_period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + roc_long_period, + roc_short_period, + wma_period, + roc_long: Roc::new(roc_long_period)?, + roc_short: Roc::new(roc_short_period)?, + wma: Wma::new(wma_period)?, + current: None, + }) + } + + /// The `(roc_long, roc_short, wma)` periods. + pub const fn periods(&self) -> (usize, usize, usize) { + (self.roc_long_period, self.roc_short_period, self.wma_period) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.current + } +} + +impl Indicator for Coppock { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + // Non-finite input is ignored; no component is advanced. + return self.current; + } + let long = self.roc_long.update(input); + let short = self.roc_short.update(input); + let result = match (long, short) { + (Some(l), Some(s)) => self.wma.update(l + s), + _ => None, + }; + if result.is_some() { + self.current = result; + } + result + } + + fn reset(&mut self) { + self.roc_long.reset(); + self.roc_short.reset(); + self.wma.reset(); + self.current = None; + } + + fn warmup_period(&self) -> usize { + // Both ROCs must be ready (the longer one is `period + 1`), then the + // WMA needs `wma_period` of their summed values. + self.roc_long_period.max(self.roc_short_period) + self.wma_period + } + + fn is_ready(&self) -> bool { + self.current.is_some() + } + + fn name(&self) -> &'static str { + "Coppock" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(Coppock::new(0, 11, 10), Err(Error::PeriodZero))); + assert!(matches!(Coppock::new(14, 0, 10), Err(Error::PeriodZero))); + assert!(matches!(Coppock::new(14, 11, 0), Err(Error::PeriodZero))); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut c = Coppock::new(6, 4, 3).unwrap(); + assert_eq!(c.warmup_period(), 9); + let out = c.batch(&(1..=40).map(f64::from).collect::>()); + for v in out.iter().take(8) { + assert!(v.is_none()); + } + assert!(out[8].is_some()); + } + + #[test] + fn constant_series_yields_zero() { + // Both ROCs are 0 on a flat series, so the WMA of zeros is 0. + let mut c = Coppock::new(6, 4, 3).unwrap(); + let out = c.batch(&[100.0; 40]); + for v in out.iter().skip(c.warmup_period() - 1).flatten() { + assert_relative_eq!(*v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn uptrend_is_positive() { + // A steady uptrend has positive ROCs, so the Coppock Curve is positive. + let mut c = Coppock::new(14, 11, 10).unwrap(); + let prices: Vec = (1..=120).map(|i| 100.0 * 1.01_f64.powi(i)).collect(); + let out = c.batch(&prices); + let last = out.iter().rev().flatten().next().unwrap(); + assert!( + *last > 0.0, + "uptrend Coppock should be positive, got {last}" + ); + } + + #[test] + fn ignores_non_finite_input() { + let mut c = Coppock::new(6, 4, 3).unwrap(); + let out = c.batch(&(1..=40).map(f64::from).collect::>()); + let last = *out.last().unwrap(); + assert!(last.is_some()); + assert_eq!(c.update(f64::NAN), last); + assert_eq!(c.update(f64::INFINITY), last); + } + + #[test] + fn reset_clears_state() { + let mut c = Coppock::new(6, 4, 3).unwrap(); + c.batch(&(1..=40).map(f64::from).collect::>()); + assert!(c.is_ready()); + c.reset(); + assert!(!c.is_ready()); + assert_eq!(c.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=120) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 10.0) + .collect(); + let batch = Coppock::new(14, 11, 10).unwrap().batch(&prices); + let mut b = Coppock::new(14, 11, 10).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/dpo.rs b/crates/wickra-core/src/indicators/dpo.rs new file mode 100644 index 00000000..35b11597 --- /dev/null +++ b/crates/wickra-core/src/indicators/dpo.rs @@ -0,0 +1,208 @@ +//! Detrended Price Oscillator. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +/// Detrended Price Oscillator — strips the trend out of price to expose its +/// shorter cycles. +/// +/// Instead of comparing price to a *current* moving average, DPO compares a +/// **past** price — shifted back by `period / 2 + 1` bars — to the moving +/// average of the window: +/// +/// ```text +/// shift = period / 2 + 1 +/// DPO_t = price_{t − shift} − SMA(period)_t +/// ``` +/// +/// Because the price is taken from roughly half a cycle back, the dominant +/// trend cancels out and what remains oscillates around zero — making the +/// peak-to-peak cycle length easy to read. DPO is **not** a momentum +/// indicator and is not meant to track the latest bar. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Dpo}; +/// +/// let mut indicator = Dpo::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 10.0); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Dpo { + period: usize, + shift: usize, + /// Window of the most recent `capacity` prices, oldest at the front. + capacity: usize, + window: VecDeque, + sum: f64, + last: Option, +} + +impl Dpo { + /// Construct a new DPO with the given period. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + let shift = period / 2 + 1; + // The window must cover both the SMA (`period` prices) and the + // look-back (`shift + 1` prices: the current bar plus `shift` history). + let capacity = period.max(shift + 1); + Ok(Self { + period, + shift, + capacity, + window: VecDeque::with_capacity(capacity), + sum: 0.0, + last: None, + }) + } + + /// Configured period. + pub const fn period(&self) -> usize { + self.period + } + + /// The look-back shift `period / 2 + 1`. + pub const fn shift(&self) -> usize { + self.shift + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for Dpo { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + // Non-finite input is ignored; the window is left untouched. + return self.last; + } + self.window.push_back(input); + self.sum += input; + let len = self.window.len(); + if len > self.period { + // The price that just left the SMA window. + self.sum -= self.window[len - 1 - self.period]; + } + if self.window.len() > self.capacity { + self.window.pop_front(); + } + if self.window.len() < self.capacity { + return None; + } + let sma = self.sum / self.period as f64; + // `price_{t - shift}` — index counts back from the newest bar. + let shifted = self.window[self.window.len() - 1 - self.shift]; + let dpo = shifted - sma; + self.last = Some(dpo); + Some(dpo) + } + + fn reset(&mut self) { + self.window.clear(); + self.sum = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.capacity + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "DPO" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(Dpo::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn shift_is_half_period_plus_one() { + assert_eq!(Dpo::new(20).unwrap().shift(), 11); + assert_eq!(Dpo::new(4).unwrap().shift(), 3); + } + + #[test] + fn reference_values() { + // DPO(4): shift = 3, capacity = max(4, 4) = 4. + // At input 4: window [1,2,3,4], SMA = 2.5, price[t-3] = 1 -> 1 - 2.5 = -1.5. + let mut dpo = Dpo::new(4).unwrap(); + let out = dpo.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); + assert_eq!(dpo.warmup_period(), 4); + assert_eq!(out[0], None); + assert_eq!(out[2], None); + assert_relative_eq!(out[3].unwrap(), -1.5, epsilon = 1e-12); + assert_relative_eq!(out[4].unwrap(), -1.5, epsilon = 1e-12); + assert_relative_eq!(out[5].unwrap(), -1.5, epsilon = 1e-12); + } + + #[test] + fn constant_series_yields_zero() { + // A flat series: the shifted price equals the SMA, so DPO is 0. + let mut dpo = Dpo::new(10).unwrap(); + let out = dpo.batch(&[50.0; 40]); + for v in out.iter().skip(dpo.warmup_period() - 1).flatten() { + assert_relative_eq!(*v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn ignores_non_finite_input() { + let mut dpo = Dpo::new(4).unwrap(); + let out = dpo.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]); + let last = *out.last().unwrap(); + assert!(last.is_some()); + assert_eq!(dpo.update(f64::NAN), last); + assert_eq!(dpo.update(f64::INFINITY), last); + } + + #[test] + fn reset_clears_state() { + let mut dpo = Dpo::new(4).unwrap(); + dpo.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); + assert!(dpo.is_ready()); + dpo.reset(); + assert!(!dpo.is_ready()); + assert_eq!(dpo.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=80) + .map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 7.0) + .collect(); + let batch = Dpo::new(20).unwrap().batch(&prices); + let mut b = Dpo::new(20).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index 31abb838..a7f70825 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -11,8 +11,10 @@ mod awesome_oscillator; mod bollinger; mod cci; mod cmo; +mod coppock; mod dema; mod donchian; +mod dpo; mod ema; mod hma; mod kama; @@ -22,6 +24,7 @@ mod mfi; mod mom; mod obv; mod pmo; +mod ppo; mod psar; mod roc; mod rsi; @@ -48,8 +51,10 @@ pub use awesome_oscillator::AwesomeOscillator; pub use bollinger::{BollingerBands, BollingerOutput}; pub use cci::Cci; pub use cmo::Cmo; +pub use coppock::Coppock; pub use dema::Dema; pub use donchian::{Donchian, DonchianOutput}; +pub use dpo::Dpo; pub use ema::Ema; pub use hma::Hma; pub use kama::Kama; @@ -59,6 +64,7 @@ pub use mfi::Mfi; pub use mom::Mom; pub use obv::Obv; pub use pmo::Pmo; +pub use ppo::Ppo; pub use psar::Psar; pub use roc::Roc; pub use rsi::Rsi; diff --git a/crates/wickra-core/src/indicators/ppo.rs b/crates/wickra-core/src/indicators/ppo.rs new file mode 100644 index 00000000..81d2a737 --- /dev/null +++ b/crates/wickra-core/src/indicators/ppo.rs @@ -0,0 +1,205 @@ +//! Percentage Price Oscillator. + +use crate::error::{Error, Result}; +use crate::traits::Indicator; + +use super::Ema; + +/// Percentage Price Oscillator — MACD expressed as a percentage. +/// +/// PPO is the gap between a fast and a slow EMA, divided by the slow EMA and +/// scaled to a percentage: +/// +/// ```text +/// PPO = 100 · (EMA_fast − EMA_slow) / EMA_slow +/// ``` +/// +/// Dividing by the slow EMA makes PPO **scale-free**: a `PPO` of `1.5` means +/// "the fast EMA is 1.5 % above the slow EMA" on any instrument, so PPO +/// readings *are* comparable across assets — unlike the raw price-unit +/// [`MacdIndicator`](crate::MacdIndicator). The classic PPO **signal line** is +/// a 9-period EMA of this PPO line; compose it with [`Chain`](crate::Chain) +/// and an [`Ema`] if you need it. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Indicator, Ppo}; +/// +/// let mut indicator = Ppo::new(12, 26).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = indicator.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Ppo { + fast: usize, + slow: usize, + ema_fast: Ema, + ema_slow: Ema, + current: Option, +} + +impl Ppo { + /// Construct a new PPO with the `fast` and `slow` EMA periods. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if either period is `0`, or + /// [`Error::InvalidPeriod`] if `fast >= slow`. + pub fn new(fast: usize, slow: usize) -> Result { + if fast == 0 || slow == 0 { + return Err(Error::PeriodZero); + } + if fast >= slow { + return Err(Error::InvalidPeriod { + message: "PPO fast period must be < slow period", + }); + } + Ok(Self { + fast, + slow, + ema_fast: Ema::new(fast)?, + ema_slow: Ema::new(slow)?, + current: None, + }) + } + + /// The `(fast, slow)` periods. + pub const fn periods(&self) -> (usize, usize) { + (self.fast, self.slow) + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.current + } +} + +impl Indicator for Ppo { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + if !input.is_finite() { + // Non-finite input is ignored; the EMAs are not advanced. + return self.current; + } + let fast = self.ema_fast.update(input); + let slow = self.ema_slow.update(input); + match (fast, slow) { + (Some(f), Some(s)) => { + let ppo = if s == 0.0 { + // Undefined ratio against a zero slow EMA: report flat. + 0.0 + } else { + 100.0 * (f - s) / s + }; + self.current = Some(ppo); + Some(ppo) + } + _ => None, + } + } + + fn reset(&mut self) { + self.ema_fast.reset(); + self.ema_slow.reset(); + self.current = None; + } + + fn warmup_period(&self) -> usize { + // The slow EMA is the last to seed. + self.slow + } + + fn is_ready(&self) -> bool { + self.current.is_some() + } + + fn name(&self) -> &'static str { + "PPO" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn new_rejects_zero_period() { + assert!(matches!(Ppo::new(0, 26), Err(Error::PeriodZero))); + assert!(matches!(Ppo::new(12, 0), Err(Error::PeriodZero))); + } + + #[test] + fn new_rejects_fast_not_less_than_slow() { + assert!(matches!(Ppo::new(26, 12), Err(Error::InvalidPeriod { .. }))); + assert!(matches!(Ppo::new(12, 12), Err(Error::InvalidPeriod { .. }))); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut ppo = Ppo::new(3, 6).unwrap(); + assert_eq!(ppo.warmup_period(), 6); + let out = ppo.batch(&(1..=30).map(f64::from).collect::>()); + for v in out.iter().take(5) { + assert!(v.is_none()); + } + assert!(out[5].is_some()); + } + + #[test] + fn constant_series_yields_zero() { + // Both EMAs converge to the constant, so their gap is zero. + let mut ppo = Ppo::new(3, 6).unwrap(); + let out = ppo.batch(&[100.0; 60]); + for v in out.iter().skip(5).flatten() { + assert_relative_eq!(*v, 0.0, epsilon = 1e-9); + } + } + + #[test] + fn uptrend_is_positive() { + // In a rising series the fast EMA leads the slow EMA, so PPO > 0. + let mut ppo = Ppo::new(5, 12).unwrap(); + let out = ppo.batch(&(1..=80).map(f64::from).collect::>()); + let last = out.iter().rev().flatten().next().unwrap(); + assert!(*last > 0.0, "uptrend PPO should be positive, got {last}"); + } + + #[test] + fn ignores_non_finite_input() { + let mut ppo = Ppo::new(3, 6).unwrap(); + let out = ppo.batch(&(1..=30).map(f64::from).collect::>()); + let last = *out.last().unwrap(); + assert!(last.is_some()); + assert_eq!(ppo.update(f64::NAN), last); + assert_eq!(ppo.update(f64::INFINITY), last); + } + + #[test] + fn reset_clears_state() { + let mut ppo = Ppo::new(3, 6).unwrap(); + ppo.batch(&(1..=30).map(f64::from).collect::>()); + assert!(ppo.is_ready()); + ppo.reset(); + assert!(!ppo.is_ready()); + assert_eq!(ppo.update(1.0), None); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=120) + .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0) + .collect(); + let batch = Ppo::new(12, 26).unwrap().batch(&prices); + let mut b = Ppo::new(12, 26).unwrap(); + let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index 9d464b80..07529e4a 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -45,8 +45,8 @@ pub mod indicators; pub use error::{Error, Result}; pub use indicators::{ Adx, AdxOutput, Aroon, AroonOutput, Atr, AwesomeOscillator, BollingerBands, BollingerOutput, - Cci, Cmo, Dema, Donchian, DonchianOutput, Ema, Hma, Kama, Keltner, KeltnerOutput, - MacdIndicator, MacdOutput, Mfi, Mom, Obv, Pmo, Psar, Roc, RollingVwap, Rsi, Sma, Smma, + Cci, Cmo, Coppock, Dema, Donchian, DonchianOutput, Dpo, Ema, Hma, Kama, Keltner, KeltnerOutput, + MacdIndicator, MacdOutput, Mfi, Mom, Obv, Pmo, Ppo, Psar, Roc, RollingVwap, Rsi, Sma, Smma, StochRsi, Stochastic, StochasticOutput, Tema, Trima, Trix, Tsi, UltimateOscillator, Vwap, Vwma, WilliamsR, Wma, Zlema, T3, }; diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 62f86737..bd994911 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -104,6 +104,9 @@ Rust / Python / Node examples. They are grouped by family, mirroring the - [Indicator-Pmo.md](indicators/momentum/Indicator-Pmo.md) - [Indicator-StochRsi.md](indicators/momentum/Indicator-StochRsi.md) - [Indicator-UltimateOscillator.md](indicators/momentum/Indicator-UltimateOscillator.md) +- [Indicator-Ppo.md](indicators/momentum/Indicator-Ppo.md) +- [Indicator-Dpo.md](indicators/momentum/Indicator-Dpo.md) +- [Indicator-Coppock.md](indicators/momentum/Indicator-Coppock.md) **Volatility** — envelope width and per-bar dispersion measures. diff --git a/docs/wiki/Indicators-Overview.md b/docs/wiki/Indicators-Overview.md index d10d89ba..b5f12f93 100644 --- a/docs/wiki/Indicators-Overview.md +++ b/docs/wiki/Indicators-Overview.md @@ -1,6 +1,6 @@ # Indicators Overview -Wickra ships 36 indicators, organised in source under the four classical +Wickra ships 39 indicators, organised in source under the four classical families — trend, momentum, volatility, volume — that map directly to the directory structure of `crates/wickra-core/src/indicators/`. The same family labels are used here, plus a second-level grouping that reflects how the @@ -103,6 +103,9 @@ Centered on zero or driven by raw price differences; no fixed cap. | `Cmo` | Chande Momentum Oscillator; `100·(Σgain − Σloss)/(Σgain + Σloss)` over `period` changes. | `f64` | `f64` | `[−100, 100]` | `period = 14` (Python) | `period + 1` | [Indicator-Cmo.md](indicators/momentum/Indicator-Cmo.md) | | `Tsi` | True Strength Index; ratio of double-EMA-smoothed momentum to its absolute value. | `f64` | `f64` | ≈ `[−100, 100]` around zero | `(long=25, short=13)` (Python) | `long + short` | [Indicator-Tsi.md](indicators/momentum/Indicator-Tsi.md) | | `Pmo` | DecisionPoint Price Momentum Oscillator; doubly-smoothed rate of change. | `f64` | `f64` | unbounded around zero | `(smoothing1=35, smoothing2=20)` (Python) | `2` | [Indicator-Pmo.md](indicators/momentum/Indicator-Pmo.md) | +| `Ppo` | Percentage Price Oscillator; `100·(EMA_fast − EMA_slow)/EMA_slow`. | `f64` | `f64` | unbounded around zero (percent) | `(fast=12, slow=26)` (Python) | `slow` | [Indicator-Ppo.md](indicators/momentum/Indicator-Ppo.md) | +| `Dpo` | Detrended Price Oscillator; `price[t − period/2 − 1] − SMA(period)`. | `f64` | `f64` | unbounded around zero | `period = 20` (Python) | `max(period, period/2 + 2)` | [Indicator-Dpo.md](indicators/momentum/Indicator-Dpo.md) | +| `Coppock` | Coppock Curve; `WMA(ROC(long) + ROC(short), wma_period)`. | `f64` | `f64` | unbounded around zero | `(roc_long=14, roc_short=11, wma_period=10)` (Python) | `max(roc_long, roc_short) + wma_period` | [Indicator-Coppock.md](indicators/momentum/Indicator-Coppock.md) | ### Directional diff --git a/docs/wiki/indicators/momentum/Indicator-Coppock.md b/docs/wiki/indicators/momentum/Indicator-Coppock.md new file mode 100644 index 00000000..69990f50 --- /dev/null +++ b/docs/wiki/indicators/momentum/Indicator-Coppock.md @@ -0,0 +1,154 @@ +# Coppock + +> Coppock Curve — a long-horizon momentum indicator: a weighted moving +> average of two rates of change, designed to flag major bottoms. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Momentum | +| Sub-category | Unbounded oscillators | +| Input type | `f64` (single close) | +| Output type | `f64` | +| Output range | unbounded around zero | +| Default parameters | `(roc_long = 14, roc_short = 11, wma_period = 10)` (Python) | +| Warmup period | `max(roc_long, roc_short) + wma_period` | +| Interpretation | Long-term momentum; an upturn from below zero is the buy signal. | + +## Formula + +``` +Coppock = WMA( ROC(roc_long) + ROC(roc_short), wma_period ) +``` + +Edwin Coppock built this in 1962 as a long-horizon buy signal for stock +indices. The two rates of change blend a slightly longer and a slightly +shorter momentum horizon; the [`Wma`](../trend/Indicator-Wma.md) smooths +their sum. On a **monthly** chart with the conventional +`(14, 11, 10)` settings, the curve turning *up from below zero* has +historically marked the start of a new bull phase. + +## Parameters + +| Name | Type | Default | Valid range | Description | +|--------------|---------|---------------|-------------|-------------| +| `roc_long` | `usize` | `14` (Python) | `>= 1` | Longer ROC period. `0` errors with `Error::PeriodZero`. | +| `roc_short` | `usize` | `11` (Python) | `>= 1` | Shorter ROC period. | +| `wma_period` | `usize` | `10` (Python) | `>= 1` | WMA smoothing length. | + +The Python binding defaults the trio to `(14, 11, 10)`. The `periods` +property returns `(roc_long, roc_short, wma_period)`. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/coppock.rs`: + +```rust +impl Indicator for Coppock { + type Input = f64; + type Output = f64; + // update(&mut self, input: f64) -> Option +} +``` + +A single `f64` close in, an `Option` out. Python maps this to +`float | None` / `numpy.ndarray` (NaN warmup); Node to `number | null` / +`Array` (NaN warmup). + +## Warmup + +`warmup_period() == max(roc_long, roc_short) + wma_period`. Each ROC emits +its first value at input `roc_period + 1`; the longer ROC is the last to +become ready, and the WMA then needs `wma_period` of the summed ROC +values — so the first non-`None` output lands on input +`max(roc_long, roc_short) + wma_period`. + +## Edge cases + +- **Constant series.** Both ROCs are `0` on a flat series, so the WMA of + zeros — and the curve — is `0` (`constant_series_yields_zero` pins + this). +- **NaN / infinity inputs.** Non-finite inputs are silently dropped; no + component is advanced. +- **Reset.** `coppock.reset()` clears both ROCs and the WMA. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Indicator, Coppock}; + +fn main() -> Result<(), Box> { + let mut coppock = Coppock::new(14, 11, 10)?; + let prices: Vec = (1..=120).map(|i| 100.0 * 1.01_f64.powi(i)).collect(); + let out = coppock.batch(&prices); + println!("warmup_period = {}", coppock.warmup_period()); + println!("last > 0: {}", out.last().unwrap().unwrap() > 0.0); + Ok(()) +} +``` + +Output: + +``` +warmup_period = 24 +last > 0: true +``` + +A steady uptrend keeps both ROCs positive, so the Coppock Curve stays +above zero. + +### Python + +```python +import numpy as np +import wickra as ta + +coppock = ta.Coppock() # (roc_long=14, roc_short=11, wma_period=10) +prices = np.full(60, 100.0) # flat series +print(coppock.batch(prices)[-1]) # ROCs are 0 -> 0 +``` + +Output: + +``` +0.0 +``` + +### Node + +```javascript +const ta = require('wickra'); +const coppock = new ta.Coppock(14, 11, 10); +const prices = Array.from({ length: 120 }, (_, i) => 100 * 1.01 ** i); +console.log('warmupPeriod:', coppock.warmupPeriod()); +``` + +## Interpretation + +`Coppock` is a long-horizon signal, traditionally read on **monthly** +data. The canonical rule is a single one: when the curve has been below +zero and turns up, that is a long-term buy. It was not designed to give +sell signals — Coppock left exits to other tools. On faster timeframes it +behaves as a smoothed momentum oscillator, but its statistical edge is +specifically the monthly bottom call. + +## Common pitfalls + +- **Using it for sell signals.** The Coppock Curve is a buy-only + indicator by design; pair it with a separate exit rule. +- **Applying it intraday and expecting the historical edge.** The + documented behaviour is for monthly index charts. + +## References + +E. S. Coppock, "Practical Relative Strength Charting", *Barron's* (1962). +The `WMA(ROC(14) + ROC(11), 10)` construction here is Coppock's original. + +## See also + +- [Indicator-Roc.md](Indicator-Roc.md) — the rate-of-change building block. +- [Indicator-Wma.md](../trend/Indicator-Wma.md) — the smoothing average. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy. diff --git a/docs/wiki/indicators/momentum/Indicator-Dpo.md b/docs/wiki/indicators/momentum/Indicator-Dpo.md new file mode 100644 index 00000000..85866d7f --- /dev/null +++ b/docs/wiki/indicators/momentum/Indicator-Dpo.md @@ -0,0 +1,162 @@ +# DPO + +> Detrended Price Oscillator — removes the trend from price by comparing a +> shifted past price to the moving average, exposing the underlying cycle. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Momentum | +| Sub-category | Unbounded oscillators | +| Input type | `f64` (single close) | +| Output type | `f64` | +| Output range | unbounded around zero (price-difference scale) | +| Default parameters | `period = 20` (Python) | +| Warmup period | `max(period, period / 2 + 2)` | +| Interpretation | Detrended price; peak-to-peak spacing reveals the cycle length. | + +## Formula + +``` +shift = period / 2 + 1 +DPO_t = price_{t − shift} − SMA(period)_t +``` + +A normal oscillator compares price to a *current* average and therefore +still carries the trend. DPO instead subtracts the average from a price +taken `period / 2 + 1` bars **back** — roughly half a cycle. The dominant +trend cancels, and what is left swings around zero with the same period +as the price's shorter cycles, so the distance between DPO peaks reads off +the cycle length directly. + +DPO is **not** a momentum or signal indicator: by construction it is +shifted into the past and is not meant to track the latest bar. + +## Parameters + +| Name | Type | Default | Valid range | Description | +|----------|---------|---------------|-------------|-------------| +| `period` | `usize` | `20` (Python) | `>= 1` | SMA length; also sets the look-back `shift = period / 2 + 1`. `0` errors with `Error::PeriodZero`. | + +The Python binding defaults `period` to `20`. The derived `shift` is +exposed as a read-only property. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/dpo.rs`: + +```rust +impl Indicator for Dpo { + type Input = f64; + type Output = f64; + // update(&mut self, input: f64) -> Option +} +``` + +A single `f64` close in, an `Option` out. Python maps this to +`float | None` / `numpy.ndarray` (NaN warmup); Node to `number | null` / +`Array` (NaN warmup). + +## Warmup + +`warmup_period() == max(period, period / 2 + 2)`. The output needs both a +full `period`-bar SMA window and a price `shift` bars back; the indicator +becomes ready once the rolling window holds enough bars for both. For the +usual `period >= 4` this simplifies to `period`. + +## Edge cases + +- **Constant series.** On a flat series the shifted price equals the SMA, + so DPO is `0` (`constant_series_yields_zero` pins this). +- **NaN / infinity inputs.** Non-finite inputs are silently dropped; the + window is not advanced. +- **Reset.** `dpo.reset()` clears the window and the rolling sum. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Indicator, Dpo}; + +fn main() -> Result<(), Box> { + let mut dpo = Dpo::new(4)?; + let out: Vec> = dpo.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); + println!("{:?}", out); + println!("shift = {}, warmup_period = {}", dpo.shift(), dpo.warmup_period()); + Ok(()) +} +``` + +Output: + +``` +[None, None, None, Some(-1.5), Some(-1.5), Some(-1.5)] +shift = 3, warmup_period = 4 +``` + +`DPO(4)` has `shift = 3`. At input 4 the SMA of `[1,2,3,4]` is `2.5` and +the price 3 bars back is `1`, giving `1 − 2.5 = −1.5`. On a pure ramp the +detrended value is constant. This matches the `reference_values` test in +`crates/wickra-core/src/indicators/dpo.rs`. + +### Python + +```python +import numpy as np +import wickra as ta + +dpo = ta.DPO(4) +print(dpo.batch(np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0]))) +``` + +Output: + +``` +[ nan nan nan -1.5 -1.5 -1.5] +``` + +### Node + +```javascript +const ta = require('wickra'); +const dpo = new ta.DPO(4); +console.log(dpo.batch([1, 2, 3, 4, 5, 6])); +``` + +Output: + +``` +[ NaN, NaN, NaN, -1.5, -1.5, -1.5 ] +``` + +## Interpretation + +`Dpo` is a cycle-measurement tool, not a trading trigger. Read it for the +*spacing* of its peaks and troughs: regular spacing reveals the dominant +cycle length, which you can then feed back into the periods of other +indicators. Crossing zero is not a signal — because the series is shifted +into the past, the latest DPO value does not correspond to the latest bar. + +## Common pitfalls + +- **Trading the zero cross.** DPO is detrended *and* time-shifted; its + latest value is historical. Use it to size cycles, not to time entries. +- **Reading it as momentum.** It is a detrended price, not a rate of + change — see [`Roc`](Indicator-Roc.md) or [`Mom`](Indicator-Mom.md) for + momentum. + +## References + +The Detrended Price Oscillator is a standard cycle-analysis study; the +`period / 2 + 1` look-back shift used here matches the common definition +(StockCharts, TA-Lib-compatible implementations). + +## See also + +- [Indicator-Sma.md](../trend/Indicator-Sma.md) — the moving average DPO + detrends against. +- [Indicator-Roc.md](Indicator-Roc.md) — momentum, the indicator DPO is + often confused with. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy. diff --git a/docs/wiki/indicators/momentum/Indicator-Ppo.md b/docs/wiki/indicators/momentum/Indicator-Ppo.md new file mode 100644 index 00000000..a3a9ba15 --- /dev/null +++ b/docs/wiki/indicators/momentum/Indicator-Ppo.md @@ -0,0 +1,155 @@ +# PPO + +> Percentage Price Oscillator — MACD expressed as a percentage of the slow +> EMA, so readings are comparable across instruments. + +## Quick reference + +| Field | Value | +|-------|-------| +| Family | Momentum | +| Sub-category | Unbounded oscillators | +| Input type | `f64` (single close) | +| Output type | `f64` | +| Output range | unbounded around zero (percent) | +| Default parameters | `(fast = 12, slow = 26)` (Python) | +| Warmup period | `slow` | +| Interpretation | Percentage gap between a fast and slow EMA; zero-line crosses are signals. | + +## Formula + +``` +PPO = 100 · (EMA_fast − EMA_slow) / EMA_slow +``` + +PPO is [`MacdIndicator`](Indicator-MacdIndicator.md) divided by the slow +EMA. That single change makes it **scale-free**: a `PPO` of `1.5` always +means "the fast EMA is 1.5 % above the slow EMA", whether the instrument +trades at $5 or $5000 — so PPO values can be compared across assets and +across time, which raw MACD values cannot. The classic PPO **signal +line** is a 9-period EMA of this PPO line; compose it with +[`Chain`](../Indicator-Chaining.md) and an `Ema(9)`. + +## Parameters + +| Name | Type | Default | Valid range | Description | +|--------|---------|---------------|------------------|-------------| +| `fast` | `usize` | `12` (Python) | `>= 1`, `< slow` | Fast EMA period. | +| `slow` | `usize` | `26` (Python) | `> fast` | Slow EMA period. | + +`fast` must be strictly less than `slow` — otherwise `new` returns +`Error::InvalidPeriod`. A zero period returns `Error::PeriodZero`. The +Python binding defaults the pair to `(12, 26)`; the `periods` property +returns `(fast, slow)`. + +## Inputs / Outputs + +From `crates/wickra-core/src/indicators/ppo.rs`: + +```rust +impl Indicator for Ppo { + type Input = f64; + type Output = f64; + // update(&mut self, input: f64) -> Option +} +``` + +A single `f64` close in, an `Option` out. Python maps this to +`float | None` / `numpy.ndarray` (NaN warmup); Node to `number | null` / +`Array` (NaN warmup). + +## Warmup + +`Ppo::new(fast, slow).warmup_period() == slow`. Both EMAs are SMA-seeded; +the slow EMA is the last to seed, at input `slow`, which is also when PPO +emits its first value. + +## Edge cases + +- **Constant series.** Both EMAs converge to the constant, so their gap — + and PPO — is `0` (`constant_series_yields_zero` pins this). +- **Zero slow EMA.** A `0.0` slow EMA would divide by zero; PPO reports + `0.0` for that bar instead. +- **NaN / infinity inputs.** Non-finite inputs are silently dropped; the + EMAs are not advanced. +- **Reset.** `ppo.reset()` clears both EMAs and the cached value. + +## Examples + +### Rust + +```rust +use wickra::{BatchExt, Indicator, Ppo}; + +fn main() -> Result<(), Box> { + let mut ppo = Ppo::new(12, 26)?; + let prices: Vec = (1..=80).map(f64::from).collect(); + let out = ppo.batch(&prices); + println!("warmup_period = {}", ppo.warmup_period()); + println!("last > 0: {}", out.last().unwrap().unwrap() > 0.0); + Ok(()) +} +``` + +Output: + +``` +warmup_period = 26 +last > 0: true +``` + +In a rising series the fast EMA leads the slow EMA, so PPO is positive. + +### Python + +```python +import numpy as np +import wickra as ta + +ppo = ta.PPO() # (fast=12, slow=26) +prices = np.full(60, 100.0) # flat series +print(ppo.batch(prices)[-1]) # both EMAs equal -> 0 +``` + +Output: + +``` +0.0 +``` + +### Node + +```javascript +const ta = require('wickra'); +const ppo = new ta.PPO(12, 26); +const prices = Array.from({ length: 80 }, (_, i) => 100 + i); +console.log('warmupPeriod:', ppo.warmupPeriod()); +``` + +## Interpretation + +`Ppo` is read exactly like MACD: the zero-line cross (fast EMA crossing +the slow EMA), the signal-line cross (PPO crossing its own 9-EMA), and +histogram-style divergence. Its advantage over MACD is comparability — a +PPO scan across a watchlist ranks instruments by *relative* trend +strength, which a MACD scan cannot do because MACD is in each +instrument's own price units. + +## Common pitfalls + +- **Expecting a bundled signal line.** `Ppo` here is the single PPO line; + add `Ema(9)` via `Chain` for the signal line and histogram. +- **`fast >= slow`.** The constructor rejects it — the fast EMA must be + the faster one. + +## References + +Gerald Appel's MACD, re-expressed as a percentage. The implementation +follows the standard PPO definition and matches TA-Lib's `PPO`. + +## See also + +- [Indicator-MacdIndicator.md](Indicator-MacdIndicator.md) — the price-unit + original, with a bundled signal line and histogram. +- [Indicator-Ema.md](../trend/Indicator-Ema.md) — the underlying average. +- [Indicators-Overview.md](../../Indicators-Overview.md) — the full taxonomy.