Files
wickra/crates/wickra-core/src/indicators/decycler.rs
T
kingchencandGitHub 7a18a26daf feat(family-10): add 16 Ehlers / Cycle (DSP) indicators (#49)
Implements Family 10 (Ehlers / Cycle) end-to-end across Rust core,
Python / Node / WASM bindings, fuzz, tests, benches and docs. This
is an entirely new family covering John Ehlers' digital-signal-
processing school of cycle analytics — a strong differentiator
versus TA-Lib and pandas-ta, which ship only fragments.

Indicators:
- MAMA (Mesa Adaptive MA) — multi-output { mama, fama }
- FAMA (Following Adaptive MA) — scalar wrapper around MAMA's slow line
- Fisher Transform — Gaussian-normalising price transform
- Inverse Fisher Transform — bounded oscillator (tanh-based)
- SuperSmoother — 2-pole Butterworth lowpass
- Roofing Filter — high-pass + SuperSmoother bandpass
- Decycler — price minus 2-pole high-pass (lag-free trend)
- Decycler Oscillator — fast / slow Decycler difference (MACD-like)
- Hilbert Dominant Cycle — phase-derived period estimator [6, 50]
- Sine Wave Indicator — sin(phase) with 45° lead companion
- Adaptive Cycle Indicator — half-period driver for adaptive oscillators
- Center of Gravity Oscillator — weighted-mass momentum
- Cybernetic Cycle Component — EasyLanguage classic
- Empirical Mode Decomposition — bandpass + envelope mean
- Ehlers Stochastic — Stochastic on Roofing Filter input, [-1, +1]
- Instantaneous Trendline — Ehlers 2-pole lag-free trend

Indicator count rises 71 -> 87 across nine families (was eight).

All sixteen pass batch == streaming equivalence, expose the standard
Indicator surface (update / batch / reset / is_ready / warmup_period
/ name), are fuzz-tested, benchmarked against the checked-in BTCUSDT
1-minute dataset and reach across all four bindings.

Wiki deep-dive drafts for every indicator + Sidebar / Overview /
Home / Warmup updates are staged under indicator-ideas/families/
wiki/family-10-ehlers-cycle/ in the main repo (ghost-ignored) for
the maintainer to publish to the wiki repo manually.
2026-05-25 22:14:27 +02:00

214 lines
6.0 KiB
Rust

//! Ehlers Decycler (single-pole high-pass complement).
use std::f64::consts::PI;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Ehlers' Decycler: price minus the dominant cycle component.
///
/// Implemented as `decycler = input - HP(input)`, where `HP` is a 2-pole
/// high-pass filter with critical period `period`. Subtracting the high-pass
/// from the raw price leaves the slow component — equivalent to a smoothed
/// trend line with no group delay at low frequencies. From *Cycle Analytics
/// for Traders* (Ehlers 2013, ch. 4).
///
/// The high-pass uses the standard 2-pole formulation:
///
/// ```text
/// alpha = (cos(.707*2*pi/period) + sin(.707*2*pi/period) - 1) / cos(.707*2*pi/period)
/// HP[t] = (1 - alpha/2)^2 * (x[t] - 2*x[t-1] + x[t-2])
/// + 2*(1 - alpha) * HP[t-1]
/// - (1 - alpha)^2 * HP[t-2]
/// ```
///
/// The first two outputs simply equal the input (warmup buffering), which is
/// the conventional Ehlers initialisation and keeps downstream consumers
/// reactive while the recursion fills.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Decycler};
///
/// let mut dc = Decycler::new(20).unwrap();
/// let mut last = None;
/// for i in 0..50 {
/// last = dc.update(100.0 + f64::from(i) * 0.5);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Decycler {
period: usize,
alpha: f64,
prev_in_1: Option<f64>,
prev_in_2: Option<f64>,
prev_hp_1: f64,
prev_hp_2: f64,
last_value: Option<f64>,
}
impl Decycler {
/// Construct a Decycler with the given critical period for the high-pass filter.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
let arg = 0.707 * 2.0 * PI / period as f64;
let c = arg.cos();
let alpha = (c + arg.sin() - 1.0) / c;
Ok(Self {
period,
alpha,
prev_in_1: None,
prev_in_2: None,
prev_hp_1: 0.0,
prev_hp_2: 0.0,
last_value: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// High-pass `alpha` coefficient derived from the period.
pub const fn alpha(&self) -> f64 {
self.alpha
}
/// Current decycler value if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
/// Compute and store the high-pass output for the latest input.
fn step_hp(&mut self, input: f64) -> f64 {
let (Some(x1), Some(x2)) = (self.prev_in_1, self.prev_in_2) else {
self.prev_hp_2 = self.prev_hp_1;
self.prev_hp_1 = 0.0;
return 0.0;
};
let one_minus_half_alpha = 1.0 - self.alpha / 2.0;
let one_minus_alpha = 1.0 - self.alpha;
let drv = one_minus_half_alpha * one_minus_half_alpha;
let term1 = drv * (input - 2.0 * x1 + x2);
let term2 = 2.0 * one_minus_alpha * self.prev_hp_1;
let term3 = one_minus_alpha * one_minus_alpha * self.prev_hp_2;
let hp = term1 + term2 - term3;
self.prev_hp_2 = self.prev_hp_1;
self.prev_hp_1 = hp;
hp
}
}
impl Indicator for Decycler {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
let hp = self.step_hp(input);
let v = input - hp;
self.prev_in_2 = self.prev_in_1;
self.prev_in_1 = Some(input);
self.last_value = Some(v);
Some(v)
}
fn reset(&mut self) {
self.prev_in_1 = None;
self.prev_in_2 = None;
self.prev_hp_1 = 0.0;
self.prev_hp_2 = 0.0;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"Decycler"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_zero_period() {
assert!(matches!(Decycler::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let mut dc = Decycler::new(20).unwrap();
assert_eq!(dc.period(), 20);
assert_eq!(dc.warmup_period(), 1);
assert_eq!(dc.name(), "Decycler");
assert!(dc.alpha() > 0.0 && dc.alpha() < 1.0);
assert!(!dc.is_ready());
dc.update(100.0);
assert!(dc.is_ready());
assert!(dc.value().is_some());
}
#[test]
fn constant_series_passes_through() {
// For a flat input, the high-pass output is zero, so the decycler
// equals the input.
let mut dc = Decycler::new(20).unwrap();
let out = dc.batch(&[42.0_f64; 80]);
for x in out.iter().flatten() {
assert_relative_eq!(*x, 42.0, epsilon = 1e-9);
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..100)
.map(|i| 100.0 + (f64::from(i) * 0.15).sin() * 5.0)
.collect();
let mut a = Decycler::new(20).unwrap();
let mut b = Decycler::new(20).unwrap();
let batch = a.batch(&prices);
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn ignores_non_finite_input() {
let mut dc = Decycler::new(20).unwrap();
dc.batch(&(1..=30).map(f64::from).collect::<Vec<_>>());
let before = dc.value();
assert!(before.is_some());
assert_eq!(dc.update(f64::NAN), before);
assert_eq!(dc.update(f64::INFINITY), before);
}
#[test]
fn reset_clears_state() {
let mut dc = Decycler::new(20).unwrap();
dc.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
assert!(dc.is_ready());
dc.reset();
assert!(!dc.is_ready());
}
}