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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,)),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user