Skip to main content

nautilus_analysis/statistics/
omega_ratio.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Omega Ratio statistic.
17
18use std::fmt::Display;
19
20use nautilus_core::correctness::check_predicate_true;
21use nautilus_model::position::Position;
22
23use crate::{Returns, statistic::PortfolioStatistic};
24
25/// Calculates the Omega ratio of portfolio returns.
26///
27/// The Omega ratio is the ratio of probability-weighted gains to losses relative
28/// to a return threshold `θ`. It captures the entire return distribution (all
29/// moments), unlike the Sharpe ratio which only uses the first two:
30///
31/// `Omega(θ) = sum(max(r - θ, 0)) / sum(max(θ - r, 0))`
32///
33/// The threshold `θ` defaults to `0` (gains vs losses about zero). A value above
34/// `1` means gains above the threshold outweigh losses below it. Returns `NaN`
35/// for an empty series, or when there are no returns below the threshold (the
36/// ratio is undefined).
37///
38/// # References
39///
40/// - Keating, C., & Shadwick, W. F. (2002). "A Universal Performance Measure".
41///   *Journal of Performance Measurement*, 6(3), 59-84.
42#[repr(C)]
43#[derive(Debug, Clone)]
44#[cfg_attr(
45    feature = "python",
46    pyo3::pyclass(module = "nautilus_trader.analysis", from_py_object)
47)]
48#[cfg_attr(
49    feature = "python",
50    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.analysis")
51)]
52pub struct OmegaRatio {
53    /// The return threshold `θ` separating gains from losses (default: 0.0).
54    threshold: f64,
55}
56
57impl OmegaRatio {
58    /// Creates a new checked [`OmegaRatio`] instance.
59    ///
60    /// # Errors
61    ///
62    /// Returns an error if `threshold` is not finite.
63    pub fn new_checked(threshold: Option<f64>) -> anyhow::Result<Self> {
64        let threshold = threshold.unwrap_or(0.0);
65        check_predicate_true(threshold.is_finite(), "threshold must be finite")?;
66        Ok(Self { threshold })
67    }
68
69    /// Creates a new [`OmegaRatio`] instance.
70    ///
71    /// # Panics
72    ///
73    /// Panics if `threshold` is not finite.
74    #[must_use]
75    pub fn new(threshold: Option<f64>) -> Self {
76        Self::new_checked(threshold).expect("Invalid `threshold` for `OmegaRatio`")
77    }
78}
79
80impl Display for OmegaRatio {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        write!(f, "Omega Ratio (threshold {})", self.threshold)
83    }
84}
85
86impl PortfolioStatistic for OmegaRatio {
87    type Item = f64;
88
89    fn name(&self) -> String {
90        self.to_string()
91    }
92
93    fn calculate_from_returns(&self, raw_returns: &Returns) -> Option<Self::Item> {
94        if !self.check_valid_returns(raw_returns) {
95            return Some(f64::NAN);
96        }
97
98        let returns = self.downsample_to_daily_bins(raw_returns);
99
100        let mut gain = 0.0;
101        let mut loss = 0.0;
102
103        for &ret in returns.values() {
104            let excess = ret - self.threshold;
105            if excess > 0.0 {
106                gain += excess;
107            } else {
108                loss -= excess;
109            }
110        }
111
112        if loss <= 0.0 {
113            return Some(f64::NAN);
114        }
115
116        Some(gain / loss)
117    }
118
119    fn calculate_from_realized_pnls(&self, _realized_pnls: &[f64]) -> Option<Self::Item> {
120        None
121    }
122
123    fn calculate_from_positions(&self, _positions: &[Position]) -> Option<Self::Item> {
124        None
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use std::collections::BTreeMap;
131
132    use nautilus_core::{UnixNanos, approx_eq};
133    use rstest::rstest;
134
135    use super::*;
136
137    fn create_returns(values: &[f64]) -> BTreeMap<UnixNanos, f64> {
138        let mut new_return = BTreeMap::new();
139        let one_day_in_nanos = 86_400_000_000_000;
140        let start_time = 1_600_000_000_000_000_000;
141
142        for (i, &value) in values.iter().enumerate() {
143            let timestamp = start_time + i as u64 * one_day_in_nanos;
144            new_return.insert(UnixNanos::from(timestamp), value);
145        }
146
147        new_return
148    }
149
150    #[rstest]
151    fn test_name() {
152        let ratio = OmegaRatio::new(None);
153        assert_eq!(ratio.name(), "Omega Ratio (threshold 0)");
154    }
155
156    #[rstest]
157    fn test_empty_returns() {
158        let ratio = OmegaRatio::new(None);
159        let returns = create_returns(&[]);
160        let result = ratio.calculate_from_returns(&returns);
161        assert!(result.is_some());
162        assert!(result.unwrap().is_nan());
163    }
164
165    #[rstest]
166    fn test_no_losses_is_nan() {
167        // No returns below the threshold leaves the ratio undefined.
168        let ratio = OmegaRatio::new(None);
169        let returns = create_returns(&[0.01, 0.02, 0.015]);
170        let result = ratio.calculate_from_returns(&returns);
171        assert!(result.is_some());
172        assert!(result.unwrap().is_nan());
173    }
174
175    #[rstest]
176    fn test_omega_ratio_calculation() {
177        // Gains above 0: 0.01 + 0.015 + 0.025 = 0.05.
178        // Losses below 0: 0.02 + 0.005 = 0.025.
179        // Omega(0) = 0.05 / 0.025 = 2.0.
180        let ratio = OmegaRatio::new(Some(0.0));
181        let returns = create_returns(&[0.01, -0.02, 0.015, -0.005, 0.025]);
182        let result = ratio.calculate_from_returns(&returns).unwrap();
183        assert!(approx_eq!(f64, result, 2.0, epsilon = 1e-12));
184    }
185
186    #[rstest]
187    #[case(Some(f64::NAN))]
188    #[case(Some(f64::INFINITY))]
189    #[case(Some(f64::NEG_INFINITY))]
190    fn test_new_checked_rejects_non_finite_threshold(#[case] threshold: Option<f64>) {
191        assert!(OmegaRatio::new_checked(threshold).is_err());
192    }
193
194    #[rstest]
195    #[case(None)]
196    #[case(Some(0.0))]
197    #[case(Some(-0.02))]
198    #[case(Some(0.5))]
199    fn test_new_checked_accepts_finite_threshold(#[case] threshold: Option<f64>) {
200        assert!(OmegaRatio::new_checked(threshold).is_ok());
201    }
202}