From ec269d8aebae32b5032a47120d1d95d96c1e6c83 Mon Sep 17 00:00:00 2001 From: kingchenc Date: Sun, 24 May 2026 21:38:48 +0200 Subject: [PATCH] feat(apo): add Absolute Price Oscillator EMA(close, fast) - EMA(close, slow). Like MACD without the signal EMA. Defaults to (fast = 12, slow = 26); fast must be strictly less than slow. Touchpoints: apo.rs + mod.rs + lib.rs re-export, PyApo + __init__.py + test_new_indicators SCALAR + test_known_values flat reference, ApoNode + index.d.ts/index.js + indicators.test.js factory + reference, WasmApo via scalar macro, scalar-fuzz target, README + CHANGELOG. --- CHANGELOG.md | 6 + README.md | 4 +- bindings/node/__tests__/indicators.test.js | 7 + bindings/node/index.js | 3 +- bindings/python/python/wickra/__init__.py | 2 + bindings/python/tests/test_known_values.py | 7 + bindings/python/tests/test_new_indicators.py | 1 + crates/wickra-core/src/indicators/apo.rs | 183 +++++++++++++++++++ crates/wickra-core/src/indicators/mod.rs | 2 + crates/wickra-core/src/lib.rs | 2 +- fuzz/fuzz_targets/indicator_update.rs | 6 +- 11 files changed, 217 insertions(+), 6 deletions(-) create mode 100644 crates/wickra-core/src/indicators/apo.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 28b7fb86..e32ee506 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Family 03 — MACD & Price Oscillators.** `APO` (Absolute Price + Oscillator): `EMA(close, fast) − EMA(close, slow)`. Like MACD's line + without the signal EMA. Default `(fast = 12, slow = 26)`. `fast` must + be strictly less than `slow`. Exposed in all four bindings. + ## [0.2.7] - 2026-05-24 ### Added diff --git a/README.md b/README.md index cbde05d9..83e04639 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ python -m benchmarks.compare_libraries ## Indicators -71 streaming-first indicators across eight families. Every one passes the +72 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 | +| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO | | 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 | diff --git a/bindings/node/__tests__/indicators.test.js b/bindings/node/__tests__/indicators.test.js index f9c7aff8..d86b71b6 100644 --- a/bindings/node/__tests__/indicators.test.js +++ b/bindings/node/__tests__/indicators.test.js @@ -47,6 +47,7 @@ const scalarFactories = { PMO: () => new wickra.PMO(35, 20), StochRSI: () => new wickra.StochRSI(14, 14), PPO: () => new wickra.PPO(12, 26), + APO: () => new wickra.APO(12, 26), DPO: () => new wickra.DPO(20), Coppock: () => new wickra.Coppock(14, 11, 10), StdDev: () => new wickra.StdDev(20), @@ -258,3 +259,9 @@ test('LinRegAngle of a unit-slope series is 45 degrees', () => { const out = new wickra.LinRegAngle(5).batch([1, 2, 3, 4, 5, 6]); assert.ok(Math.abs(out[4] - 45) < 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])); + for (let i = 4; i < 30; i++) assert.ok(Math.abs(out[i]) < 1e-12); +}); diff --git a/bindings/node/index.js b/bindings/node/index.js index 7e75b0f6..cec8d084 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, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, KAMA, 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, 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 @@ -349,6 +349,7 @@ module.exports.RollingVWAP = RollingVWAP module.exports.AwesomeOscillator = AwesomeOscillator module.exports.Aroon = Aroon module.exports.KAMA = KAMA +module.exports.APO = APO module.exports.T3 = T3 module.exports.TSI = TSI module.exports.PMO = PMO diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py index 6a4bd208..7e4bf78f 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -56,6 +56,7 @@ from ._wickra import ( PMO, StochRSI, UltimateOscillator, + APO, PPO, DPO, Coppock, @@ -137,6 +138,7 @@ __all__ = [ "PMO", "StochRSI", "UltimateOscillator", + "APO", "PPO", "DPO", "Coppock", diff --git a/bindings/python/tests/test_known_values.py b/bindings/python/tests/test_known_values.py index c481b26c..8485efd6 100644 --- a/bindings/python/tests/test_known_values.py +++ b/bindings/python/tests/test_known_values.py @@ -66,6 +66,13 @@ def test_rsi_wilder_textbook_first_value(): assert math.isclose(out[14], 70.464, abs_tol=0.05) +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)) + assert np.all(np.isnan(out[:4])) + np.testing.assert_allclose(out[4:], 0.0, atol=1e-12) + + def test_macd_constant_series_converges_to_zero(): out = ta.MACD().batch(np.full(200, 100.0)) # Last row's MACD and signal must be ~0. diff --git a/bindings/python/tests/test_new_indicators.py b/bindings/python/tests/test_new_indicators.py index e4919bd4..50088d46 100644 --- a/bindings/python/tests/test_new_indicators.py +++ b/bindings/python/tests/test_new_indicators.py @@ -51,6 +51,7 @@ SCALAR = [ (ta.PMO, (35, 20)), (ta.StochRSI, (14, 14)), (ta.PPO, (12, 26)), + (ta.APO, (12, 26)), (ta.DPO, (20,)), (ta.Coppock, (14, 11, 10)), (ta.StdDev, (20,)), diff --git a/crates/wickra-core/src/indicators/apo.rs b/crates/wickra-core/src/indicators/apo.rs new file mode 100644 index 00000000..6b5e242e --- /dev/null +++ b/crates/wickra-core/src/indicators/apo.rs @@ -0,0 +1,183 @@ +//! Absolute Price Oscillator (APO). + +use crate::error::{Error, Result}; +use crate::indicators::ema::Ema; +use crate::traits::Indicator; + +/// Absolute Price Oscillator — the raw difference between a fast and a slow +/// `EMA`. This is MACD's line without the signal-EMA — useful when only the +/// momentum-direction reading is needed. +/// +/// ```text +/// APO_t = EMA(close, fast)_t − EMA(close, slow)_t +/// ``` +/// +/// Default parameters mirror MACD: `(fast = 12, slow = 26)`. `fast` must be +/// strictly less than `slow`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Apo, Indicator}; +/// +/// let mut apo = Apo::new(12, 26).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// last = apo.update(100.0 + f64::from(i)); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Apo { + fast_period: usize, + slow_period: usize, + fast: Ema, + slow: Ema, +} + +impl Apo { + /// # Errors + /// - [`Error::PeriodZero`] if either period is zero. + /// - [`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: "APO fast period must be strictly less than slow", + }); + } + Ok(Self { + fast_period: fast, + slow_period: slow, + fast: Ema::new(fast)?, + slow: Ema::new(slow)?, + }) + } + + /// MACD-style defaults: `(fast = 12, slow = 26)`. + pub fn classic() -> Self { + Self::new(12, 26).expect("classic APO parameters are valid") + } + + /// Configured `(fast, slow)`. + pub const fn periods(&self) -> (usize, usize) { + (self.fast_period, self.slow_period) + } +} + +impl Indicator for Apo { + type Input = f64; + type Output = f64; + + fn update(&mut self, input: f64) -> Option { + // Feed both EMAs on every input so the slow one warms in parallel. + let f = self.fast.update(input); + let s = self.slow.update(input); + Some(f? - s?) + } + + fn reset(&mut self) { + self.fast.reset(); + self.slow.reset(); + } + + fn warmup_period(&self) -> usize { + // Slow EMA dominates; both EMAs emit at their `period` th input. + self.slow_period + } + + fn is_ready(&self) -> bool { + self.slow.is_ready() + } + + fn name(&self) -> &'static str { + "APO" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + #[test] + fn rejects_zero_period() { + assert!(matches!(Apo::new(0, 26), Err(Error::PeriodZero))); + assert!(matches!(Apo::new(12, 0), Err(Error::PeriodZero))); + } + + #[test] + fn rejects_fast_geq_slow() { + assert!(matches!(Apo::new(26, 12), Err(Error::InvalidPeriod { .. }))); + assert!(matches!(Apo::new(12, 12), Err(Error::InvalidPeriod { .. }))); + } + + #[test] + fn accessors_and_metadata() { + let apo = Apo::classic(); + assert_eq!(apo.periods(), (12, 26)); + assert_eq!(apo.warmup_period(), 26); + assert_eq!(apo.name(), "APO"); + } + + #[test] + fn classic_factory() { + assert_eq!(Apo::classic().periods(), (12, 26)); + } + + #[test] + fn constant_series_converges_to_zero() { + // Both EMAs reproduce the constant exactly, so APO is 0. + let mut apo = Apo::new(3, 5).unwrap(); + let out = apo.batch(&[42.0_f64; 30]); + for v in out.iter().skip(4).flatten() { + assert_relative_eq!(*v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn warmup_emits_first_value_at_slow_period() { + let mut apo = Apo::new(2, 4).unwrap(); + assert_eq!(apo.warmup_period(), 4); + for i in 1..=3 { + assert_eq!(apo.update(f64::from(i)), None); + } + assert!(apo.update(4.0).is_some()); + } + + #[test] + fn pure_uptrend_is_positive() { + // Fast EMA leads the slow EMA on an uptrend, so APO > 0. + let mut apo = Apo::classic(); + let prices: Vec = (1..=200).map(f64::from).collect(); + let out = apo.batch(&prices); + let last = out.iter().rev().flatten().next().unwrap(); + assert!(*last > 0.0, "APO on uptrend should be positive: {last}"); + } + + #[test] + fn batch_equals_streaming() { + let prices: Vec = (1..=120) + .map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0) + .collect(); + let mut a = Apo::classic(); + let mut b = Apo::classic(); + assert_eq!( + a.batch(&prices), + prices.iter().map(|p| b.update(*p)).collect::>() + ); + } + + #[test] + fn reset_clears_state() { + let mut apo = Apo::classic(); + apo.batch(&(1..=80).map(f64::from).collect::>()); + assert!(apo.is_ready()); + apo.reset(); + assert!(!apo.is_ready()); + assert_eq!(apo.update(1.0), None); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index c555bd04..5301d91d 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -7,6 +7,7 @@ mod accelerator_oscillator; mod adl; mod adx; +mod apo; mod aroon; mod aroon_oscillator; mod atr; @@ -79,6 +80,7 @@ mod zlema; pub use accelerator_oscillator::AcceleratorOscillator; pub use adl::Adl; pub use adx::{Adx, AdxOutput}; +pub use apo::Apo; pub use aroon::{Aroon, AroonOutput}; pub use aroon_oscillator::AroonOscillator; pub use atr::Atr; diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index 4e5135b8..db4e5518 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -44,7 +44,7 @@ pub mod indicators; pub use error::{Error, Result}; pub use indicators::{ - AcceleratorOscillator, Adl, Adx, AdxOutput, Aroon, AroonOscillator, AroonOutput, Atr, + AcceleratorOscillator, Adl, Adx, AdxOutput, Apo, Aroon, AroonOscillator, AroonOutput, Atr, AtrTrailingStop, AwesomeOscillator, BalanceOfPower, BollingerBands, BollingerBandwidth, BollingerOutput, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex, Cmo, Coppock, diff --git a/fuzz/fuzz_targets/indicator_update.rs b/fuzz/fuzz_targets/indicator_update.rs index 7a4d3113..f74b9df4 100644 --- a/fuzz/fuzz_targets/indicator_update.rs +++ b/fuzz/fuzz_targets/indicator_update.rs @@ -15,8 +15,9 @@ use libfuzzer_sys::fuzz_target; use wickra_core::{ - BatchExt, BollingerBands, Cmo, Coppock, Dema, Dpo, Ema, HistoricalVolatility, Hma, Indicator, - Kama, LinRegAngle, LinRegSlope, LinearRegression, MacdIndicator, Mom, Pmo, Ppo, Roc, Rsi, Sma, + Apo, BatchExt, BollingerBands, 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, ZScore, Zlema, }; @@ -61,6 +62,7 @@ fuzz_target!(|data: Vec| { drive(|| StochRsi::new(14, 14).unwrap(), &data); drive(|| Dpo::new(14).unwrap(), &data); drive(|| Ppo::new(12, 26).unwrap(), &data); + drive(|| Apo::new(12, 26).unwrap(), &data); drive(|| Coppock::new(14, 11, 10).unwrap(), &data); drive(|| StdDev::new(14).unwrap(), &data); drive(|| UlcerIndex::new(14).unwrap(), &data);