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.
This commit is contained in:
kingchenc
2026-05-24 21:38:48 +02:00
parent e30b3c6b35
commit ec269d8aeb
11 changed files with 217 additions and 6 deletions
+6
View File
@@ -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
+2 -2
View File
@@ -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 |
@@ -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);
});
+2 -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, 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
@@ -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",
@@ -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.
@@ -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,)),
+183
View File
@@ -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<Self> {
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<f64> {
// 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<f64> = (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<f64> = (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::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut apo = Apo::classic();
apo.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
assert!(apo.is_ready());
apo.reset();
assert!(!apo.is_ready());
assert_eq!(apo.update(1.0), None);
}
}
+2
View File
@@ -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;
+1 -1
View File
@@ -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,
+4 -2
View File
@@ -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<f64>| {
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);