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.
This commit is contained in:
kingchenc
2026-05-25 22:14:27 +02:00
committed by GitHub
parent 4f9ed34884
commit 7a18a26daf
34 changed files with 4947 additions and 46 deletions
@@ -0,0 +1,143 @@
//! Ehlers Adaptive Cycle period estimator (for adaptive oscillators).
use crate::indicators::hilbert_dominant_cycle::HilbertDominantCycle;
use crate::traits::Indicator;
/// Ehlers' Adaptive Cycle Indicator.
///
/// Returns half the current dominant cycle period — the "best" lookback for
/// downstream oscillators like an adaptive RSI or adaptive Stochastic, per
/// Ehlers' *Cycle Analytics for Traders* (2013, ch. 11). Halving accounts for
/// the fact that an oscillator over a half-cycle captures the full peak-to-
/// trough swing without aliasing.
///
/// The output is rounded to an integer-valued `f64` and clamped to `[3, 25]`,
/// matching the typical operating range of period-adaptive oscillators.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, AdaptiveCycle};
///
/// let mut ac = AdaptiveCycle::new();
/// let mut last = None;
/// for i in 0..200 {
/// last = ac.update(100.0 + (f64::from(i) * 0.4).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct AdaptiveCycle {
cycle: HilbertDominantCycle,
last_value: Option<f64>,
}
impl AdaptiveCycle {
/// Construct a new adaptive cycle estimator.
pub fn new() -> Self {
Self::default()
}
/// Current adaptive period if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for AdaptiveCycle {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
let period = self.cycle.update(input)?;
let half = (period * 0.5).round().clamp(3.0, 25.0);
self.last_value = Some(half);
Some(half)
}
fn reset(&mut self) {
self.cycle.reset();
self.last_value = None;
}
fn warmup_period(&self) -> usize {
self.cycle.warmup_period()
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"AdaptiveCycle"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn accessors_and_metadata() {
let mut ac = AdaptiveCycle::new();
assert_eq!(ac.warmup_period(), 50);
assert_eq!(ac.name(), "AdaptiveCycle");
assert!(!ac.is_ready());
assert!(ac.value().is_none());
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
.collect();
ac.batch(&prices);
assert!(ac.is_ready());
assert!(ac.value().is_some());
}
#[test]
fn output_within_clamp_band() {
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.5).sin() * 5.0)
.collect();
let mut ac = AdaptiveCycle::new();
for v in ac.batch(&prices).into_iter().flatten() {
assert!((3.0..=25.0).contains(&v), "period {v} out of band");
assert_eq!(v, v.round(), "expected integer-valued output");
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
let mut a = AdaptiveCycle::new();
let mut b = AdaptiveCycle::new();
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 ac = AdaptiveCycle::new();
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
.collect();
ac.batch(&prices);
let before = ac.value();
assert!(before.is_some());
assert_eq!(ac.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut ac = AdaptiveCycle::new();
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
.collect();
ac.batch(&prices);
assert!(ac.is_ready());
ac.reset();
assert!(!ac.is_ready());
}
}
@@ -0,0 +1,193 @@
//! Ehlers Center of Gravity Oscillator.
#![allow(clippy::manual_midpoint)]
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Ehlers' Center of Gravity (CG) oscillator.
///
/// Treats the most recent `period` prices as masses and reports the
/// weighted "center" of that mass distribution, negated so positive readings
/// correspond to recent strength:
///
/// ```text
/// num = sum_{k=0..period-1} (1 + k) * price[t - k]
/// den = sum_{k=0..period-1} price[t - k]
/// cg = - num / den + (period + 1) / 2
/// ```
///
/// The constant offset centres the oscillator around zero. From Ehlers,
/// *Cybernetic Analysis for Stocks and Futures* (2004, ch. 7).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, CenterOfGravity};
///
/// let mut cg = CenterOfGravity::new(10).unwrap();
/// let mut last = None;
/// for i in 0..30 {
/// last = cg.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct CenterOfGravity {
period: usize,
window: VecDeque<f64>,
last_value: Option<f64>,
}
impl CenterOfGravity {
/// Construct with the rolling window length.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
last_value: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for CenterOfGravity {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
// Most recent has weight 1; oldest has weight `period`.
let mut num = 0.0;
let mut den = 0.0;
for (k, p) in self.window.iter().rev().enumerate() {
let w = 1.0 + k as f64;
num += w * p;
den += p;
}
let v = if den.abs() > f64::EPSILON {
-num / den + (self.period as f64 + 1.0) / 2.0
} else {
0.0
};
self.last_value = Some(v);
Some(v)
}
fn reset(&mut self) {
self.window.clear();
self.last_value = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"CenterOfGravity"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_zero_period() {
assert!(matches!(CenterOfGravity::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let mut cg = CenterOfGravity::new(10).unwrap();
assert_eq!(cg.period(), 10);
assert_eq!(cg.warmup_period(), 10);
assert_eq!(cg.name(), "CenterOfGravity");
assert!(!cg.is_ready());
for i in 1..=10 {
cg.update(f64::from(i));
}
assert!(cg.is_ready());
assert!(cg.value().is_some());
}
#[test]
fn constant_series_yields_zero() {
// num = sum k * p, den = period * p, ratio = (period + 1) / 2,
// so cg = - (period+1)/2 + (period+1)/2 = 0.
let mut cg = CenterOfGravity::new(5).unwrap();
let out = cg.batch(&[7.0_f64; 30]);
for x in out.iter().skip(5).flatten() {
assert_relative_eq!(*x, 0.0, epsilon = 1e-12);
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=50).map(f64::from).collect();
let mut a = CenterOfGravity::new(10).unwrap();
let mut b = CenterOfGravity::new(10).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 cg = CenterOfGravity::new(5).unwrap();
cg.batch(&(1..=10).map(f64::from).collect::<Vec<_>>());
let before = cg.value();
assert!(before.is_some());
assert_eq!(cg.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut cg = CenterOfGravity::new(5).unwrap();
cg.batch(&(1..=10).map(f64::from).collect::<Vec<_>>());
assert!(cg.is_ready());
cg.reset();
assert!(!cg.is_ready());
}
#[test]
fn warmup_returns_none_until_seed() {
let mut cg = CenterOfGravity::new(4).unwrap();
assert_eq!(cg.update(1.0), None);
assert_eq!(cg.update(2.0), None);
assert_eq!(cg.update(3.0), None);
assert!(cg.update(4.0).is_some());
}
}
@@ -0,0 +1,240 @@
//! Ehlers Cybernetic Cycle Component.
#![allow(clippy::doc_markdown)]
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Ehlers' Cybernetic Cycle Component (CCC).
///
/// Classic EasyLanguage construct from *Cybernetic Analysis for Stocks and
/// Futures* (Ehlers 2004, ch. 4):
///
/// ```text
/// smooth[t] = (x[t] + 2*x[t-1] + 2*x[t-2] + x[t-3]) / 6
/// cycle[t] = (1 - alpha/2)^2 * (smooth[t] - 2*smooth[t-1] + smooth[t-2])
/// + 2 * (1 - alpha) * cycle[t-1]
/// - (1 - alpha)^2 * cycle[t-2]
/// ```
///
/// The result is a near-zero-mean oscillator that tracks the dominant cycle
/// component while filtering trend. `alpha` is a smoothing fraction in
/// `(0, 1]`; Ehlers recommends `2 / (period + 1)` for a given critical period.
///
/// The first six outputs follow Ehlers' "use the input directly" initial
/// condition so downstream consumers stay reactive.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, CyberneticCycle};
///
/// let mut cc = CyberneticCycle::new(10).unwrap();
/// let mut last = None;
/// for i in 0..30 {
/// last = cc.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct CyberneticCycle {
period: usize,
alpha: f64,
in_buf: [Option<f64>; 4],
smooth_buf: [Option<f64>; 3],
cycle_buf: [Option<f64>; 3],
count: usize,
last_value: Option<f64>,
}
impl CyberneticCycle {
/// Construct with the dominant-cycle period (alpha = 2 / (period + 1)).
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
let alpha = 2.0 / (period as f64 + 1.0);
Ok(Self {
period,
alpha,
in_buf: [None; 4],
smooth_buf: [None; 3],
cycle_buf: [None; 3],
count: 0,
last_value: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Smoothing alpha.
pub const fn alpha(&self) -> f64 {
self.alpha
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
/// Shift in `x` at position 0 of a 3-slot buffer.
fn push3(buf: &mut [Option<f64>; 3], x: f64) {
buf[2] = buf[1];
buf[1] = buf[0];
buf[0] = Some(x);
}
fn push4(buf: &mut [Option<f64>; 4], x: f64) {
buf[3] = buf[2];
buf[2] = buf[1];
buf[1] = buf[0];
buf[0] = Some(x);
}
}
impl Indicator for CyberneticCycle {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
self.count += 1;
Self::push4(&mut self.in_buf, input);
// Smooth needs four prior inputs (positions 0..=3).
let smooth = if let (Some(a), Some(b), Some(c), Some(d)) = (
self.in_buf[0],
self.in_buf[1],
self.in_buf[2],
self.in_buf[3],
) {
(a + 2.0 * b + 2.0 * c + d) / 6.0
} else {
// Initial condition: use the raw input.
input
};
Self::push3(&mut self.smooth_buf, smooth);
// Cycle needs two prior smooths and two prior cycles.
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 cycle = if let (Some(s0), Some(s1), Some(s2), Some(c1), Some(c2)) = (
self.smooth_buf[0],
self.smooth_buf[1],
self.smooth_buf[2],
self.cycle_buf[0],
self.cycle_buf[1],
) {
drv * (s0 - 2.0 * s1 + s2) + 2.0 * one_minus_alpha * c1
- one_minus_alpha * one_minus_alpha * c2
} else if self.count < 7 {
// Ehlers initial condition: cycle starts as the second-difference
// of the raw input series, scaled by 0.5 (matches the EasyLanguage
// implementation's first-bar fallback).
let (x0, x1, x2) = (
self.in_buf[0].unwrap_or(input),
self.in_buf[1].unwrap_or(input),
self.in_buf[2].unwrap_or(input),
);
(x0 - 2.0 * x1 + x2) / 4.0
} else {
0.0
};
Self::push3(&mut self.cycle_buf, cycle);
self.last_value = Some(cycle);
Some(cycle)
}
fn reset(&mut self) {
self.in_buf = [None; 4];
self.smooth_buf = [None; 3];
self.cycle_buf = [None; 3];
self.count = 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 {
"CyberneticCycle"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_zero_period() {
assert!(matches!(CyberneticCycle::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let mut cc = CyberneticCycle::new(10).unwrap();
assert_eq!(cc.period(), 10);
assert_relative_eq!(cc.alpha(), 2.0 / 11.0, epsilon = 1e-15);
assert_eq!(cc.warmup_period(), 1);
assert_eq!(cc.name(), "CyberneticCycle");
assert!(!cc.is_ready());
cc.update(100.0);
assert!(cc.is_ready());
}
#[test]
fn constant_series_converges_to_zero() {
let mut cc = CyberneticCycle::new(10).unwrap();
let out = cc.batch(&[50.0_f64; 200]);
for x in out.iter().skip(50).flatten() {
assert_relative_eq!(*x, 0.0, epsilon = 1e-9);
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 5.0)
.collect();
let mut a = CyberneticCycle::new(15).unwrap();
let mut b = CyberneticCycle::new(15).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 cc = CyberneticCycle::new(10).unwrap();
cc.batch(&(1..=30).map(f64::from).collect::<Vec<_>>());
let before = cc.value();
assert!(before.is_some());
assert_eq!(cc.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut cc = CyberneticCycle::new(10).unwrap();
cc.batch(&(1..=30).map(f64::from).collect::<Vec<_>>());
assert!(cc.is_ready());
cc.reset();
assert!(!cc.is_ready());
}
}
@@ -0,0 +1,213 @@
//! 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());
}
}
@@ -0,0 +1,179 @@
//! Ehlers Decycler Oscillator (difference of two decyclers).
use crate::error::{Error, Result};
use crate::indicators::decycler::Decycler;
use crate::traits::Indicator;
/// Difference between a fast and a slow [`Decycler`], producing a smoothed
/// oscillator that crosses zero at trend changes.
///
/// Defined as `fast_decycler - slow_decycler` with `fast_period < slow_period`.
/// The construct removes the trend component that both decyclers share, leaving
/// the medium-frequency cycle band — analogous in spirit to MACD but with
/// Ehlers' zero-lag high-pass filters instead of EMAs.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, DecyclerOscillator};
///
/// let mut dco = DecyclerOscillator::new(10, 30).unwrap();
/// let mut last = None;
/// for i in 0..60 {
/// last = dco.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct DecyclerOscillator {
fast: Decycler,
slow: Decycler,
last_value: Option<f64>,
}
impl DecyclerOscillator {
/// Construct with the fast and slow periods.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if either period is zero, and
/// [`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: "fast period must be strictly less than slow period",
});
}
Ok(Self {
fast: Decycler::new(fast)?,
slow: Decycler::new(slow)?,
last_value: None,
})
}
/// Configured `(fast, slow)` periods.
pub fn periods(&self) -> (usize, usize) {
(self.fast.period(), self.slow.period())
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for DecyclerOscillator {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
let (Some(f), Some(s)) = (self.fast.update(input), self.slow.update(input)) else {
return None;
};
let v = f - s;
self.last_value = Some(v);
Some(v)
}
fn reset(&mut self) {
self.fast.reset();
self.slow.reset();
self.last_value = None;
}
fn warmup_period(&self) -> usize {
self.fast.warmup_period().max(self.slow.warmup_period())
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"DecyclerOscillator"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_invalid_periods() {
assert!(matches!(
DecyclerOscillator::new(0, 20),
Err(Error::PeriodZero)
));
assert!(matches!(
DecyclerOscillator::new(10, 0),
Err(Error::PeriodZero)
));
assert!(matches!(
DecyclerOscillator::new(20, 10),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
DecyclerOscillator::new(10, 10),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let mut dco = DecyclerOscillator::new(10, 30).unwrap();
assert_eq!(dco.periods(), (10, 30));
assert_eq!(dco.name(), "DecyclerOscillator");
assert!(dco.warmup_period() >= 1);
assert!(!dco.is_ready());
dco.update(100.0);
assert!(dco.is_ready());
assert!(dco.value().is_some());
}
#[test]
fn constant_series_yields_zero() {
let mut dco = DecyclerOscillator::new(10, 30).unwrap();
let out = dco.batch(&[42.0_f64; 80]);
for x in out.iter().flatten() {
assert_relative_eq!(*x, 0.0, epsilon = 1e-9);
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..100)
.map(|i| 100.0 + (f64::from(i) * 0.2).cos() * 6.0)
.collect();
let mut a = DecyclerOscillator::new(10, 30).unwrap();
let mut b = DecyclerOscillator::new(10, 30).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 dco = DecyclerOscillator::new(10, 30).unwrap();
dco.batch(&(1..=50).map(f64::from).collect::<Vec<_>>());
let before = dco.value();
assert!(before.is_some());
assert_eq!(dco.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut dco = DecyclerOscillator::new(10, 30).unwrap();
dco.batch(&(1..=50).map(f64::from).collect::<Vec<_>>());
assert!(dco.is_ready());
dco.reset();
assert!(!dco.is_ready());
}
}
@@ -0,0 +1,214 @@
//! Ehlers Stochastic — Stochastic computed on a Roofing-Filter pre-filtered input.
#![allow(clippy::doc_markdown)]
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::roofing_filter::RoofingFilter;
use crate::traits::Indicator;
/// Ehlers' Adaptive Stochastic.
///
/// Implements the construction described in *Cycle Analytics for Traders*
/// (Ehlers 2013, ch. 7): the raw price is first passed through a
/// [`RoofingFilter`] (high-pass + SuperSmoother bandpass) to isolate the
/// tradable cycle band, then the classic Stochastic %K formula is applied
/// to the filtered output over `period` bars and finally re-smoothed by a
/// 2-bar SuperSmoother. The result is a ±1-normalised oscillator that
/// reacts to cycles without trending bias from low-frequency drift.
///
/// The output uses Ehlers' `2 * (X - MinX) / (MaxX - MinX) - 1` convention,
/// so the range is `[-1, +1]` rather than the conventional `[0, 100]`.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, EhlersStochastic};
///
/// let mut es = EhlersStochastic::new(20).unwrap();
/// let mut last = None;
/// for i in 0..120 {
/// last = es.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct EhlersStochastic {
period: usize,
roofing: RoofingFilter,
filtered_buf: VecDeque<f64>,
// Tiny 2-tap IIR (Ehlers uses a simple SMA(2) for the final smoothing).
prev_stoch: f64,
has_prev: bool,
last_value: Option<f64>,
}
impl EhlersStochastic {
/// Construct with the rolling window length used by the inner stochastic.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
// Defaults match Ehlers' (10, 48) roofing filter cutoffs.
roofing: RoofingFilter::new(10, 48)?,
filtered_buf: VecDeque::with_capacity(period),
prev_stoch: 0.0,
has_prev: false,
last_value: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for EhlersStochastic {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
let filtered = self.roofing.update(input)?;
if self.filtered_buf.len() == self.period {
self.filtered_buf.pop_front();
}
self.filtered_buf.push_back(filtered);
if self.filtered_buf.len() < self.period {
return None;
}
let max = self
.filtered_buf
.iter()
.copied()
.fold(f64::NEG_INFINITY, f64::max);
let min = self
.filtered_buf
.iter()
.copied()
.fold(f64::INFINITY, f64::min);
let range = max - min;
let raw = if range > 0.0 {
((filtered - min) / range).mul_add(2.0, -1.0)
} else {
0.0
};
// 2-bar SMA smoothing.
let smoothed = if self.has_prev {
0.5 * (raw + self.prev_stoch)
} else {
raw
};
self.prev_stoch = raw;
self.has_prev = true;
self.last_value = Some(smoothed);
Some(smoothed)
}
fn reset(&mut self) {
self.roofing.reset();
self.filtered_buf.clear();
self.prev_stoch = 0.0;
self.has_prev = false;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
self.period + self.roofing.warmup_period()
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"EhlersStochastic"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn new_rejects_zero_period() {
assert!(matches!(EhlersStochastic::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let mut es = EhlersStochastic::new(20).unwrap();
assert_eq!(es.period(), 20);
assert_eq!(es.warmup_period(), 22);
assert_eq!(es.name(), "EhlersStochastic");
assert!(!es.is_ready());
let prices: Vec<f64> = (0..150)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
.collect();
es.batch(&prices);
assert!(es.is_ready());
assert!(es.value().is_some());
}
#[test]
fn output_bounded_in_unit_interval() {
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
let mut es = EhlersStochastic::new(20).unwrap();
for v in es.batch(&prices).into_iter().flatten() {
assert!((-1.0..=1.0).contains(&v), "value out of band: {v}");
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..150)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
let mut a = EhlersStochastic::new(20).unwrap();
let mut b = EhlersStochastic::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 es = EhlersStochastic::new(20).unwrap();
let prices: Vec<f64> = (0..150)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
es.batch(&prices);
let before = es.value();
assert!(before.is_some());
assert_eq!(es.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut es = EhlersStochastic::new(20).unwrap();
let prices: Vec<f64> = (0..150)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
es.batch(&prices);
assert!(es.is_ready());
es.reset();
assert!(!es.is_ready());
}
}
@@ -0,0 +1,266 @@
//! Ehlers Empirical Mode Decomposition (bandpass + envelope).
use std::collections::VecDeque;
use std::f64::consts::PI;
use crate::error::{Error, Result};
use crate::indicators::super_smoother::SuperSmoother;
use crate::traits::Indicator;
/// Ehlers' adaptation of Empirical Mode Decomposition (EMD).
///
/// Implementation per *Cycle Analytics for Traders* (Ehlers 2013, ch. 14).
/// The procedure is:
///
/// 1. Apply a bandpass filter centred on `period` to the price.
/// 2. Detect peaks and valleys of the bandpassed signal over a `fraction`
/// of the period.
/// 3. Average the peaks and valleys separately to form an upper / lower
/// envelope, then return the centred bandpass minus the envelope mean
/// (the "EMD" line).
///
/// The output crosses zero at trend changes and stays near zero in
/// non-trending markets — the classic visual cue Ehlers documents.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, EmpiricalModeDecomposition};
///
/// let mut emd = EmpiricalModeDecomposition::new(20, 0.5).unwrap();
/// let mut last = None;
/// for i in 0..200 {
/// last = emd.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct EmpiricalModeDecomposition {
period: usize,
fraction: f64,
bandpass: f64,
prev_bp_1: f64,
prev_bp_2: f64,
prev_in_1: Option<f64>,
prev_in_2: Option<f64>,
beta: f64,
alpha: f64,
smoother: SuperSmoother,
peak_smoother: SuperSmoother,
valley_smoother: SuperSmoother,
bp_buf: VecDeque<f64>,
bp_history_len: usize,
last_value: Option<f64>,
}
impl EmpiricalModeDecomposition {
/// Construct with the bandpass centre period and the peak-detection
/// window fraction.
///
/// `fraction` is multiplied by `period` to size the rolling peak/valley
/// window; Ehlers recommends `0.5`. Both must be positive.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`, and
/// [`Error::InvalidPeriod`] if `fraction <= 0` or non-finite.
pub fn new(period: usize, fraction: f64) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if !fraction.is_finite() || fraction <= 0.0 || fraction > 1.0 {
return Err(Error::InvalidPeriod {
message: "fraction must be in (0, 1]",
});
}
let beta = (2.0 * PI / period as f64).cos();
let gamma = 1.0 / (2.0 * PI * 0.25 / period as f64).cos();
let alpha = gamma - (gamma * gamma - 1.0).sqrt();
let history = (period as f64 * fraction).round().max(1.0) as usize;
Ok(Self {
period,
fraction,
bandpass: 0.0,
prev_bp_1: 0.0,
prev_bp_2: 0.0,
prev_in_1: None,
prev_in_2: None,
beta,
alpha,
smoother: SuperSmoother::new(period.max(2))?,
peak_smoother: SuperSmoother::new(period.max(2))?,
valley_smoother: SuperSmoother::new(period.max(2))?,
bp_buf: VecDeque::with_capacity(history),
bp_history_len: history,
last_value: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Configured fraction.
pub const fn fraction(&self) -> f64 {
self.fraction
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for EmpiricalModeDecomposition {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
// 2nd-order resonant bandpass per Ehlers ch. 6.
let bp = if let (Some(_x1), Some(x2)) = (self.prev_in_1, self.prev_in_2) {
0.5 * (1.0 - self.alpha) * (input - x2)
+ self.beta * (1.0 + self.alpha) * self.prev_bp_1
- self.alpha * self.prev_bp_2
} else {
0.0
};
self.prev_bp_2 = self.prev_bp_1;
self.prev_bp_1 = bp;
self.bandpass = bp;
self.prev_in_2 = self.prev_in_1;
self.prev_in_1 = Some(input);
if self.bp_buf.len() == self.bp_history_len {
self.bp_buf.pop_front();
}
self.bp_buf.push_back(bp);
if self.bp_buf.len() < self.bp_history_len {
return None;
}
// Identify the current peak (largest), valley (smallest) within the window.
let peak = self
.bp_buf
.iter()
.copied()
.fold(f64::NEG_INFINITY, f64::max);
let valley = self.bp_buf.iter().copied().fold(f64::INFINITY, f64::min);
let avg_peak = self.peak_smoother.update(peak)?;
let avg_valley = self.valley_smoother.update(valley)?;
// The EMD line is the bandpass minus the smoothed mean envelope.
let mean = 0.5 * (avg_peak + avg_valley);
let raw = bp - mean;
let v = self.smoother.update(raw)?;
self.last_value = Some(v);
Some(v)
}
fn reset(&mut self) {
self.bandpass = 0.0;
self.prev_bp_1 = 0.0;
self.prev_bp_2 = 0.0;
self.prev_in_1 = None;
self.prev_in_2 = None;
self.smoother.reset();
self.peak_smoother.reset();
self.valley_smoother.reset();
self.bp_buf.clear();
self.last_value = None;
}
fn warmup_period(&self) -> usize {
self.bp_history_len
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"EmpiricalModeDecomposition"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn new_rejects_invalid_params() {
assert!(matches!(
EmpiricalModeDecomposition::new(0, 0.5),
Err(Error::PeriodZero)
));
assert!(matches!(
EmpiricalModeDecomposition::new(20, 0.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
EmpiricalModeDecomposition::new(20, 1.5),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
EmpiricalModeDecomposition::new(20, f64::NAN),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let mut emd = EmpiricalModeDecomposition::new(20, 0.5).unwrap();
assert_eq!(emd.period(), 20);
assert!((emd.fraction() - 0.5).abs() < 1e-15);
assert_eq!(emd.name(), "EmpiricalModeDecomposition");
assert!(emd.warmup_period() >= 1);
assert!(!emd.is_ready());
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
emd.batch(&prices);
assert!(emd.is_ready());
assert!(emd.value().is_some());
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.2).cos() * 5.0)
.collect();
let mut a = EmpiricalModeDecomposition::new(20, 0.5).unwrap();
let mut b = EmpiricalModeDecomposition::new(20, 0.5).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 emd = EmpiricalModeDecomposition::new(20, 0.5).unwrap();
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
emd.batch(&prices);
let before = emd.value();
assert!(before.is_some());
assert_eq!(emd.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut emd = EmpiricalModeDecomposition::new(20, 0.5).unwrap();
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
emd.batch(&prices);
assert!(emd.is_ready());
emd.reset();
assert!(!emd.is_ready());
}
}
+161
View File
@@ -0,0 +1,161 @@
//! Ehlers Following Adaptive Moving Average (FAMA).
use crate::error::Result;
use crate::indicators::mama::Mama;
use crate::traits::Indicator;
/// Scalar wrapper that exposes only the FAMA line from a [`Mama`] indicator.
///
/// FAMA (Following Adaptive Moving Average) is MAMA's lagging companion in
/// Ehlers' MESA construction. It uses half MAMA's adaptive alpha, so it
/// reacts later than MAMA — MAMA crossing above FAMA marks a trend
/// confirmation, MAMA below FAMA a reversal. See [`Mama`] for the joint
/// `(mama, fama)` output; this wrapper exposes the slow line as a plain
/// scalar indicator so it can be chained directly.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Fama};
///
/// let mut fama = Fama::new(0.5, 0.05).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = fama.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Fama {
inner: Mama,
last_value: Option<f64>,
}
impl Fama {
/// Construct with the same `(fast_limit, slow_limit)` semantics as [`Mama`].
///
/// # Errors
///
/// Forwards [`Mama::new`]'s validation errors.
pub fn new(fast_limit: f64, slow_limit: f64) -> Result<Self> {
Ok(Self {
inner: Mama::new(fast_limit, slow_limit)?,
last_value: None,
})
}
/// Default `(0.5, 0.05)` parameters.
pub fn classic() -> Self {
Self {
inner: Mama::classic(),
last_value: None,
}
}
/// Configured `(fast_limit, slow_limit)`.
pub const fn limits(&self) -> (f64, f64) {
self.inner.limits()
}
/// Current FAMA value if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for Fama {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
let v = self.inner.update(input)?.fama;
self.last_value = Some(v);
Some(v)
}
fn reset(&mut self) {
self.inner.reset();
self.last_value = None;
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"FAMA"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Error;
use crate::traits::BatchExt;
#[test]
fn rejects_invalid_limits() {
assert!(matches!(
Fama::new(0.0, 0.05),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
Fama::new(0.05, 0.5),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let mut fama = Fama::classic();
assert_eq!(fama.limits(), (0.5, 0.05));
assert_eq!(fama.warmup_period(), 33);
assert_eq!(fama.name(), "FAMA");
assert!(!fama.is_ready());
for i in 0..60 {
fama.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
}
assert!(fama.is_ready());
assert!(fama.value().is_some());
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.25).cos() * 5.0)
.collect();
let mut a = Fama::classic();
let mut b = Fama::classic();
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 fama = Fama::classic();
let prices: Vec<f64> = (0..100)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
fama.batch(&prices);
let before = fama.value();
assert!(before.is_some());
assert_eq!(fama.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut fama = Fama::classic();
let prices: Vec<f64> = (0..100)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
fama.batch(&prices);
assert!(fama.is_ready());
fama.reset();
assert!(!fama.is_ready());
}
}
@@ -0,0 +1,199 @@
//! Ehlers Fisher Transform.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Ehlers' Fisher Transform of price.
///
/// Normalises the most recent price to `[-1, +1]` via min/max over a `period`
/// window, smooths the normalised value with a 0.33 / 0.67 IIR step, and
/// applies the Fisher transform `0.5 * ln((1+x)/(1-x))`. The result has a
/// near-Gaussian distribution, so extreme readings stand out cleanly. A
/// secondary signal is produced by lagging the Fisher value by one bar (the
/// classic trigger), making the indicator a two-line crossover system in
/// charts.
///
/// Only the primary Fisher value is exposed here as a scalar; the lagged
/// trigger is one update behind by construction.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, FisherTransform};
///
/// let mut ft = FisherTransform::new(10).unwrap();
/// let mut last = None;
/// for i in 0..30 {
/// last = ft.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct FisherTransform {
period: usize,
window: VecDeque<f64>,
smoothed: f64,
last_fisher: Option<f64>,
}
impl FisherTransform {
/// Construct with the rolling extrema window length.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
smoothed: 0.0,
last_fisher: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Current Fisher value if available.
pub const fn value(&self) -> Option<f64> {
self.last_fisher
}
}
impl Indicator for FisherTransform {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_fisher;
}
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
let max = self
.window
.iter()
.copied()
.fold(f64::NEG_INFINITY, f64::max);
let min = self.window.iter().copied().fold(f64::INFINITY, f64::min);
let range = max - min;
// Normalise to roughly [-1, +1]; centred midpoint when range == 0.
let raw = if range > 0.0 {
((input - min) / range).mul_add(2.0, -1.0)
} else {
0.0
};
// Ehlers IIR: 0.33 * raw + 0.67 * prev_smoothed, then clamp.
self.smoothed = 0.33f64.mul_add(raw, 0.67 * self.smoothed);
// Clamp strictly inside (-1, +1) to keep the log finite.
let clamped = self.smoothed.clamp(-0.999, 0.999);
let fisher = 0.5 * ((1.0 + clamped) / (1.0 - clamped)).ln();
self.last_fisher = Some(fisher);
Some(fisher)
}
fn reset(&mut self) {
self.window.clear();
self.smoothed = 0.0;
self.last_fisher = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.last_fisher.is_some()
}
fn name(&self) -> &'static str {
"FisherTransform"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn new_rejects_zero_period() {
assert!(matches!(FisherTransform::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let mut ft = FisherTransform::new(10).unwrap();
assert_eq!(ft.period(), 10);
assert_eq!(ft.warmup_period(), 10);
assert_eq!(ft.name(), "FisherTransform");
assert!(ft.value().is_none());
for i in 1..=10 {
ft.update(f64::from(i));
}
assert!(ft.value().is_some());
assert!(ft.is_ready());
}
#[test]
fn warmup_returns_none_until_seed() {
let mut ft = FisherTransform::new(5).unwrap();
for i in 1..=4 {
assert_eq!(ft.update(f64::from(i)), None);
}
assert!(ft.update(5.0).is_some());
}
#[test]
fn constant_series_zero_range_yields_zero() {
let mut ft = FisherTransform::new(5).unwrap();
let out = ft.batch(&[42.0_f64; 30]);
for x in out.iter().skip(5).flatten() {
assert!(x.abs() < 1e-6, "expected near-zero, got {x}");
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..60)
.map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 8.0)
.collect();
let mut a = FisherTransform::new(10).unwrap();
let mut b = FisherTransform::new(10).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 ft = FisherTransform::new(5).unwrap();
ft.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
let before = ft.value();
assert!(before.is_some());
assert_eq!(ft.update(f64::NAN), before);
assert_eq!(ft.update(f64::INFINITY), before);
}
#[test]
fn reset_clears_state() {
let mut ft = FisherTransform::new(5).unwrap();
ft.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
assert!(ft.is_ready());
ft.reset();
assert!(!ft.is_ready());
assert_eq!(ft.update(1.0), None);
}
}
@@ -0,0 +1,272 @@
//! Ehlers Hilbert Transform Dominant Cycle period estimator.
#![allow(clippy::manual_clamp)]
use std::f64::consts::PI;
use crate::traits::Indicator;
/// Ehlers' Hilbert Transformbased Dominant Cycle period estimator.
///
/// Decomposes price into in-phase and quadrature components via Ehlers'
/// truncated Hilbert transform, then derives the instantaneous phase. The
/// dominant cycle period is recovered from the phase rate of change and
/// median-smoothed. From *Rocket Science for Traders* (Ehlers 2001, ch. 7),
/// implementation aligned with the formulation used in TA-Lib's `HT_DCPERIOD`.
///
/// The output is clamped to the band `[6, 50]` bars, which Ehlers identifies
/// as the meaningful tradable cycle range. The estimator emits its first
/// value after ~50 inputs as the moving-average chain fills.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, HilbertDominantCycle};
///
/// let mut ht = HilbertDominantCycle::new();
/// let mut last = None;
/// for i in 0..200 {
/// last = ht.update(100.0 + (f64::from(i) * 0.4).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct HilbertDominantCycle {
// Rolling 7-tap smoother input buffer.
smooth_buf: Vec<f64>,
// Detrender / Q1 / I1 ring history (need 6 prior).
detrender_buf: Vec<f64>,
q1_buf: Vec<f64>,
i1_buf: Vec<f64>,
// Smoothed I/Q lines for phase computation.
prev_i2: f64,
prev_q2: f64,
prev_re: f64,
prev_im: f64,
prev_period: f64,
prev_smooth_period: f64,
count: usize,
last_value: Option<f64>,
}
impl HilbertDominantCycle {
/// Construct a new dominant cycle estimator.
pub fn new() -> Self {
Self::default()
}
/// Current period estimate if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for HilbertDominantCycle {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
self.count += 1;
// 4-bar weighted moving average of the input (smoothed price).
// Ehlers: (4*x[0] + 3*x[1] + 2*x[2] + x[3]) / 10.
Self::push_front(&mut self.smooth_buf, input, 7);
if self.smooth_buf.len() < 4 {
return None;
}
let smooth = (4.0 * self.smooth_buf[0]
+ 3.0 * self.smooth_buf[1]
+ 2.0 * self.smooth_buf[2]
+ self.smooth_buf[3])
/ 10.0;
// Adaptive coefficient based on the previous period estimate.
let period = self.prev_period.max(6.0).min(50.0);
let adj = 0.075 * period + 0.54;
// We need the smooth buffer to hold ≥ 7 samples for the Hilbert taps.
if self.smooth_buf.len() < 7 {
return None;
}
// Ehlers' Hilbert transform of `smooth` (using current + 2/4/6 lags).
let s0 = smooth;
let s2 = self.smooth_buf[2];
let s4 = self.smooth_buf[4];
let s6 = self.smooth_buf[6];
let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj;
Self::push_front(&mut self.detrender_buf, detrender, 7);
if self.detrender_buf.len() < 7 {
return None;
}
// In-phase and quadrature components.
let q1 = (0.0962 * self.detrender_buf[0] + 0.5769 * self.detrender_buf[2]
- 0.5769 * self.detrender_buf[4]
- 0.0962 * self.detrender_buf[6])
* adj;
let i1 = self.detrender_buf[3];
Self::push_front(&mut self.q1_buf, q1, 7);
Self::push_front(&mut self.i1_buf, i1, 7);
if self.q1_buf.len() < 7 || self.i1_buf.len() < 7 {
return None;
}
// Advance the phase 90 deg via a second Hilbert pass.
let ji = (0.0962 * self.i1_buf[0] + 0.5769 * self.i1_buf[2]
- 0.5769 * self.i1_buf[4]
- 0.0962 * self.i1_buf[6])
* adj;
let jq = (0.0962 * self.q1_buf[0] + 0.5769 * self.q1_buf[2]
- 0.5769 * self.q1_buf[4]
- 0.0962 * self.q1_buf[6])
* adj;
// Phasor smoothing.
let mut i2 = i1 - jq;
let mut q2 = q1 + ji;
i2 = 0.2 * i2 + 0.8 * self.prev_i2;
q2 = 0.2 * q2 + 0.8 * self.prev_q2;
// Homodyne discriminator.
let mut re = i2 * self.prev_i2 + q2 * self.prev_q2;
let mut im = i2 * self.prev_q2 - q2 * self.prev_i2;
re = 0.2 * re + 0.8 * self.prev_re;
im = 0.2 * im + 0.8 * self.prev_im;
self.prev_i2 = i2;
self.prev_q2 = q2;
self.prev_re = re;
self.prev_im = im;
let mut new_period = if im.abs() > f64::EPSILON && re.abs() > f64::EPSILON {
2.0 * PI / im.atan2(re)
} else {
self.prev_period
};
// Rate-of-change clamp per Ehlers.
new_period = new_period.min(1.5 * self.prev_period);
new_period = new_period.max(0.67 * self.prev_period);
new_period = new_period.clamp(6.0, 50.0);
// EMA smoothing of the period.
self.prev_period = 0.2 * new_period + 0.8 * self.prev_period;
// Second smoothing step (TA-Lib uses 0.33/0.67).
self.prev_smooth_period = 0.33 * self.prev_period + 0.67 * self.prev_smooth_period;
if self.count < 50 {
return None;
}
self.last_value = Some(self.prev_smooth_period);
Some(self.prev_smooth_period)
}
fn reset(&mut self) {
self.smooth_buf.clear();
self.detrender_buf.clear();
self.q1_buf.clear();
self.i1_buf.clear();
self.prev_i2 = 0.0;
self.prev_q2 = 0.0;
self.prev_re = 0.0;
self.prev_im = 0.0;
self.prev_period = 0.0;
self.prev_smooth_period = 0.0;
self.count = 0;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
50
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"HilbertDominantCycle"
}
}
impl HilbertDominantCycle {
/// Push `v` at the front of `buf`, capping the length at `cap`.
fn push_front(buf: &mut Vec<f64>, v: f64, cap: usize) {
buf.insert(0, v);
if buf.len() > cap {
buf.truncate(cap);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn accessors_and_metadata() {
let mut ht = HilbertDominantCycle::new();
assert_eq!(ht.warmup_period(), 50);
assert_eq!(ht.name(), "HilbertDominantCycle");
assert!(!ht.is_ready());
assert!(ht.value().is_none());
for i in 0..120 {
ht.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
}
assert!(ht.is_ready());
assert!(ht.value().is_some());
}
#[test]
fn output_within_clamp_band() {
let mut ht = HilbertDominantCycle::new();
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
.collect();
let out = ht.batch(&prices);
for v in out.iter().flatten() {
assert!((6.0..=50.0).contains(v), "period {v} outside [6, 50]");
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
let mut a = HilbertDominantCycle::new();
let mut b = HilbertDominantCycle::new();
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 ht = HilbertDominantCycle::new();
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
.collect();
ht.batch(&prices);
let before = ht.value();
assert!(before.is_some());
assert_eq!(ht.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut ht = HilbertDominantCycle::new();
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
.collect();
ht.batch(&prices);
assert!(ht.is_ready());
ht.reset();
assert!(!ht.is_ready());
assert!(ht.value().is_none());
}
}
@@ -0,0 +1,213 @@
//! Ehlers Instantaneous Trendline (ITrend).
#![allow(clippy::doc_markdown)]
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Ehlers' Instantaneous Trendline (ITrend).
///
/// A 2-pole IIR that approximates a lag-free trend line:
///
/// ```text
/// itrend[t] = (alpha - alpha^2/4) * x[t]
/// + 0.5 * alpha^2 * x[t-1]
/// - (alpha - 0.75*alpha^2) * x[t-2]
/// + 2*(1 - alpha) * itrend[t-1]
/// - (1 - alpha)^2 * itrend[t-2]
/// ```
///
/// where `alpha = 2 / (period + 1)`. From *Cybernetic Analysis for Stocks
/// and Futures* (Ehlers 2004, ch. 8). During the first six bars the output
/// uses the EasyLanguage initial condition `(x[t] + 2*x[t-1] + x[t-2]) / 4`.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, InstantaneousTrendline};
///
/// let mut it = InstantaneousTrendline::new(20).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = it.update(100.0 + f64::from(i) * 0.5);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct InstantaneousTrendline {
period: usize,
alpha: f64,
in_buf: [Option<f64>; 3],
out_buf: [Option<f64>; 2],
count: usize,
last_value: Option<f64>,
}
impl InstantaneousTrendline {
/// Construct with the dominant-cycle period.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
let alpha = 2.0 / (period as f64 + 1.0);
Ok(Self {
period,
alpha,
in_buf: [None; 3],
out_buf: [None; 2],
count: 0,
last_value: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Smoothing alpha.
pub const fn alpha(&self) -> f64 {
self.alpha
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for InstantaneousTrendline {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
self.count += 1;
// Shift input buffer (position 0 = most recent).
self.in_buf[2] = self.in_buf[1];
self.in_buf[1] = self.in_buf[0];
self.in_buf[0] = Some(input);
let alpha = self.alpha;
let v = if self.count >= 7 {
// Full recursive formula.
let (x0, x1, x2) = (
self.in_buf[0].expect("filled"),
self.in_buf[1].expect("filled"),
self.in_buf[2].expect("filled"),
);
let (y1, y2) = (
self.out_buf[0].expect("filled"),
self.out_buf[1].expect("filled"),
);
(alpha - alpha * alpha / 4.0) * x0 + 0.5 * alpha * alpha * x1
- (alpha - 0.75 * alpha * alpha) * x2
+ 2.0 * (1.0 - alpha) * y1
- (1.0 - alpha) * (1.0 - alpha) * y2
} else {
// Initial condition: 4-point weighted average of the most recent
// inputs (Ehlers EasyLanguage default).
let x0 = self.in_buf[0].expect("just pushed");
let x1 = self.in_buf[1].unwrap_or(x0);
let x2 = self.in_buf[2].unwrap_or(x0);
(x0 + 2.0 * x1 + x2) / 4.0
};
self.out_buf[1] = self.out_buf[0];
self.out_buf[0] = Some(v);
self.last_value = Some(v);
Some(v)
}
fn reset(&mut self) {
self.in_buf = [None; 3];
self.out_buf = [None; 2];
self.count = 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 {
"InstantaneousTrendline"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_zero_period() {
assert!(matches!(
InstantaneousTrendline::new(0),
Err(Error::PeriodZero)
));
}
#[test]
fn accessors_and_metadata() {
let mut it = InstantaneousTrendline::new(20).unwrap();
assert_eq!(it.period(), 20);
assert_relative_eq!(it.alpha(), 2.0 / 21.0, epsilon = 1e-15);
assert_eq!(it.warmup_period(), 1);
assert_eq!(it.name(), "InstantaneousTrendline");
assert!(!it.is_ready());
it.update(100.0);
assert!(it.is_ready());
}
#[test]
fn constant_series_passes_through() {
// Coefficients sum to 1, so a flat input stays flat after warmup.
let mut it = InstantaneousTrendline::new(20).unwrap();
let out = it.batch(&[42.0_f64; 200]);
for x in out.iter().skip(20).flatten() {
assert_relative_eq!(*x, 42.0, epsilon = 1e-6);
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.2).cos() * 5.0)
.collect();
let mut a = InstantaneousTrendline::new(15).unwrap();
let mut b = InstantaneousTrendline::new(15).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 it = InstantaneousTrendline::new(20).unwrap();
it.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
let before = it.value();
assert!(before.is_some());
assert_eq!(it.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut it = InstantaneousTrendline::new(20).unwrap();
it.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
assert!(it.is_ready());
it.reset();
assert!(!it.is_ready());
}
}
@@ -0,0 +1,164 @@
//! Inverse Fisher Transform (Ehlers).
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Inverse Fisher Transform of a scaled scalar input.
///
/// Compresses the input through `(e^{2x} - 1) / (e^{2x} + 1) = tanh(x)`, the
/// algebraic inverse of the Fisher transform. The output is bounded in
/// `[-1, +1]` (saturating to exactly `±1` for `|scale * input| >= ~19.06`
/// under IEEE 754 doubles), which makes overbought/oversold thresholds at, say, `±0.5`
/// universal across markets and timeframes — the classic use described by
/// Ehlers in *Cybernetic Analysis for Stocks and Futures* (2004).
///
/// The constructor takes a `scale` multiplier so callers can feed raw
/// oscillator readings (e.g. RSI in `[0, 100]`, mapped to `[-5, +5]` with
/// `scale = 0.1` after a `-50` shift) without writing their own scaler.
/// Internally the indicator just computes `tanh(scale * input)`.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, InverseFisherTransform};
///
/// let mut ift = InverseFisherTransform::new(1.0).unwrap();
/// // Large positive input saturates to +1, large negative to -1.
/// assert!(ift.update(10.0).unwrap() > 0.999);
/// assert!(ift.update(-10.0).unwrap() < -0.999);
/// ```
#[derive(Debug, Clone)]
pub struct InverseFisherTransform {
scale: f64,
last_value: Option<f64>,
}
impl InverseFisherTransform {
/// Construct with a multiplicative scale applied before the tanh squash.
///
/// # Errors
///
/// Returns [`Error::InvalidPeriod`] if `scale` is not finite or non-positive.
pub fn new(scale: f64) -> Result<Self> {
if !scale.is_finite() || scale <= 0.0 {
return Err(Error::InvalidPeriod {
message: "scale must be a positive finite number",
});
}
Ok(Self {
scale,
last_value: None,
})
}
/// Configured scale.
pub const fn scale(&self) -> f64 {
self.scale
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for InverseFisherTransform {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
let scaled = self.scale * input;
// tanh is numerically safe for any finite input.
let v = scaled.tanh();
self.last_value = Some(v);
Some(v)
}
fn reset(&mut self) {
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 {
"InverseFisherTransform"
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
#[test]
fn new_rejects_non_positive_scale() {
assert!(matches!(
InverseFisherTransform::new(0.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
InverseFisherTransform::new(-1.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
InverseFisherTransform::new(f64::NAN),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let mut ift = InverseFisherTransform::new(0.5).unwrap();
assert_relative_eq!(ift.scale(), 0.5, epsilon = 1e-15);
assert_eq!(ift.warmup_period(), 1);
assert_eq!(ift.name(), "InverseFisherTransform");
assert!(!ift.is_ready());
assert!(ift.update(1.0).is_some());
assert!(ift.is_ready());
assert!(ift.value().is_some());
}
#[test]
fn zero_input_yields_zero() {
let mut ift = InverseFisherTransform::new(1.0).unwrap();
assert_relative_eq!(ift.update(0.0).unwrap(), 0.0, epsilon = 1e-15);
}
#[test]
fn output_bounded_in_closed_unit_interval() {
// tanh saturates to exactly ±1.0 in IEEE 754 once |x| >= ~19.06, so the
// output is in the closed interval [-1, +1] rather than strictly open.
let mut ift = InverseFisherTransform::new(1.0).unwrap();
for i in -100..=100 {
let v = ift.update(f64::from(i)).unwrap();
assert!((-1.0..=1.0).contains(&v), "v={v}");
}
}
#[test]
fn reset_clears_state() {
let mut ift = InverseFisherTransform::new(1.0).unwrap();
ift.update(2.0);
assert!(ift.is_ready());
ift.reset();
assert!(!ift.is_ready());
}
#[test]
fn ignores_non_finite_input() {
let mut ift = InverseFisherTransform::new(1.0).unwrap();
ift.update(1.0);
let before = ift.value();
assert_eq!(ift.update(f64::NAN), before);
assert_eq!(ift.update(f64::INFINITY), before);
}
}
+370
View File
@@ -0,0 +1,370 @@
//! Ehlers MESA Adaptive Moving Average (MAMA) and its follower (FAMA).
#![allow(
clippy::doc_markdown,
clippy::doc_lazy_continuation,
clippy::struct_field_names,
clippy::manual_clamp
)]
use std::f64::consts::PI;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// MAMA + FAMA output pair.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MamaOutput {
/// MESA Adaptive Moving Average.
pub mama: f64,
/// Following Adaptive Moving Average (slower companion).
pub fama: f64,
}
/// Ehlers' MESA Adaptive Moving Average (MAMA).
///
/// MAMA adapts its smoothing constant from the rate-of-change of price phase,
/// derived via a truncated Hilbert transform — full math in "Cycle Analytics
/// for Traders" (Ehlers 2013, ch. 8) and the original 2001 MESA paper.
///
/// The two-parameter `(fast_limit, slow_limit)` is the range over which the
/// adaptive alpha can vary; defaults `(0.5, 0.05)` match the canonical
/// EasyLanguage implementation. The companion FAMA is `mama * 0.5 * fast_limit
/// + fama_prev * (1 - 0.5 * fast_limit)`, lagging MAMA so crossovers signal
/// trend reversals.
///
/// The indicator emits both lines as a [`MamaOutput`]. Use the [`Fama`] wrapper
/// in this module to expose just the slow line if needed (e.g. for chaining).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Mama};
///
/// let mut mama = Mama::new(0.5, 0.05).unwrap();
/// let mut last = None;
/// for i in 0..100 {
/// last = mama.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Mama {
fast_limit: f64,
slow_limit: f64,
smooth_buf: Vec<f64>,
detrender_buf: Vec<f64>,
q1_buf: Vec<f64>,
i1_buf: Vec<f64>,
prev_i2: f64,
prev_q2: f64,
prev_re: f64,
prev_im: f64,
prev_period: f64,
prev_phase: f64,
prev_mama: f64,
prev_fama: f64,
count: usize,
last_value: Option<MamaOutput>,
}
impl Mama {
/// Construct with custom `(fast_limit, slow_limit)` adaptive alpha bounds.
///
/// # Errors
///
/// Returns [`Error::InvalidPeriod`] if either limit is outside `(0, 1]`
/// or if `slow_limit > fast_limit`.
pub fn new(fast_limit: f64, slow_limit: f64) -> Result<Self> {
if !fast_limit.is_finite()
|| !slow_limit.is_finite()
|| fast_limit <= 0.0
|| fast_limit > 1.0
|| slow_limit <= 0.0
|| slow_limit > 1.0
|| slow_limit > fast_limit
{
return Err(Error::InvalidPeriod {
message: "fast_limit, slow_limit must satisfy 0 < slow_limit <= fast_limit <= 1",
});
}
Ok(Self {
fast_limit,
slow_limit,
smooth_buf: Vec::with_capacity(7),
detrender_buf: Vec::with_capacity(7),
q1_buf: Vec::with_capacity(7),
i1_buf: Vec::with_capacity(7),
prev_i2: 0.0,
prev_q2: 0.0,
prev_re: 0.0,
prev_im: 0.0,
prev_period: 0.0,
prev_phase: 0.0,
prev_mama: 0.0,
prev_fama: 0.0,
count: 0,
last_value: None,
})
}
/// Default `(0.5, 0.05)` parameters from Ehlers' original publication.
pub fn classic() -> Self {
Self::new(0.5, 0.05).expect("classic MAMA limits are valid")
}
/// Configured `(fast_limit, slow_limit)`.
pub const fn limits(&self) -> (f64, f64) {
(self.fast_limit, self.slow_limit)
}
/// Current `(mama, fama)` pair if available.
pub const fn value(&self) -> Option<MamaOutput> {
self.last_value
}
fn push_front(buf: &mut Vec<f64>, v: f64, cap: usize) {
buf.insert(0, v);
if buf.len() > cap {
buf.truncate(cap);
}
}
}
impl Indicator for Mama {
type Input = f64;
type Output = MamaOutput;
fn update(&mut self, input: f64) -> Option<MamaOutput> {
if !input.is_finite() {
return self.last_value;
}
self.count += 1;
Self::push_front(&mut self.smooth_buf, input, 7);
if self.smooth_buf.len() < 4 {
return None;
}
let smooth = (4.0 * self.smooth_buf[0]
+ 3.0 * self.smooth_buf[1]
+ 2.0 * self.smooth_buf[2]
+ self.smooth_buf[3])
/ 10.0;
let period = self.prev_period.max(6.0).min(50.0);
let adj = 0.075 * period + 0.54;
if self.smooth_buf.len() < 7 {
// Seed the EMA outputs with the smoothed price so early bars are
// well-behaved without producing a public value.
self.prev_mama = smooth;
self.prev_fama = smooth;
return None;
}
let s0 = smooth;
let s2 = self.smooth_buf[2];
let s4 = self.smooth_buf[4];
let s6 = self.smooth_buf[6];
let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj;
Self::push_front(&mut self.detrender_buf, detrender, 7);
if self.detrender_buf.len() < 7 {
return None;
}
let q1 = (0.0962 * self.detrender_buf[0] + 0.5769 * self.detrender_buf[2]
- 0.5769 * self.detrender_buf[4]
- 0.0962 * self.detrender_buf[6])
* adj;
let i1 = self.detrender_buf[3];
Self::push_front(&mut self.q1_buf, q1, 7);
Self::push_front(&mut self.i1_buf, i1, 7);
if self.q1_buf.len() < 7 || self.i1_buf.len() < 7 {
return None;
}
let ji = (0.0962 * self.i1_buf[0] + 0.5769 * self.i1_buf[2]
- 0.5769 * self.i1_buf[4]
- 0.0962 * self.i1_buf[6])
* adj;
let jq = (0.0962 * self.q1_buf[0] + 0.5769 * self.q1_buf[2]
- 0.5769 * self.q1_buf[4]
- 0.0962 * self.q1_buf[6])
* adj;
let mut i2 = i1 - jq;
let mut q2 = q1 + ji;
i2 = 0.2 * i2 + 0.8 * self.prev_i2;
q2 = 0.2 * q2 + 0.8 * self.prev_q2;
let mut re = i2 * self.prev_i2 + q2 * self.prev_q2;
let mut im = i2 * self.prev_q2 - q2 * self.prev_i2;
re = 0.2 * re + 0.8 * self.prev_re;
im = 0.2 * im + 0.8 * self.prev_im;
self.prev_i2 = i2;
self.prev_q2 = q2;
self.prev_re = re;
self.prev_im = im;
let mut new_period = if im.abs() > f64::EPSILON && re.abs() > f64::EPSILON {
2.0 * PI / im.atan2(re)
} else {
self.prev_period
};
new_period = new_period.min(1.5 * self.prev_period);
new_period = new_period.max(0.67 * self.prev_period);
new_period = new_period.clamp(6.0, 50.0);
self.prev_period = 0.2 * new_period + 0.8 * self.prev_period;
// Adaptive alpha derived from phase rate-of-change.
let phase = if i1.abs() > f64::EPSILON {
(q1 / i1).atan().to_degrees()
} else {
self.prev_phase
};
let mut delta_phase = self.prev_phase - phase;
self.prev_phase = phase;
if delta_phase < 1.0 {
delta_phase = 1.0;
}
let mut alpha = self.fast_limit / delta_phase;
if alpha < self.slow_limit {
alpha = self.slow_limit;
}
if alpha > self.fast_limit {
alpha = self.fast_limit;
}
self.prev_mama = alpha * input + (1.0 - alpha) * self.prev_mama;
let fama_alpha = 0.5 * alpha;
self.prev_fama = fama_alpha * self.prev_mama + (1.0 - fama_alpha) * self.prev_fama;
if self.count < 33 {
return None;
}
let out = MamaOutput {
mama: self.prev_mama,
fama: self.prev_fama,
};
self.last_value = Some(out);
Some(out)
}
fn reset(&mut self) {
self.smooth_buf.clear();
self.detrender_buf.clear();
self.q1_buf.clear();
self.i1_buf.clear();
self.prev_i2 = 0.0;
self.prev_q2 = 0.0;
self.prev_re = 0.0;
self.prev_im = 0.0;
self.prev_period = 0.0;
self.prev_phase = 0.0;
self.prev_mama = 0.0;
self.prev_fama = 0.0;
self.count = 0;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
33
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"MAMA"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn rejects_invalid_limits() {
assert!(matches!(
Mama::new(0.0, 0.05),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
Mama::new(0.5, 0.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
Mama::new(0.05, 0.5),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
Mama::new(1.5, 0.05),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
Mama::new(f64::NAN, 0.05),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let mut mama = Mama::classic();
assert_eq!(mama.limits(), (0.5, 0.05));
assert_eq!(mama.warmup_period(), 33);
assert_eq!(mama.name(), "MAMA");
assert!(!mama.is_ready());
for i in 0..60 {
mama.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
}
assert!(mama.is_ready());
assert!(mama.value().is_some());
}
#[test]
fn fama_lags_or_equals_mama_on_constant_series() {
let mut mama = Mama::classic();
let out = mama.batch(&[100.0_f64; 200]);
let last = out.iter().flatten().last().unwrap();
// On a flat series both lines converge to the price.
assert!((last.mama - 100.0).abs() < 1.0);
assert!((last.fama - 100.0).abs() < 1.0);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 5.0)
.collect();
let mut a = Mama::classic();
let mut b = Mama::classic();
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 mama = Mama::classic();
let prices: Vec<f64> = (0..100)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
mama.batch(&prices);
let before = mama.value();
assert!(before.is_some());
assert_eq!(mama.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut mama = Mama::classic();
let prices: Vec<f64> = (0..100)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
mama.batch(&prices);
assert!(mama.is_ready());
mama.reset();
assert!(!mama.is_ready());
}
}
+32
View File
@@ -7,6 +7,7 @@
mod acceleration_bands;
mod accelerator_oscillator;
mod ad_oscillator;
mod adaptive_cycle;
mod adl;
mod adx;
mod adxr;
@@ -26,6 +27,7 @@ mod bollinger;
mod bollinger_bandwidth;
mod camarilla_pivots;
mod cci;
mod center_of_gravity;
mod cfo;
mod chaikin_oscillator;
mod chaikin_volatility;
@@ -37,6 +39,9 @@ mod cmf;
mod cmo;
mod connors_rsi;
mod coppock;
mod cybernetic_cycle;
mod decycler;
mod decycler_oscillator;
mod dema;
mod demand_index;
mod demark_pivots;
@@ -45,19 +50,26 @@ mod donchian_stop;
mod double_bollinger;
mod dpo;
mod ease_of_movement;
mod ehlers_stochastic;
mod elder_impulse;
mod ema;
mod empirical_mode_decomposition;
mod evwma;
mod fama;
mod fibonacci_pivots;
mod fisher_transform;
mod force_index;
mod fractal_chaos_bands;
mod frama;
mod garman_klass;
mod hilbert_dominant_cycle;
mod hilo_activator;
mod historical_volatility;
mod hma;
mod hurst_channel;
mod inertia;
mod instantaneous_trendline;
mod inverse_fisher_transform;
mod jma;
mod kama;
mod keltner;
@@ -70,6 +82,7 @@ mod linreg_channel;
mod linreg_slope;
mod ma_envelope;
mod macd;
mod mama;
mod market_facilitation_index;
mod mass_index;
mod mcginley_dynamic;
@@ -90,10 +103,12 @@ mod pvi;
mod renko_trailing_stop;
mod roc;
mod rogers_satchell;
mod roofing_filter;
mod rsi;
mod rvi;
mod rvi_volatility;
mod rwi;
mod sine_wave;
mod sma;
mod smi;
mod smma;
@@ -104,6 +119,7 @@ mod std_dev;
mod step_trailing_stop;
mod stoch_rsi;
mod stochastic;
mod super_smoother;
mod super_trend;
mod t3;
mod td_combo;
@@ -155,6 +171,7 @@ mod zlema;
pub use acceleration_bands::{AccelerationBands, AccelerationBandsOutput};
pub use accelerator_oscillator::AcceleratorOscillator;
pub use ad_oscillator::AdOscillator;
pub use adaptive_cycle::AdaptiveCycle;
pub use adl::Adl;
pub use adx::{Adx, AdxOutput};
pub use adxr::Adxr;
@@ -174,6 +191,7 @@ pub use bollinger::{BollingerBands, BollingerOutput};
pub use bollinger_bandwidth::BollingerBandwidth;
pub use camarilla_pivots::{Camarilla, CamarillaPivotsOutput};
pub use cci::Cci;
pub use center_of_gravity::CenterOfGravity;
pub use cfo::Cfo;
pub use chaikin_oscillator::ChaikinOscillator;
pub use chaikin_volatility::ChaikinVolatility;
@@ -185,6 +203,9 @@ pub use cmf::ChaikinMoneyFlow;
pub use cmo::Cmo;
pub use connors_rsi::ConnorsRsi;
pub use coppock::Coppock;
pub use cybernetic_cycle::CyberneticCycle;
pub use decycler::Decycler;
pub use decycler_oscillator::DecyclerOscillator;
pub use dema::Dema;
pub use demand_index::DemandIndex;
pub use demark_pivots::{DemarkPivots, DemarkPivotsOutput};
@@ -193,19 +214,26 @@ pub use donchian_stop::{DonchianStop, DonchianStopOutput};
pub use double_bollinger::{DoubleBollinger, DoubleBollingerOutput};
pub use dpo::Dpo;
pub use ease_of_movement::EaseOfMovement;
pub use ehlers_stochastic::EhlersStochastic;
pub use elder_impulse::ElderImpulse;
pub use ema::Ema;
pub use empirical_mode_decomposition::EmpiricalModeDecomposition;
pub use evwma::Evwma;
pub use fama::Fama;
pub use fibonacci_pivots::{FibonacciPivots, FibonacciPivotsOutput};
pub use fisher_transform::FisherTransform;
pub use force_index::ForceIndex;
pub use fractal_chaos_bands::{FractalChaosBands, FractalChaosBandsOutput};
pub use frama::Frama;
pub use garman_klass::GarmanKlassVolatility;
pub use hilbert_dominant_cycle::HilbertDominantCycle;
pub use hilo_activator::HiLoActivator;
pub use historical_volatility::HistoricalVolatility;
pub use hma::Hma;
pub use hurst_channel::{HurstChannel, HurstChannelOutput};
pub use inertia::Inertia;
pub use instantaneous_trendline::InstantaneousTrendline;
pub use inverse_fisher_transform::InverseFisherTransform;
pub use jma::Jma;
pub use kama::Kama;
pub use keltner::{Keltner, KeltnerOutput};
@@ -218,6 +246,7 @@ pub use linreg_channel::{LinRegChannel, LinRegChannelOutput};
pub use linreg_slope::LinRegSlope;
pub use ma_envelope::{MaEnvelope, MaEnvelopeOutput};
pub use macd::{MacdIndicator, MacdOutput};
pub use mama::{Mama, MamaOutput};
pub use market_facilitation_index::MarketFacilitationIndex;
pub use mass_index::MassIndex;
pub use mcginley_dynamic::McGinleyDynamic;
@@ -238,10 +267,12 @@ pub use pvi::Pvi;
pub use renko_trailing_stop::RenkoTrailingStop;
pub use roc::Roc;
pub use rogers_satchell::RogersSatchellVolatility;
pub use roofing_filter::RoofingFilter;
pub use rsi::Rsi;
pub use rvi::Rvi;
pub use rvi_volatility::RviVolatility;
pub use rwi::{Rwi, RwiOutput};
pub use sine_wave::SineWave;
pub use sma::Sma;
pub use smi::Smi;
pub use smma::Smma;
@@ -252,6 +283,7 @@ pub use std_dev::StdDev;
pub use step_trailing_stop::StepTrailingStop;
pub use stoch_rsi::StochRsi;
pub use stochastic::{Stochastic, StochasticOutput};
pub use super_smoother::SuperSmoother;
pub use super_trend::{SuperTrend, SuperTrendOutput};
pub use t3::T3;
pub use td_combo::TdCombo;
@@ -0,0 +1,202 @@
//! Ehlers Roofing Filter (high-pass followed by SuperSmoother).
#![allow(clippy::doc_markdown)]
use std::f64::consts::PI;
use crate::error::{Error, Result};
use crate::indicators::super_smoother::SuperSmoother;
use crate::traits::Indicator;
/// Ehlers' Roofing Filter — a bandpass formed by feeding a 2-pole high-pass
/// into a [`SuperSmoother`].
///
/// Defined in *Cycle Analytics for Traders* (Ehlers 2013, ch. 7) as the
/// canonical pre-filter for cycle-aware oscillators: the high-pass strips out
/// the trend (periods longer than `hp_period`), and the SuperSmoother removes
/// noise (periods shorter than `lp_period`). The result is essentially the
/// 1048 bar cycle band by default.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, RoofingFilter};
///
/// let mut rf = RoofingFilter::new(10, 48).unwrap();
/// let mut last = None;
/// for i in 0..120 {
/// last = rf.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct RoofingFilter {
lp_period: usize,
hp_period: usize,
alpha: f64,
prev_in_1: Option<f64>,
prev_hp_1: f64,
prev_hp_2: f64,
smoother: SuperSmoother,
last_value: Option<f64>,
}
impl RoofingFilter {
/// Construct with `lp_period` (SuperSmoother critical period) and
/// `hp_period` (high-pass cutoff). Defaults in Ehlers are `(10, 48)`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if either period is zero, and
/// [`Error::InvalidPeriod`] if `lp_period >= hp_period`.
pub fn new(lp_period: usize, hp_period: usize) -> Result<Self> {
if lp_period == 0 || hp_period == 0 {
return Err(Error::PeriodZero);
}
if lp_period >= hp_period {
return Err(Error::InvalidPeriod {
message: "lp_period must be strictly less than hp_period",
});
}
// Single-pole high-pass alpha from Ehlers ch. 7.
let arg = 2.0 * PI / hp_period as f64;
let alpha = (arg.cos() + arg.sin() - 1.0) / arg.cos();
Ok(Self {
lp_period,
hp_period,
alpha,
prev_in_1: None,
prev_hp_1: 0.0,
prev_hp_2: 0.0,
smoother: SuperSmoother::new(lp_period)?,
last_value: None,
})
}
/// Configured `(lp_period, hp_period)`.
pub const fn periods(&self) -> (usize, usize) {
(self.lp_period, self.hp_period)
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for RoofingFilter {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
let hp = if let Some(x1) = self.prev_in_1 {
let one_minus_half_alpha = 1.0 - self.alpha / 2.0;
one_minus_half_alpha * (input - x1) + (1.0 - self.alpha) * self.prev_hp_1
} else {
0.0
};
self.prev_hp_2 = self.prev_hp_1;
self.prev_hp_1 = hp;
self.prev_in_1 = Some(input);
let v = self.smoother.update(hp)?;
self.last_value = Some(v);
Some(v)
}
fn reset(&mut self) {
self.prev_in_1 = None;
self.prev_hp_1 = 0.0;
self.prev_hp_2 = 0.0;
self.smoother.reset();
self.last_value = None;
}
fn warmup_period(&self) -> usize {
// SuperSmoother is ready after one input; we need two to compute HP.
2
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"RoofingFilter"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_invalid_periods() {
assert!(matches!(RoofingFilter::new(0, 48), Err(Error::PeriodZero)));
assert!(matches!(RoofingFilter::new(10, 0), Err(Error::PeriodZero)));
assert!(matches!(
RoofingFilter::new(48, 10),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
RoofingFilter::new(10, 10),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let mut rf = RoofingFilter::new(10, 48).unwrap();
assert_eq!(rf.periods(), (10, 48));
assert_eq!(rf.warmup_period(), 2);
assert_eq!(rf.name(), "RoofingFilter");
assert!(!rf.is_ready());
rf.update(100.0);
rf.update(101.0);
assert!(rf.is_ready());
assert!(rf.value().is_some());
}
#[test]
fn constant_series_converges_to_zero() {
// High-pass on a flat input is zero, and the smoother of zero is zero.
let mut rf = RoofingFilter::new(10, 48).unwrap();
let out = rf.batch(&[42.0_f64; 300]);
for x in out.iter().skip(100).flatten() {
assert_relative_eq!(*x, 0.0, epsilon = 1e-6);
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.15).sin() * 5.0)
.collect();
let mut a = RoofingFilter::new(10, 48).unwrap();
let mut b = RoofingFilter::new(10, 48).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 rf = RoofingFilter::new(10, 48).unwrap();
rf.batch(&(1..=100).map(f64::from).collect::<Vec<_>>());
let before = rf.value();
assert!(before.is_some());
assert_eq!(rf.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut rf = RoofingFilter::new(10, 48).unwrap();
rf.batch(&(1..=100).map(f64::from).collect::<Vec<_>>());
assert!(rf.is_ready());
rf.reset();
assert!(!rf.is_ready());
}
}
@@ -0,0 +1,218 @@
//! Ehlers Sine Wave indicator.
#![allow(clippy::manual_clamp)]
use std::f64::consts::PI;
use crate::indicators::hilbert_dominant_cycle::HilbertDominantCycle;
use crate::traits::Indicator;
/// Ehlers' Sine Wave indicator (sine + leadsine).
///
/// Implementation from *Rocket Science for Traders* (Ehlers 2001, ch. 9). Uses
/// the same Hilbert-transform machinery as [`HilbertDominantCycle`] to derive
/// the instantaneous phase, then returns `sin(phase)` and the 45° lead
/// `sin(phase + 45°)`. The two lines cross deep in trends but oscillate
/// rapidly during cycles, providing a visual lead/lag signal.
///
/// Only the primary `sine` line is exposed as the scalar output to match the
/// crate's standard scalar-indicator surface; the lead is accessible via the
/// [`SineWave::lead`] accessor after each update.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, SineWave};
///
/// let mut sw = SineWave::new();
/// let mut last = None;
/// for i in 0..200 {
/// last = sw.update(100.0 + (f64::from(i) * 0.4).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct SineWave {
cycle: HilbertDominantCycle,
smooth_buf: Vec<f64>,
detrender_buf: Vec<f64>,
last_phase: f64,
last_sine: Option<f64>,
last_lead: f64,
count: usize,
}
impl SineWave {
/// Construct a new Sine Wave indicator.
pub fn new() -> Self {
Self::default()
}
/// Most recent lead (45°-ahead) value. `0.0` until the indicator is ready.
pub const fn lead(&self) -> f64 {
self.last_lead
}
/// Current sine value if available.
pub const fn value(&self) -> Option<f64> {
self.last_sine
}
fn push_front(buf: &mut Vec<f64>, v: f64, cap: usize) {
buf.insert(0, v);
if buf.len() > cap {
buf.truncate(cap);
}
}
}
impl Indicator for SineWave {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_sine;
}
self.count += 1;
// Drive the dominant-cycle estimator first; its smoothing state is
// independent from ours so the two share input but not buffers.
let _ = self.cycle.update(input);
Self::push_front(&mut self.smooth_buf, input, 7);
if self.smooth_buf.len() < 4 {
return None;
}
let smooth = (4.0 * self.smooth_buf[0]
+ 3.0 * self.smooth_buf[1]
+ 2.0 * self.smooth_buf[2]
+ self.smooth_buf[3])
/ 10.0;
if self.smooth_buf.len() < 7 {
return None;
}
let period = self.cycle.value().unwrap_or(15.0).max(6.0).min(50.0);
let adj = 0.075 * period + 0.54;
let s0 = smooth;
let s2 = self.smooth_buf[2];
let s4 = self.smooth_buf[4];
let s6 = self.smooth_buf[6];
let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj;
Self::push_front(&mut self.detrender_buf, detrender, 7);
if self.detrender_buf.len() < 7 {
return None;
}
let q1 = (0.0962 * self.detrender_buf[0] + 0.5769 * self.detrender_buf[2]
- 0.5769 * self.detrender_buf[4]
- 0.0962 * self.detrender_buf[6])
* adj;
let i1 = self.detrender_buf[3];
let phase = if i1.abs() > f64::EPSILON {
(q1 / i1).atan()
} else {
self.last_phase
};
self.last_phase = phase;
let sine = phase.sin();
let lead = (phase + PI / 4.0).sin();
if self.count < 50 {
return None;
}
self.last_sine = Some(sine);
self.last_lead = lead;
Some(sine)
}
fn reset(&mut self) {
self.cycle.reset();
self.smooth_buf.clear();
self.detrender_buf.clear();
self.last_phase = 0.0;
self.last_sine = None;
self.last_lead = 0.0;
self.count = 0;
}
fn warmup_period(&self) -> usize {
50
}
fn is_ready(&self) -> bool {
self.last_sine.is_some()
}
fn name(&self) -> &'static str {
"SineWave"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn accessors_and_metadata() {
let mut sw = SineWave::new();
assert_eq!(sw.warmup_period(), 50);
assert_eq!(sw.name(), "SineWave");
assert!(!sw.is_ready());
assert!(sw.value().is_none());
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
.collect();
sw.batch(&prices);
assert!(sw.is_ready());
assert!(sw.value().is_some());
}
#[test]
fn output_bounded() {
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.3).cos() * 5.0)
.collect();
let mut sw = SineWave::new();
for v in sw.batch(&prices).into_iter().flatten() {
assert!((-1.0..=1.0).contains(&v), "sine out of bounds: {v}");
}
// Lead value also bounded after warmup.
assert!(sw.lead() >= -1.0 && sw.lead() <= 1.0);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
let mut a = SineWave::new();
let mut b = SineWave::new();
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 sw = SineWave::new();
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
.collect();
sw.batch(&prices);
let before = sw.value();
assert!(before.is_some());
assert_eq!(sw.update(f64::NAN), before);
}
#[test]
fn reset_clears_state() {
let mut sw = SineWave::new();
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 5.0)
.collect();
sw.batch(&prices);
assert!(sw.is_ready());
sw.reset();
assert!(!sw.is_ready());
assert!(sw.value().is_none());
}
}
@@ -0,0 +1,217 @@
//! Ehlers SuperSmoother filter.
#![allow(clippy::doc_markdown)]
use std::f64::consts::PI;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Ehlers' 2-pole Butterworth-style "SuperSmoother" lowpass filter.
///
/// From John Ehlers' *Cycle Analytics for Traders* (2013, ch. 3). For a given
/// critical period `period`, the filter coefficients are:
///
/// ```text
/// a1 = exp(-sqrt(2) * pi / period)
/// b1 = 2 * a1 * cos(sqrt(2) * pi / period)
/// c2 = b1
/// c3 = -a1 * a1
/// c1 = 1 - c2 - c3
/// y[t] = c1 * (x[t] + x[t-1]) / 2 + c2 * y[t-1] + c3 * y[t-2]
/// ```
///
/// The implementation needs two prior inputs and two prior outputs to begin
/// running; until then it returns the input itself (a common Ehlers initial
/// condition), which lets downstream filters warm up without long delays.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, SuperSmoother};
///
/// let mut ss = SuperSmoother::new(10).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = ss.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct SuperSmoother {
period: usize,
c1: f64,
c2: f64,
c3: f64,
prev_input: Option<f64>,
prev_output_1: Option<f64>,
prev_output_2: Option<f64>,
count: usize,
}
impl SuperSmoother {
/// Construct a new SuperSmoother with the given critical period.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
let arg = std::f64::consts::SQRT_2 * PI / period as f64;
let a1 = (-arg).exp();
let b1 = 2.0 * a1 * arg.cos();
let c2 = b1;
let c3 = -a1 * a1;
let c1 = 1.0 - c2 - c3;
Ok(Self {
period,
c1,
c2,
c3,
prev_input: None,
prev_output_1: None,
prev_output_2: None,
count: 0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Filter coefficients `(c1, c2, c3)`.
pub const fn coefficients(&self) -> (f64, f64, f64) {
(self.c1, self.c2, self.c3)
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.prev_output_1
}
}
impl Indicator for SuperSmoother {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.prev_output_1;
}
self.count += 1;
let output = match (self.prev_input, self.prev_output_1, self.prev_output_2) {
(Some(p_in), Some(y1), Some(y2)) => {
let avg = 0.5 * (input + p_in);
self.c1 * avg + self.c2 * y1 + self.c3 * y2
}
_ => input,
};
self.prev_output_2 = self.prev_output_1;
self.prev_output_1 = Some(output);
self.prev_input = Some(input);
Some(output)
}
fn reset(&mut self) {
self.prev_input = None;
self.prev_output_1 = None;
self.prev_output_2 = None;
self.count = 0;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.prev_output_1.is_some()
}
fn name(&self) -> &'static str {
"SuperSmoother"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_zero_period() {
assert!(matches!(SuperSmoother::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let mut ss = SuperSmoother::new(10).unwrap();
assert_eq!(ss.period(), 10);
assert_eq!(ss.name(), "SuperSmoother");
assert_eq!(ss.warmup_period(), 1);
let (c1, c2, c3) = ss.coefficients();
// Coefficients sum to 1 by construction (steady-state gain == 1).
assert_relative_eq!(c1 + c2 + c3, 1.0, epsilon = 1e-12);
assert!(ss.value().is_none());
ss.update(42.0);
assert!(ss.value().is_some());
assert!(ss.is_ready());
}
#[test]
fn first_output_equals_input_then_filters() {
let mut ss = SuperSmoother::new(10).unwrap();
// Initial condition: first two outputs equal their inputs.
assert_eq!(ss.update(100.0), Some(100.0));
assert_eq!(ss.update(101.0), Some(101.0));
let third = ss.update(102.0).unwrap();
// From step 3 onward, the recursive filter activates and the result
// is no longer the raw input.
assert!((third - 102.0).abs() < 5.0);
}
#[test]
fn constant_series_converges_to_constant() {
// Steady-state gain is 1 (c1 + c2 + c3 = 1), so a flat input yields a
// flat output after warmup.
let mut ss = SuperSmoother::new(20).unwrap();
let out = ss.batch(&[50.0_f64; 200]);
for x in out.iter().skip(50).flatten() {
assert_relative_eq!(*x, 50.0, epsilon = 1e-9);
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
.collect();
let mut a = SuperSmoother::new(15).unwrap();
let mut b = SuperSmoother::new(15).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 ss = SuperSmoother::new(10).unwrap();
ss.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
let before = ss.value();
assert!(before.is_some());
assert_eq!(ss.update(f64::NAN), before);
assert_eq!(ss.update(f64::INFINITY), before);
}
#[test]
fn reset_clears_state() {
let mut ss = SuperSmoother::new(10).unwrap();
ss.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
assert!(ss.is_ready());
ss.reset();
assert!(!ss.is_ready());
assert_eq!(ss.update(50.0), Some(50.0));
}
}