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 `Omega
20/// ≥ 0`; a window where every return clears the threshold has zero losses and
21/// the indicator returns `f64::INFINITY` (in keeping with the standard
22/// definition). The Sharpe Ratio collapses risk into a single second-moment
23/// number; Omega keeps the full shape of the loss tail.
24///
25/// Each `update` is O(period) because the partial sums are recomputed across
26/// the window — adequate for typical backtest windows (`period ≤ 252`).
27///
28/// # Example
29///
30/// ```
31/// use wickra_core::{Indicator, OmegaRatio};
32///
33/// let mut o = OmegaRatio::new(20, 0.0).unwrap();
34/// let mut last = None;
35/// for i in 0..40 {
36///     last = o.update((f64::from(i) * 0.2).sin() * 0.01);
37/// }
38/// assert!(last.is_some());
39/// ```
40#[derive(Debug, Clone)]
41pub struct OmegaRatio {
42    period: usize,
43    threshold: f64,
44    window: VecDeque<f64>,
45}
46
47impl OmegaRatio {
48    /// Construct a new rolling Omega Ratio.
49    ///
50    /// # Errors
51    /// Returns [`Error::PeriodZero`] if `period == 0`.
52    pub fn new(period: usize, threshold: f64) -> Result<Self> {
53        if period == 0 {
54            return Err(Error::PeriodZero);
55        }
56        Ok(Self {
57            period,
58            threshold,
59            window: VecDeque::with_capacity(period),
60        })
61    }
62
63    /// Configured window length.
64    pub const fn period(&self) -> usize {
65        self.period
66    }
67
68    /// Configured threshold (per-period).
69    pub const fn threshold(&self) -> f64 {
70        self.threshold
71    }
72}
73
74impl Indicator for OmegaRatio {
75    type Input = f64;
76    type Output = f64;
77
78    fn update(&mut self, input: f64) -> Option<f64> {
79        if !input.is_finite() {
80            return None;
81        }
82        if self.window.len() == self.period {
83            self.window.pop_front();
84        }
85        self.window.push_back(input);
86        if self.window.len() < self.period {
87            return None;
88        }
89        let mut gains = 0.0_f64;
90        let mut losses = 0.0_f64;
91        for &r in &self.window {
92            let d = r - self.threshold;
93            if d >= 0.0 {
94                gains += d;
95            } else {
96                losses += -d;
97            }
98        }
99        if losses == 0.0 {
100            return Some(if gains == 0.0 { 0.0 } else { f64::INFINITY });
101        }
102        Some(gains / losses)
103    }
104
105    fn reset(&mut self) {
106        self.window.clear();
107    }
108
109    fn warmup_period(&self) -> usize {
110        self.period
111    }
112
113    fn is_ready(&self) -> bool {
114        self.window.len() == self.period
115    }
116
117    fn name(&self) -> &'static str {
118        "OmegaRatio"
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use crate::traits::BatchExt;
126    use approx::assert_relative_eq;
127
128    #[test]
129    fn rejects_zero_period() {
130        assert!(matches!(OmegaRatio::new(0, 0.0), Err(Error::PeriodZero)));
131    }
132
133    #[test]
134    fn accessors_and_metadata() {
135        let o = OmegaRatio::new(10, 0.001).unwrap();
136        assert_eq!(o.period(), 10);
137        assert_relative_eq!(o.threshold(), 0.001, epsilon = 1e-12);
138        assert_eq!(o.name(), "OmegaRatio");
139        assert_eq!(o.warmup_period(), 10);
140    }
141
142    #[test]
143    fn all_above_threshold_yields_infinity() {
144        let mut o = OmegaRatio::new(4, 0.0).unwrap();
145        let out = o.batch(&[0.01, 0.02, 0.03, 0.04]);
146        assert!(out[3].unwrap().is_infinite());
147    }
148
149    #[test]
150    fn flat_at_threshold_yields_zero() {
151        // Every return equals threshold -> gains = losses = 0 -> 0 by
152        // convention.
153        let mut o = OmegaRatio::new(4, 0.01).unwrap();
154        let out = o.batch(&[0.01; 4]);
155        assert_eq!(out[3], Some(0.0));
156    }
157
158    #[test]
159    fn reference_value() {
160        // returns = [-0.02, 0.01, -0.01, 0.03], threshold = 0.
161        // gains  = 0.01 + 0.03 = 0.04
162        // losses = 0.02 + 0.01 = 0.03
163        // Omega = 0.04 / 0.03 ≈ 1.3333...
164        let mut o = OmegaRatio::new(4, 0.0).unwrap();
165        let out = o.batch(&[-0.02, 0.01, -0.01, 0.03]);
166        assert_relative_eq!(out[3].unwrap(), 0.04 / 0.03, epsilon = 1e-9);
167    }
168
169    #[test]
170    fn ignores_non_finite_input() {
171        let mut o = OmegaRatio::new(3, 0.0).unwrap();
172        assert_eq!(o.update(f64::NAN), None);
173        assert_eq!(o.update(f64::INFINITY), None);
174    }
175
176    #[test]
177    fn reset_clears_state() {
178        let mut o = OmegaRatio::new(3, 0.0).unwrap();
179        o.batch(&[0.01, -0.02, 0.005]);
180        assert!(o.is_ready());
181        o.reset();
182        assert!(!o.is_ready());
183        assert_eq!(o.update(0.01), None);
184    }
185
186    #[test]
187    fn batch_equals_streaming() {
188        let returns: Vec<f64> = (0..50).map(|i| (f64::from(i) * 0.4).sin() * 0.01).collect();
189        let batch = OmegaRatio::new(10, 0.0).unwrap().batch(&returns);
190        let mut s = OmegaRatio::new(10, 0.0).unwrap();
191        let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect();
192        assert_eq!(batch, streamed);
193    }
194}