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:
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 Transform–based 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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
/// 10–48 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user