Skip to main content

wickra_core/indicators/
omega_ratio.rs

1//! Rolling Omega Ratio — gain-to-loss ratio above a threshold.
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Rolling Omega Ratio.
9///
10/// Over the trailing window of `period` returns and a target `threshold`:
11///
12/// ```text
13/// gains  = Σ max(0, r − threshold)
14/// losses = Σ max(0, threshold − r)
15/// Omega  = gains / losses
16/// ```
17///
18/// Omega expresses how many units of "above-threshold" return the strategy
19/// produces per unit of "below-threshold" shortfall. By construction
20/// `Omega ≥ 0`. The Sharpe Ratio collapses risk into a single second-moment
21/// number; Omega keeps the full shape of the loss tail.
22///
23/// # Unbounded output
24///
25/// A window where every return clears the threshold has zero shortfall, and
26/// the indicator returns `f64::INFINITY`, in keeping with the standard
27/// definition. This is not an edge case to be discovered in production: any
28/// `period`-bar window that stays above the threshold produces it. The value
29/// is correct -- the ratio really is unbounded -- but it propagates, and
30/// `inf - inf` is `NaN`, so a caller feeding this into further arithmetic
31/// should test for it. `f64::is_finite` is the guard.
32///
33/// The threshold decides what "flat" means here, and the two ends differ:
34/// with `threshold = 0.0` a window of zero returns has neither gains nor
35/// shortfall, which is break-even and yields `1.0`, while with a *negative*
36/// threshold every zero return clears it, so the same flat window yields
37/// `f64::INFINITY`.
38///
39/// Each `update` is O(period) because the partial sums are recomputed across
40/// the window — adequate for typical backtest windows (`period ≤ 252`).
41///
42/// # Example
43///
44/// ```
45/// use wickra_core::{Indicator, OmegaRatio};
46///
47/// let mut o = OmegaRatio::new(20, 0.0).unwrap();
48/// let mut last = None;
49/// for i in 0..40 {
50///     last = o.update((f64::from(i) * 0.2).sin() * 0.01);
51/// }
52/// assert!(last.is_some());
53/// ```
54#[derive(Debug, Clone)]
55pub struct OmegaRatio {
56    period: usize,
57    threshold: f64,
58    window: VecDeque<f64>,
59}
60
61impl OmegaRatio {
62    /// Construct a new rolling Omega Ratio.
63    ///
64    /// # Errors
65    /// Returns [`Error::PeriodZero`] if `period == 0`.
66    pub fn new(period: usize, threshold: f64) -> Result<Self> {
67        if period == 0 {
68            return Err(Error::PeriodZero);
69        }
70        if period > crate::error::MAX_PERIOD {
71            return Err(Error::InvalidPeriod {
72                message: crate::error::PERIOD_ABOVE_MAX,
73            });
74        }
75        Ok(Self {
76            period,
77            threshold,
78            window: VecDeque::with_capacity(period),
79        })
80    }
81
82    /// Configured window length.
83    pub const fn period(&self) -> usize {
84        self.period
85    }
86
87    /// Configured threshold (per-period).
88    pub const fn threshold(&self) -> f64 {
89        self.threshold
90    }
91}
92
93impl Indicator for OmegaRatio {
94    type Input = f64;
95    type Output = f64;
96
97    #[inline]
98    fn update(&mut self, input: f64) -> Option<f64> {
99        if !input.is_finite() {
100            return None;
101        }
102        if self.window.len() == self.period {
103            self.window.pop_front();
104        }
105        self.window.push_back(input);
106        if self.window.len() < self.period {
107            return None;
108        }
109        let mut gains = 0.0_f64;
110        let mut losses = 0.0_f64;
111        for &r in &self.window {
112            let d = r - self.threshold;
113            if d >= 0.0 {
114                gains += d;
115            } else {
116                losses += -d;
117            }
118        }
119        if losses == 0.0 {
120            // Neither gains nor losses: the window is break-even, which is
121            // what 1.0 means here. Returning 0.0 made a flat window
122            // indistinguishable from one that lost on every bar.
123            return Some(if gains == 0.0 { 1.0 } else { f64::INFINITY });
124        }
125        Some(gains / losses)
126    }
127
128    fn reset(&mut self) {
129        self.window.clear();
130    }
131
132    #[inline]
133    fn warmup_period(&self) -> usize {
134        self.period
135    }
136
137    #[inline]
138    fn is_ready(&self) -> bool {
139        self.window.len() == self.period
140    }
141
142    #[inline]
143    fn name(&self) -> &'static str {
144        "OmegaRatio"
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::traits::BatchExt;
152    use approx::assert_relative_eq;
153
154    #[test]
155    fn rejects_zero_period() {
156        assert!(matches!(OmegaRatio::new(0, 0.0), Err(Error::PeriodZero)));
157    }
158
159    #[test]
160    fn accessors_and_metadata() {
161        let o = OmegaRatio::new(10, 0.001).unwrap();
162        assert_eq!(o.period(), 10);
163        assert_relative_eq!(o.threshold(), 0.001, epsilon = 1e-12);
164        assert_eq!(o.name(), "OmegaRatio");
165        assert_eq!(o.warmup_period(), 10);
166    }
167
168    #[test]
169    fn all_above_threshold_yields_infinity() {
170        let mut o = OmegaRatio::new(4, 0.0).unwrap();
171        let out = o.batch(&[0.01, 0.02, 0.03, 0.04]);
172        assert!(out[3].unwrap().is_infinite());
173    }
174
175    #[test]
176    fn flat_at_threshold_is_break_even() {
177        // Every return equals threshold -> gains = losses = 0 -> 0 by
178        // convention.
179        let mut o = OmegaRatio::new(4, 0.01).unwrap();
180        let out = o.batch(&[0.01; 4]);
181        assert_eq!(out[3], Some(1.0));
182    }
183
184    #[test]
185    fn reference_value() {
186        // returns = [-0.02, 0.01, -0.01, 0.03], threshold = 0.
187        // gains  = 0.01 + 0.03 = 0.04
188        // losses = 0.02 + 0.01 = 0.03
189        // Omega = 0.04 / 0.03 ≈ 1.3333...
190        let mut o = OmegaRatio::new(4, 0.0).unwrap();
191        let out = o.batch(&[-0.02, 0.01, -0.01, 0.03]);
192        assert_relative_eq!(out[3].unwrap(), 0.04 / 0.03, epsilon = 1e-9);
193    }
194
195    #[test]
196    fn ignores_non_finite_input() {
197        let mut o = OmegaRatio::new(3, 0.0).unwrap();
198        assert_eq!(o.update(f64::NAN), None);
199        assert_eq!(o.update(f64::INFINITY), None);
200    }
201
202    #[test]
203    fn reset_clears_state() {
204        let mut o = OmegaRatio::new(3, 0.0).unwrap();
205        o.batch(&[0.01, -0.02, 0.005]);
206        assert!(o.is_ready());
207        o.reset();
208        assert!(!o.is_ready());
209        assert_eq!(o.update(0.01), None);
210    }
211
212    #[test]
213    fn batch_equals_streaming() {
214        let returns: Vec<f64> = (0..50).map(|i| (f64::from(i) * 0.4).sin() * 0.01).collect();
215        let batch = OmegaRatio::new(10, 0.0).unwrap().batch(&returns);
216        let mut s = OmegaRatio::new(10, 0.0).unwrap();
217        let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect();
218        assert_eq!(batch, streamed);
219    }
220    /// With a negative threshold every flat return counts as clearing it, so a
221    /// window that did not move at all reports an unbounded ratio rather than
222    /// the break-even `1.0` the same window gives at a threshold of zero.
223    /// Worth pinning because it is the opposite answer to the obvious one.
224    #[test]
225    fn a_negative_threshold_makes_a_flat_window_unbounded() {
226        let flat = [0.0_f64; 20];
227
228        let mut at_zero = OmegaRatio::new(14, 0.0).unwrap();
229        let mut below = OmegaRatio::new(14, -0.005).unwrap();
230        let (mut last_at_zero, mut last_below) = (None, None);
231        for &r in &flat {
232            last_at_zero = at_zero.update(r).or(last_at_zero);
233            last_below = below.update(r).or(last_below);
234        }
235
236        assert_eq!(last_at_zero, Some(1.0));
237        assert_eq!(last_below, Some(f64::INFINITY));
238    }
239    /// A flat window and a window that lost on every single bar are opposite
240    /// states, and both used to report `0.0`. Asserting each value on its own
241    /// could never catch that; asserting they differ is the property that
242    /// matters.
243    #[test]
244    fn a_flat_window_is_not_confused_with_an_all_losing_one() {
245        let flat = [0.0_f64; 20];
246        let losing = [-0.01_f64; 20];
247
248        let mut a = OmegaRatio::new(14, 0.0).unwrap();
249        let mut b = OmegaRatio::new(14, 0.0).unwrap();
250        let (mut flat_value, mut losing_value) = (None, None);
251        for i in 0..flat.len() {
252            flat_value = a.update(flat[i]).or(flat_value);
253            losing_value = b.update(losing[i]).or(losing_value);
254        }
255
256        assert_eq!(flat_value, Some(1.0), "a flat window is break-even");
257        assert_eq!(losing_value, Some(0.0), "an all-losing window has no gains");
258        assert_ne!(flat_value, losing_value);
259    }
260}