Skip to main content

wickra_core/indicators/
ultimate_oscillator.rs

1//! Ultimate Oscillator.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::ohlcv::Candle;
7use crate::traits::Indicator;
8
9/// Ultimate Oscillator — Larry Williams' three-timeframe momentum oscillator.
10///
11/// A single-timeframe oscillator can give false divergence signals when the
12/// chosen lookback does not match the swing being measured. The Ultimate
13/// Oscillator blends *three* lookbacks into one bounded `[0, 100]` reading,
14/// weighting the fastest most heavily:
15///
16/// ```text
17/// true_low_t   = min(low_t, close_{t−1})
18/// BP_t         = close_t − true_low_t                       (buying pressure)
19/// TR_t         = max(high_t, close_{t−1}) − true_low_t      (true range)
20/// avg_n        = Σ BP over n / Σ TR over n
21/// UO           = 100 · (4·avg_short + 2·avg_mid + avg_long) / 7
22/// ```
23///
24/// The conventional periods are `7`, `14` and `28`. A fully flat window (zero
25/// true range) contributes the neutral ratio `0.5`, so a flat market reads
26/// `50`.
27///
28/// # Example
29///
30/// ```
31/// use wickra_core::{Candle, Indicator, UltimateOscillator};
32///
33/// let mut indicator = UltimateOscillator::new(7, 14, 28).unwrap();
34/// let mut last = None;
35/// for i in 0..80 {
36///     let p = 100.0 + f64::from(i);
37///     let candle = Candle::new(p, p + 1.0, p - 1.0, p, 10.0, i64::from(i)).unwrap();
38///     last = indicator.update(candle);
39/// }
40/// assert!(last.is_some());
41/// ```
42#[derive(Debug, Clone)]
43pub struct UltimateOscillator {
44    short: usize,
45    mid: usize,
46    long: usize,
47    longest: usize,
48    prev_close: Option<f64>,
49    /// Rolling window of `(buying_pressure, true_range)` pairs.
50    window: VecDeque<(f64, f64)>,
51    sum_bp_short: f64,
52    sum_tr_short: f64,
53    sum_bp_mid: f64,
54    sum_tr_mid: f64,
55    sum_bp_long: f64,
56    sum_tr_long: f64,
57    pairs: usize,
58    last: Option<f64>,
59}
60
61impl UltimateOscillator {
62    /// Construct a new Ultimate Oscillator with the three lookback periods.
63    ///
64    /// # Errors
65    ///
66    /// Returns [`Error::PeriodZero`] if any period is `0`.
67    pub fn new(short: usize, mid: usize, long: usize) -> Result<Self> {
68        if short == 0 || mid == 0 || long == 0 {
69            return Err(Error::PeriodZero);
70        }
71        let longest = short.max(mid).max(long);
72        Ok(Self {
73            short,
74            mid,
75            long,
76            longest,
77            prev_close: None,
78            window: VecDeque::with_capacity(longest + 1),
79            sum_bp_short: 0.0,
80            sum_tr_short: 0.0,
81            sum_bp_mid: 0.0,
82            sum_tr_mid: 0.0,
83            sum_bp_long: 0.0,
84            sum_tr_long: 0.0,
85            pairs: 0,
86            last: None,
87        })
88    }
89
90    /// Classic Ultimate Oscillator: periods `7`, `14`, `28`.
91    pub fn classic() -> Self {
92        Self::new(7, 14, 28).expect("classic Ultimate Oscillator periods are valid")
93    }
94
95    /// The `(short, mid, long)` periods.
96    pub const fn periods(&self) -> (usize, usize, usize) {
97        (self.short, self.mid, self.long)
98    }
99
100    /// Current value if available.
101    pub const fn value(&self) -> Option<f64> {
102        self.last
103    }
104}
105
106impl Indicator for UltimateOscillator {
107    type Input = Candle;
108    type Output = f64;
109
110    fn update(&mut self, candle: Candle) -> Option<f64> {
111        let Some(prev_close) = self.prev_close else {
112            // The first bar has no previous close, so no BP/TR can be formed.
113            self.prev_close = Some(candle.close);
114            return None;
115        };
116        self.prev_close = Some(candle.close);
117
118        let true_low = candle.low.min(prev_close);
119        let bp = candle.close - true_low;
120        let tr = candle.high.max(prev_close) - true_low;
121
122        self.window.push_back((bp, tr));
123        let n = self.window.len();
124        self.sum_bp_short += bp;
125        self.sum_tr_short += tr;
126        self.sum_bp_mid += bp;
127        self.sum_tr_mid += tr;
128        self.sum_bp_long += bp;
129        self.sum_tr_long += tr;
130        if n > self.short {
131            let (b, t) = self.window[n - 1 - self.short];
132            self.sum_bp_short -= b;
133            self.sum_tr_short -= t;
134        }
135        if n > self.mid {
136            let (b, t) = self.window[n - 1 - self.mid];
137            self.sum_bp_mid -= b;
138            self.sum_tr_mid -= t;
139        }
140        if n > self.long {
141            let (b, t) = self.window[n - 1 - self.long];
142            self.sum_bp_long -= b;
143            self.sum_tr_long -= t;
144        }
145        if self.window.len() > self.longest {
146            self.window.pop_front();
147        }
148
149        self.pairs += 1;
150        if self.pairs < self.longest {
151            return None;
152        }
153
154        let avg = |bp_sum: f64, tr_sum: f64| {
155            if tr_sum == 0.0 {
156                // A fully flat window has no range; contribute the midpoint.
157                0.5
158            } else {
159                bp_sum / tr_sum
160            }
161        };
162        let avg_short = avg(self.sum_bp_short, self.sum_tr_short);
163        let avg_mid = avg(self.sum_bp_mid, self.sum_tr_mid);
164        let avg_long = avg(self.sum_bp_long, self.sum_tr_long);
165        let uo = 100.0 * (4.0 * avg_short + 2.0 * avg_mid + avg_long) / 7.0;
166        self.last = Some(uo);
167        Some(uo)
168    }
169
170    fn reset(&mut self) {
171        self.prev_close = None;
172        self.window.clear();
173        self.sum_bp_short = 0.0;
174        self.sum_tr_short = 0.0;
175        self.sum_bp_mid = 0.0;
176        self.sum_tr_mid = 0.0;
177        self.sum_bp_long = 0.0;
178        self.sum_tr_long = 0.0;
179        self.pairs = 0;
180        self.last = None;
181    }
182
183    #[inline]
184    fn warmup_period(&self) -> usize {
185        // The first BP/TR pair needs a previous close, then the longest window
186        // must fill.
187        self.longest + 1
188    }
189
190    #[inline]
191    fn is_ready(&self) -> bool {
192        self.last.is_some()
193    }
194
195    #[inline]
196    fn name(&self) -> &'static str {
197        "UltimateOscillator"
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use crate::traits::BatchExt;
205    use approx::assert_relative_eq;
206
207    /// Build a flat candle (open = high = low = close).
208    fn flat(price: f64, ts: i64) -> Candle {
209        Candle::new(price, price, price, price, 1.0, ts).unwrap()
210    }
211
212    #[test]
213    fn new_rejects_zero_period() {
214        assert!(matches!(
215            UltimateOscillator::new(0, 14, 28),
216            Err(Error::PeriodZero)
217        ));
218        assert!(matches!(
219            UltimateOscillator::new(7, 0, 28),
220            Err(Error::PeriodZero)
221        ));
222        assert!(matches!(
223            UltimateOscillator::new(7, 14, 0),
224            Err(Error::PeriodZero)
225        ));
226    }
227
228    /// Cover the const accessors `periods` / `value` (96-103) and the
229    /// Indicator-impl `name` body (193-195). `warmup_period` is covered
230    /// by `first_emission_at_warmup_period`.
231    #[test]
232    fn accessors_and_metadata() {
233        let mut uo = UltimateOscillator::new(7, 14, 28).unwrap();
234        assert_eq!(uo.periods(), (7, 14, 28));
235        assert_eq!(uo.name(), "UltimateOscillator");
236        assert_eq!(uo.value(), None);
237        let warmup = i64::try_from(uo.warmup_period()).unwrap();
238        let candles: Vec<Candle> = (0..warmup)
239            .map(|i| {
240                let p = 100.0 + (i as f64 * 0.3).sin() * 5.0;
241                Candle::new(p, p + 1.0, p - 1.0, p, 1.0, i).unwrap()
242            })
243            .collect();
244        for c in &candles {
245            uo.update(*c);
246        }
247        assert!(uo.value().is_some());
248    }
249
250    #[test]
251    fn first_emission_at_warmup_period() {
252        let mut uo = UltimateOscillator::new(2, 3, 5).unwrap();
253        assert_eq!(uo.warmup_period(), 6);
254        let candles: Vec<Candle> = (0..20).map(|i| flat(100.0 + i as f64, i)).collect();
255        let out = uo.batch(&candles);
256        for v in out.iter().take(5) {
257            assert!(v.is_none());
258        }
259        assert!(out[5].is_some());
260    }
261
262    #[test]
263    fn pure_uptrend_saturates_at_100() {
264        // Each flat candle closes higher: BP == TR every bar, so every ratio
265        // is 1 and UO is 100.
266        let mut uo = UltimateOscillator::new(2, 3, 5).unwrap();
267        let candles: Vec<Candle> = (0..30).map(|i| flat(100.0 + i as f64, i)).collect();
268        for v in uo.batch(&candles).into_iter().flatten() {
269            assert_relative_eq!(v, 100.0, epsilon = 1e-9);
270        }
271    }
272
273    #[test]
274    fn pure_downtrend_saturates_at_0() {
275        // Each flat candle closes lower: BP is 0 every bar, so UO is 0.
276        let mut uo = UltimateOscillator::new(2, 3, 5).unwrap();
277        let candles: Vec<Candle> = (0..30).map(|i| flat(100.0 - i as f64, i)).collect();
278        for v in uo.batch(&candles).into_iter().flatten() {
279            assert_relative_eq!(v, 0.0, epsilon = 1e-9);
280        }
281    }
282
283    #[test]
284    fn flat_market_reads_50() {
285        // Every bar identical: zero true range everywhere -> neutral 50.
286        let mut uo = UltimateOscillator::new(2, 3, 5).unwrap();
287        let candles: Vec<Candle> = (0..30).map(|i| flat(100.0, i)).collect();
288        for v in uo.batch(&candles).into_iter().flatten() {
289            assert_relative_eq!(v, 50.0, epsilon = 1e-9);
290        }
291    }
292
293    #[test]
294    fn output_stays_within_0_100() {
295        let mut uo = UltimateOscillator::classic();
296        let candles: Vec<Candle> = (0..200)
297            .map(|i| {
298                let mid = 100.0 + (i as f64 * 0.2).sin() * 12.0;
299                Candle::new(mid, mid + 3.0, mid - 3.0, mid + 1.0, 10.0, i).unwrap()
300            })
301            .collect();
302        for v in uo.batch(&candles).into_iter().flatten() {
303            assert!((0.0..=100.0).contains(&v), "UO out of range: {v}");
304        }
305    }
306
307    #[test]
308    fn reset_clears_state() {
309        let mut uo = UltimateOscillator::new(2, 3, 5).unwrap();
310        let candles: Vec<Candle> = (0..20).map(|i| flat(100.0 + i as f64, i)).collect();
311        uo.batch(&candles);
312        assert!(uo.is_ready());
313        uo.reset();
314        assert!(!uo.is_ready());
315        assert_eq!(uo.update(candles[0]), None);
316    }
317
318    #[test]
319    fn batch_equals_streaming() {
320        let candles: Vec<Candle> = (0..120)
321            .map(|i| {
322                let mid = 100.0 + (i as f64 * 0.3).sin() * 10.0;
323                Candle::new(mid, mid + 2.0, mid - 2.0, mid + 0.5, 10.0, i).unwrap()
324            })
325            .collect();
326        let batch = UltimateOscillator::classic().batch(&candles);
327        let mut b = UltimateOscillator::classic();
328        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
329        assert_eq!(batch, streamed);
330    }
331}