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    fn warmup_period(&self) -> usize {
184        // The first BP/TR pair needs a previous close, then the longest window
185        // must fill.
186        self.longest + 1
187    }
188
189    fn is_ready(&self) -> bool {
190        self.last.is_some()
191    }
192
193    fn name(&self) -> &'static str {
194        "UltimateOscillator"
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use crate::traits::BatchExt;
202    use approx::assert_relative_eq;
203
204    /// Build a flat candle (open = high = low = close).
205    fn flat(price: f64, ts: i64) -> Candle {
206        Candle::new(price, price, price, price, 1.0, ts).unwrap()
207    }
208
209    #[test]
210    fn new_rejects_zero_period() {
211        assert!(matches!(
212            UltimateOscillator::new(0, 14, 28),
213            Err(Error::PeriodZero)
214        ));
215        assert!(matches!(
216            UltimateOscillator::new(7, 0, 28),
217            Err(Error::PeriodZero)
218        ));
219        assert!(matches!(
220            UltimateOscillator::new(7, 14, 0),
221            Err(Error::PeriodZero)
222        ));
223    }
224
225    /// Cover the const accessors `periods` / `value` (96-103) and the
226    /// Indicator-impl `name` body (193-195). `warmup_period` is covered
227    /// by `first_emission_at_warmup_period`.
228    #[test]
229    fn accessors_and_metadata() {
230        let mut uo = UltimateOscillator::new(7, 14, 28).unwrap();
231        assert_eq!(uo.periods(), (7, 14, 28));
232        assert_eq!(uo.name(), "UltimateOscillator");
233        assert_eq!(uo.value(), None);
234        let warmup = i64::try_from(uo.warmup_period()).unwrap();
235        let candles: Vec<Candle> = (0..warmup)
236            .map(|i| {
237                let p = 100.0 + (i as f64 * 0.3).sin() * 5.0;
238                Candle::new(p, p + 1.0, p - 1.0, p, 1.0, i).unwrap()
239            })
240            .collect();
241        for c in &candles {
242            uo.update(*c);
243        }
244        assert!(uo.value().is_some());
245    }
246
247    #[test]
248    fn first_emission_at_warmup_period() {
249        let mut uo = UltimateOscillator::new(2, 3, 5).unwrap();
250        assert_eq!(uo.warmup_period(), 6);
251        let candles: Vec<Candle> = (0..20).map(|i| flat(100.0 + i as f64, i)).collect();
252        let out = uo.batch(&candles);
253        for v in out.iter().take(5) {
254            assert!(v.is_none());
255        }
256        assert!(out[5].is_some());
257    }
258
259    #[test]
260    fn pure_uptrend_saturates_at_100() {
261        // Each flat candle closes higher: BP == TR every bar, so every ratio
262        // is 1 and UO is 100.
263        let mut uo = UltimateOscillator::new(2, 3, 5).unwrap();
264        let candles: Vec<Candle> = (0..30).map(|i| flat(100.0 + i as f64, i)).collect();
265        for v in uo.batch(&candles).into_iter().flatten() {
266            assert_relative_eq!(v, 100.0, epsilon = 1e-9);
267        }
268    }
269
270    #[test]
271    fn pure_downtrend_saturates_at_0() {
272        // Each flat candle closes lower: BP is 0 every bar, so UO is 0.
273        let mut uo = UltimateOscillator::new(2, 3, 5).unwrap();
274        let candles: Vec<Candle> = (0..30).map(|i| flat(100.0 - i as f64, i)).collect();
275        for v in uo.batch(&candles).into_iter().flatten() {
276            assert_relative_eq!(v, 0.0, epsilon = 1e-9);
277        }
278    }
279
280    #[test]
281    fn flat_market_reads_50() {
282        // Every bar identical: zero true range everywhere -> neutral 50.
283        let mut uo = UltimateOscillator::new(2, 3, 5).unwrap();
284        let candles: Vec<Candle> = (0..30).map(|i| flat(100.0, i)).collect();
285        for v in uo.batch(&candles).into_iter().flatten() {
286            assert_relative_eq!(v, 50.0, epsilon = 1e-9);
287        }
288    }
289
290    #[test]
291    fn output_stays_within_0_100() {
292        let mut uo = UltimateOscillator::classic();
293        let candles: Vec<Candle> = (0..200)
294            .map(|i| {
295                let mid = 100.0 + (i as f64 * 0.2).sin() * 12.0;
296                Candle::new(mid, mid + 3.0, mid - 3.0, mid + 1.0, 10.0, i).unwrap()
297            })
298            .collect();
299        for v in uo.batch(&candles).into_iter().flatten() {
300            assert!((0.0..=100.0).contains(&v), "UO out of range: {v}");
301        }
302    }
303
304    #[test]
305    fn reset_clears_state() {
306        let mut uo = UltimateOscillator::new(2, 3, 5).unwrap();
307        let candles: Vec<Candle> = (0..20).map(|i| flat(100.0 + i as f64, i)).collect();
308        uo.batch(&candles);
309        assert!(uo.is_ready());
310        uo.reset();
311        assert!(!uo.is_ready());
312        assert_eq!(uo.update(candles[0]), None);
313    }
314
315    #[test]
316    fn batch_equals_streaming() {
317        let candles: Vec<Candle> = (0..120)
318            .map(|i| {
319                let mid = 100.0 + (i as f64 * 0.3).sin() * 10.0;
320                Candle::new(mid, mid + 2.0, mid - 2.0, mid + 0.5, 10.0, i).unwrap()
321            })
322            .collect();
323        let batch = UltimateOscillator::classic().batch(&candles);
324        let mut b = UltimateOscillator::classic();
325        let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
326        assert_eq!(batch, streamed);
327    }
328}