Skip to main content

wickra_core/indicators/
decycler_oscillator.rs

1//! Ehlers Decycler Oscillator (difference of two decyclers).
2
3use crate::error::{Error, Result};
4use crate::indicators::decycler::Decycler;
5use crate::traits::Indicator;
6
7/// Difference between a fast and a slow [`Decycler`], producing a smoothed
8/// oscillator that crosses zero at trend changes.
9///
10/// Defined as `fast_decycler - slow_decycler` with `fast_period < slow_period`.
11/// The construct removes the trend component that both decyclers share, leaving
12/// the medium-frequency cycle band — analogous in spirit to MACD but with
13/// Ehlers' zero-lag high-pass filters instead of EMAs.
14///
15/// # Example
16///
17/// ```
18/// use wickra_core::{Indicator, DecyclerOscillator};
19///
20/// let mut dco = DecyclerOscillator::new(10, 30).unwrap();
21/// let mut last = None;
22/// for i in 0..60 {
23///     last = dco.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
24/// }
25/// assert!(last.is_some());
26/// ```
27#[derive(Debug, Clone)]
28pub struct DecyclerOscillator {
29    fast: Decycler,
30    slow: Decycler,
31    last_value: Option<f64>,
32}
33
34impl DecyclerOscillator {
35    /// Construct with the fast and slow periods.
36    ///
37    /// # Errors
38    ///
39    /// Returns [`Error::PeriodZero`] if either period is zero, and
40    /// [`Error::InvalidPeriod`] if `fast >= slow`.
41    pub fn new(fast: usize, slow: usize) -> Result<Self> {
42        if fast == 0 || slow == 0 {
43            return Err(Error::PeriodZero);
44        }
45        if fast >= slow {
46            return Err(Error::InvalidPeriod {
47                message: "fast period must be strictly less than slow period",
48            });
49        }
50        Ok(Self {
51            fast: Decycler::new(fast)?,
52            slow: Decycler::new(slow)?,
53            last_value: None,
54        })
55    }
56
57    /// Configured `(fast, slow)` periods.
58    pub fn periods(&self) -> (usize, usize) {
59        (self.fast.period(), self.slow.period())
60    }
61
62    /// Current value if available.
63    pub const fn value(&self) -> Option<f64> {
64        self.last_value
65    }
66}
67
68impl Indicator for DecyclerOscillator {
69    type Input = f64;
70    type Output = f64;
71
72    #[inline]
73    fn update(&mut self, input: f64) -> Option<f64> {
74        if !input.is_finite() {
75            return None;
76        }
77        // Both child `Decycler` instances emit `Some` from the first bar
78        // (Ehlers' convention is "output = input" until the recursion warms),
79        // so the pair is always populated and the `?` short-circuit never
80        // fires in practice.
81        let f = self.fast.update(input)?;
82        let s = self.slow.update(input)?;
83        let v = f - s;
84        self.last_value = Some(v);
85        Some(v)
86    }
87
88    fn reset(&mut self) {
89        self.fast.reset();
90        self.slow.reset();
91        self.last_value = None;
92    }
93
94    #[inline]
95    fn warmup_period(&self) -> usize {
96        self.fast.warmup_period().max(self.slow.warmup_period())
97    }
98
99    #[inline]
100    fn is_ready(&self) -> bool {
101        self.last_value.is_some()
102    }
103
104    #[inline]
105    fn name(&self) -> &'static str {
106        "DecyclerOscillator"
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use crate::traits::BatchExt;
114    use approx::assert_relative_eq;
115
116    #[test]
117    fn new_rejects_invalid_periods() {
118        assert!(matches!(
119            DecyclerOscillator::new(0, 20),
120            Err(Error::PeriodZero)
121        ));
122        assert!(matches!(
123            DecyclerOscillator::new(10, 0),
124            Err(Error::PeriodZero)
125        ));
126        assert!(matches!(
127            DecyclerOscillator::new(20, 10),
128            Err(Error::InvalidPeriod { .. })
129        ));
130        assert!(matches!(
131            DecyclerOscillator::new(10, 10),
132            Err(Error::InvalidPeriod { .. })
133        ));
134    }
135
136    #[test]
137    fn accessors_and_metadata() {
138        let mut dco = DecyclerOscillator::new(10, 30).unwrap();
139        assert_eq!(dco.periods(), (10, 30));
140        assert_eq!(dco.name(), "DecyclerOscillator");
141        assert!(dco.warmup_period() >= 1);
142        assert!(!dco.is_ready());
143        dco.update(100.0);
144        assert!(dco.is_ready());
145        assert!(dco.value().is_some());
146    }
147
148    #[test]
149    fn constant_series_yields_zero() {
150        let mut dco = DecyclerOscillator::new(10, 30).unwrap();
151        let out = dco.batch(&[42.0_f64; 80]);
152        for x in out.iter().flatten() {
153            assert_relative_eq!(*x, 0.0, epsilon = 1e-9);
154        }
155    }
156
157    #[test]
158    fn batch_equals_streaming() {
159        let prices: Vec<f64> = (0..100)
160            .map(|i| 100.0 + (f64::from(i) * 0.2).cos() * 6.0)
161            .collect();
162        let mut a = DecyclerOscillator::new(10, 30).unwrap();
163        let mut b = DecyclerOscillator::new(10, 30).unwrap();
164        let batch = a.batch(&prices);
165        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
166        assert_eq!(batch, streamed);
167    }
168
169    #[test]
170    fn ignores_non_finite_input() {
171        let mut dco = DecyclerOscillator::new(10, 30).unwrap();
172        dco.batch(&(1..=50).map(f64::from).collect::<Vec<_>>());
173        let before = dco.value();
174        assert!(before.is_some());
175        assert_eq!(dco.update(f64::NAN), None);
176    }
177
178    #[test]
179    fn reset_clears_state() {
180        let mut dco = DecyclerOscillator::new(10, 30).unwrap();
181        dco.batch(&(1..=50).map(f64::from).collect::<Vec<_>>());
182        assert!(dco.is_ready());
183        dco.reset();
184        assert!(!dco.is_ready());
185    }
186}