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    fn update(&mut self, input: f64) -> Option<f64> {
73        if !input.is_finite() {
74            return self.last_value;
75        }
76        // Both child `Decycler` instances emit `Some` from the first bar
77        // (Ehlers' convention is "output = input" until the recursion warms),
78        // so the pair is always populated and the `?` short-circuit never
79        // fires in practice.
80        let f = self.fast.update(input)?;
81        let s = self.slow.update(input)?;
82        let v = f - s;
83        self.last_value = Some(v);
84        Some(v)
85    }
86
87    fn reset(&mut self) {
88        self.fast.reset();
89        self.slow.reset();
90        self.last_value = None;
91    }
92
93    fn warmup_period(&self) -> usize {
94        self.fast.warmup_period().max(self.slow.warmup_period())
95    }
96
97    fn is_ready(&self) -> bool {
98        self.last_value.is_some()
99    }
100
101    fn name(&self) -> &'static str {
102        "DecyclerOscillator"
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use crate::traits::BatchExt;
110    use approx::assert_relative_eq;
111
112    #[test]
113    fn new_rejects_invalid_periods() {
114        assert!(matches!(
115            DecyclerOscillator::new(0, 20),
116            Err(Error::PeriodZero)
117        ));
118        assert!(matches!(
119            DecyclerOscillator::new(10, 0),
120            Err(Error::PeriodZero)
121        ));
122        assert!(matches!(
123            DecyclerOscillator::new(20, 10),
124            Err(Error::InvalidPeriod { .. })
125        ));
126        assert!(matches!(
127            DecyclerOscillator::new(10, 10),
128            Err(Error::InvalidPeriod { .. })
129        ));
130    }
131
132    #[test]
133    fn accessors_and_metadata() {
134        let mut dco = DecyclerOscillator::new(10, 30).unwrap();
135        assert_eq!(dco.periods(), (10, 30));
136        assert_eq!(dco.name(), "DecyclerOscillator");
137        assert!(dco.warmup_period() >= 1);
138        assert!(!dco.is_ready());
139        dco.update(100.0);
140        assert!(dco.is_ready());
141        assert!(dco.value().is_some());
142    }
143
144    #[test]
145    fn constant_series_yields_zero() {
146        let mut dco = DecyclerOscillator::new(10, 30).unwrap();
147        let out = dco.batch(&[42.0_f64; 80]);
148        for x in out.iter().flatten() {
149            assert_relative_eq!(*x, 0.0, epsilon = 1e-9);
150        }
151    }
152
153    #[test]
154    fn batch_equals_streaming() {
155        let prices: Vec<f64> = (0..100)
156            .map(|i| 100.0 + (f64::from(i) * 0.2).cos() * 6.0)
157            .collect();
158        let mut a = DecyclerOscillator::new(10, 30).unwrap();
159        let mut b = DecyclerOscillator::new(10, 30).unwrap();
160        let batch = a.batch(&prices);
161        let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
162        assert_eq!(batch, streamed);
163    }
164
165    #[test]
166    fn ignores_non_finite_input() {
167        let mut dco = DecyclerOscillator::new(10, 30).unwrap();
168        dco.batch(&(1..=50).map(f64::from).collect::<Vec<_>>());
169        let before = dco.value();
170        assert!(before.is_some());
171        assert_eq!(dco.update(f64::NAN), before);
172    }
173
174    #[test]
175    fn reset_clears_state() {
176        let mut dco = DecyclerOscillator::new(10, 30).unwrap();
177        dco.batch(&(1..=50).map(f64::from).collect::<Vec<_>>());
178        assert!(dco.is_ready());
179        dco.reset();
180        assert!(!dco.is_ready());
181    }
182}