Files
wickra/crates/wickra-core/src/indicators/apo.rs
T
kingchencandGitHub d9d3ad18aa feat: Family 03 MACD & Price Oscillators — APO / AO-Hist / CFO / Zero-Lag MACD / Elder Impulse / STC (#41)
* 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.

* fix(apo): add PyApo + ApoNode + WasmApo bindings missed from ec269d8

The previous APO commit (ec269d8) only registered APO in the Python
__init__.py / Node index.js / Node index.d.ts / fuzz / tests / docs.
The actual PyApo pyclass, ApoNode napi class, and WasmApo wasm class
edits silently no-op'd because the underlying lib.rs files had been
touched by a branch switch between Read and Edit. The bindings were
therefore advertising APO from the Python module / Node package /
WASM module but not actually exposing it.

Fix: insert PyApo block + add_class call in bindings/python/src/lib.rs,
ApoNode block in bindings/node/src/lib.rs, WasmApo macro line in
bindings/wasm/src/lib.rs. cargo test workspace stays at 615 (no new
tests added; the existing test_known_values + indicators.test.js
references would have failed at import once the bindings rebuilt
without these classes).

* feat(ao-histogram): add Awesome Oscillator Histogram

AO - SMA(AO, sma_period). A configurable variant of the existing
AcceleratorOscillator (which fixes fast=5, slow=34, sma=5).
Three parameters; defaults match Bill Williams' Accelerator.

Touchpoints: awesome_oscillator_histogram.rs + mod.rs + lib.rs
re-export, PyAoHist + __init__.py + test_new_indicators CANDLE_SCALAR
+ test_known_values flat reference, AwesomeOscillatorHistogramNode +
index.d.ts/index.js + indicators.test.js factory + reference,
WasmAoHist, candle-fuzz target, README + CHANGELOG.

* feat(cfo): add Chande Forecast Oscillator

100 * (close - LinReg(close, period)) / close. Positive when close
overshoots the linear forecast, negative when it undershoots. Holds
the previous value if the close is zero (percentage form undefined).
Single param period (default 14).

Touchpoints: cfo.rs + mod.rs + lib.rs re-export, PyCfo + __init__.py
+ test_new_indicators SCALAR + test_known_values linear reference,
CfoNode + index.d.ts/index.js + indicators.test.js factory + reference,
WasmCfo via scalar macro, scalar-fuzz target, README + CHANGELOG.

* fix(cfo): add WasmCfo binding missed from 733afd9

* feat(zero-lag-macd): add Zero-Lag MACD

Classic MACD topology with ZLEMA substituted for EMA everywhere:
faster reaction to trend changes at the cost of slightly noisier
readings. Multi-output ZeroLagMacdOutput { macd, signal, histogram }.
Three parameters (fast = 12, slow = 26, signal = 9); fast must be
strictly less than slow.

Touchpoints: zero_lag_macd.rs + mod.rs + lib.rs re-export, PyZeroLagMacd
+ __init__.py + test_new_indicators MULTI + test_known_values flat
reference, ZeroLagMacdNode + ZeroLagMacdValue + index.d.ts/index.js +
indicators.test.js multi factory + reference, WasmZeroLagMacd, scalar
fuzz with hand-rolled drive (multi-output bypasses the f64-only
helper), README + CHANGELOG.

* feat(elder-impulse): add Alexander Elder Impulse System

Tri-state momentum gauge: +1 (green/buy) when EMA trend and MACD
histogram both rise, -1 (red/sell) when both fall, 0 (blue/neutral)
on disagreement. Four parameters (ema_period, macd_fast, macd_slow,
macd_signal); defaults (13, 12, 26, 9) match Elder.

Internally feeds both branches on every input so they warm in parallel;
needs one bar past the slowest branch to seed direction state.

Touchpoints: elder_impulse.rs + mod.rs + lib.rs re-export, PyElderImpulse
+ __init__.py + test_new_indicators SCALAR + test_known_values neutral
reference, ElderImpulseNode + index.d.ts/index.js + indicators.test.js
factory + reference, WasmElderImpulse via scalar macro, scalar-fuzz
target, README + CHANGELOG.

* feat(stc): add Schaff Trend Cycle

Doug Schaff's doubly-Stochastic-smoothed MACD. Bounded [0, 100]
reading that reacts faster than MACD by extracting the percentile of
MACD within a recent window, half-EMA-smoothing it, and re-stochasing
the smoothed series. Four parameters (fast = 23, slow = 50,
schaff_period = 10, factor = 0.5); fast must be strictly less than
slow and factor must lie in (0, 1].

Output clamped to [0, 100] to absorb floating-point rounding. The
stochastic stages clamp to 0 when their rolling range collapses (flat
input or perfectly monotone trend), so a flat series settles
deterministically at 0 after warmup.

Touchpoints: stc.rs + mod.rs + lib.rs re-export, PyStc + __init__.py
+ test_new_indicators SCALAR + test_known_values flat reference,
StcNode + index.d.ts/index.js + indicators.test.js factory + reference,
WasmStc via scalar macro, scalar-fuzz target, README + CHANGELOG.

* fix(stc): rename last_stc -> last_value to satisfy clippy

* ci: Retry setup-node and setup-python on CDN flakes

Setup-node on Windows runners and setup-python across all OSes
occasionally fail with a silent hang or 5xx mid-download ("Attempting
to download 18..." → fail in <1s) — pure upstream CDN flake. The fix
ran on this branch's previous merge commit (24e723f) had to be
re-triggered manually via `gh run rerun --failed`.

Wrap both setup actions with continue-on-error and a follow-up retry
step that waits 30s and re-runs the same setup. The retry only fires
when the first attempt failed (steps.<id>.outcome == 'failure'), so a
green setup costs nothing extra. The retry uses the identical pinned
SHA so we still get supply-chain verification on both attempts.

Applied to ci.yml (Python matrix and Node matrix). release.yml has
the same setup-node / setup-python steps but is rarely re-run, so
the existing manual rerun pattern stays sufficient for now.

* test(zero-lag-macd): Fix MULTI dict shape mismatch + cover warmup_period

ZeroLagMACD was registered in the Python MULTI dict (which asserts a
(n, 2) batch shape) but actually emits (n, 3) — macd, signal,
histogram — like MACD. Moved out into its own standalone test
test_zero_lag_macd_streaming_matches_batch (3-tuple shape), and
included in the lifecycle sweep. Mirrors the existing Alligator
pattern for 3-output candle indicators.

Also adds a unit test for ZeroLagMacd::warmup_period that pins both
the (12, 26, 9) classic case and a small-period config — these four
lines were the codecov/patch miss on PR 41.
2026-05-25 17:26:46 +02:00

184 lines
4.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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);
}
}