Skip to main content

wickra_core/indicators/
universal_oscillator.rs

1//! Ehlers Universal Oscillator — whitened, SuperSmoothed, AGC-normalised cycle.
2#![allow(clippy::doc_markdown)]
3
4use crate::error::{Error, Result};
5use crate::indicators::super_smoother::SuperSmoother;
6use crate::traits::Indicator;
7
8/// Ehlers' **Universal Oscillator** — a cycle oscillator that whitens the price
9/// series, SuperSmooths it, then normalises with an automatic gain control (AGC)
10/// to swing in `[−1, +1]`.
11///
12/// From John Ehlers' *Cycle Analytics for Traders* (2013):
13///
14/// ```text
15/// WhiteNoise = (price_t − price_{t−2}) / 2          (flat-spectrum prewhitening)
16/// Filt       = SuperSmoother(WhiteNoise, period)
17/// Peak       = max(|Filt|, 0.991 · Peak_{t−1})      (decaying peak / AGC)
18/// Universal  = Filt / Peak                          (0 if Peak == 0)
19/// ```
20///
21/// "Whitening" the input (a two-bar difference) flattens its power spectrum so the
22/// SuperSmoother responds equally to all cycles rather than being dominated by the
23/// trend. The automatic gain control divides by a slowly-decaying running peak, so
24/// the output is amplitude-normalised to `[−1, +1]` and behaves consistently
25/// across instruments and volatility regimes — hence "universal". Read it like any
26/// bounded oscillator: turns near the rails flag cycle extremes, zero-crossings
27/// flag cycle direction changes.
28///
29/// The first value lands once a two-bar difference exists (`warmup_period == 3`).
30/// Each `update` is O(1).
31///
32/// # Example
33///
34/// ```
35/// use wickra_core::{Indicator, UniversalOscillator};
36///
37/// let mut indicator = UniversalOscillator::new(20).unwrap();
38/// let mut last = None;
39/// for i in 0..80 {
40///     last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
41/// }
42/// assert!(last.is_some());
43/// ```
44#[derive(Debug, Clone)]
45pub struct UniversalOscillator {
46    period: usize,
47    smoother: SuperSmoother,
48    prev_price_1: Option<f64>,
49    prev_price_2: Option<f64>,
50    peak: f64,
51    last: Option<f64>,
52}
53
54impl UniversalOscillator {
55    /// Construct a Universal Oscillator with the given SuperSmoother `period`.
56    ///
57    /// # Errors
58    ///
59    /// Returns [`Error::PeriodZero`] if `period == 0`.
60    pub fn new(period: usize) -> Result<Self> {
61        if period == 0 {
62            return Err(Error::PeriodZero);
63        }
64        if period > crate::error::MAX_PERIOD {
65            return Err(Error::InvalidPeriod {
66                message: crate::error::PERIOD_ABOVE_MAX,
67            });
68        }
69        Ok(Self {
70            period,
71            smoother: SuperSmoother::new(period)?,
72            prev_price_1: None,
73            prev_price_2: None,
74            peak: 0.0,
75            last: None,
76        })
77    }
78
79    /// Configured period.
80    pub const fn period(&self) -> usize {
81        self.period
82    }
83
84    /// Current value if available.
85    pub const fn value(&self) -> Option<f64> {
86        self.last
87    }
88}
89
90impl Indicator for UniversalOscillator {
91    type Input = f64;
92    type Output = f64;
93
94    #[inline]
95    fn update(&mut self, price: f64) -> Option<f64> {
96        if !price.is_finite() {
97            return None;
98        }
99        let Some(p2) = self.prev_price_2 else {
100            self.prev_price_2 = self.prev_price_1;
101            self.prev_price_1 = Some(price);
102            return None;
103        };
104        let white_noise = (price - p2) / 2.0;
105        if !white_noise.is_finite() {
106            // `price - p2` can overflow to +/-inf even when both are finite;
107            // skip the bar rather than feeding a non-finite value downstream.
108            self.prev_price_2 = self.prev_price_1;
109            self.prev_price_1 = Some(price);
110            return self.last;
111        }
112        let filt = self
113            .smoother
114            .update(white_noise)
115            .expect("supersmoother emits");
116        self.peak = filt.abs().max(0.991 * self.peak);
117        let universal = if self.peak > 0.0 {
118            (filt / self.peak).clamp(-1.0, 1.0)
119        } else {
120            0.0
121        };
122        self.prev_price_2 = self.prev_price_1;
123        self.prev_price_1 = Some(price);
124        self.last = Some(universal);
125        Some(universal)
126    }
127
128    fn reset(&mut self) {
129        self.smoother.reset();
130        self.prev_price_1 = None;
131        self.prev_price_2 = None;
132        self.peak = 0.0;
133        self.last = None;
134    }
135
136    #[inline]
137    fn warmup_period(&self) -> usize {
138        3
139    }
140
141    #[inline]
142    fn is_ready(&self) -> bool {
143        self.last.is_some()
144    }
145
146    #[inline]
147    fn name(&self) -> &'static str {
148        "UniversalOscillator"
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::traits::BatchExt;
156
157    #[test]
158    fn rejects_zero_period() {
159        assert!(matches!(
160            UniversalOscillator::new(0),
161            Err(Error::PeriodZero)
162        ));
163    }
164
165    #[test]
166    fn accessors_and_metadata() {
167        let u = UniversalOscillator::new(20).unwrap();
168        assert_eq!(u.period(), 20);
169        assert_eq!(u.warmup_period(), 3);
170        assert_eq!(u.name(), "UniversalOscillator");
171        assert!(!u.is_ready());
172        assert_eq!(u.value(), None);
173    }
174
175    #[test]
176    fn first_emission_at_warmup_period() {
177        let mut u = UniversalOscillator::new(20).unwrap();
178        let out = u.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
179        assert!(out[0].is_none());
180        assert!(out[1].is_none());
181        assert!(out[2].is_some());
182    }
183
184    #[test]
185    fn constant_input_is_zero() {
186        // A flat input whitens to zero -> output 0.
187        let mut u = UniversalOscillator::new(20).unwrap();
188        for v in u.batch(&[50.0; 200]).into_iter().flatten() {
189            assert!(v.abs() < 1e-9);
190        }
191    }
192
193    #[test]
194    fn output_in_range() {
195        let mut u = UniversalOscillator::new(20).unwrap();
196        let xs: Vec<f64> = (0..400)
197            .map(|i| 100.0 + (std::f64::consts::TAU * f64::from(i) / 20.0).sin() * 5.0)
198            .collect();
199        for v in u.batch(&xs).into_iter().flatten() {
200            assert!((-1.0..=1.0).contains(&v), "out of range: {v}");
201        }
202    }
203
204    #[test]
205    fn cyclic_input_swings_both_signs() {
206        let mut u = UniversalOscillator::new(20).unwrap();
207        let xs: Vec<f64> = (0..400)
208            .map(|i| 100.0 + (std::f64::consts::TAU * f64::from(i) / 20.0).sin() * 5.0)
209            .collect();
210        let out: Vec<f64> = u.batch(&xs).into_iter().flatten().skip(100).collect();
211        assert!(out.iter().any(|&v| v > 0.5));
212        assert!(out.iter().any(|&v| v < -0.5));
213    }
214
215    #[test]
216    fn ignores_non_finite() {
217        let mut u = UniversalOscillator::new(20).unwrap();
218        u.batch(
219            &(0..40)
220                .map(|i| 100.0 + (f64::from(i) * 0.3).sin())
221                .collect::<Vec<_>>(),
222        );
223        let before = u.value();
224        assert_eq!(u.update(f64::NAN), None);
225        // The rejected input must not have disturbed the state.
226        assert_eq!(u.value(), before);
227    }
228
229    #[test]
230    fn reset_clears_state() {
231        let mut u = UniversalOscillator::new(20).unwrap();
232        u.batch(
233            &(0..40)
234                .map(|i| 100.0 + (f64::from(i) * 0.3).sin())
235                .collect::<Vec<_>>(),
236        );
237        assert!(u.is_ready());
238        u.reset();
239        assert!(!u.is_ready());
240        assert_eq!(u.value(), None);
241    }
242
243    #[test]
244    fn batch_equals_streaming() {
245        let xs: Vec<f64> = (0..120)
246            .map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
247            .collect();
248        let batch = UniversalOscillator::new(20).unwrap().batch(&xs);
249        let mut b = UniversalOscillator::new(20).unwrap();
250        let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
251        assert_eq!(batch, streamed);
252    }
253
254    #[test]
255    fn non_finite_white_noise_is_skipped() {
256        // `price - p2` can overflow to infinity even when both prices are
257        // finite; the non-finite white-noise term must be skipped, not fed to
258        // the smoother (which would otherwise yield `None` on the first bar).
259        let mut u = UniversalOscillator::new(20).unwrap();
260        assert_eq!(u.update(-1e308), None);
261        assert_eq!(u.update(0.0), None);
262        // (1e308 - (-1e308)) overflows to +inf -> white_noise non-finite.
263        assert_eq!(u.update(1e308), None);
264    }
265}