Skip to main content

nautilus_analysis/statistics/
sortino_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
16use std::fmt::Display;
17
18use nautilus_model::position::Position;
19
20use crate::{Returns, statistic::PortfolioStatistic};
21
22/// Calculates the Sortino ratio for portfolio returns.
23///
24/// The Sortino ratio is a variation of the Sharpe ratio that only penalizes downside
25/// volatility, making it more appropriate for strategies with asymmetric return distributions.
26///
27/// Formula: `Mean Return / Downside Deviation * sqrt(period)`
28///
29/// Where downside deviation is calculated as:
30/// `sqrt(sum(negative_returns^2) / total_observations)`
31///
32/// Note: Uses total observations count (not just negative returns) as per Sortino's methodology.
33///
34/// # References
35///
36/// - Sortino, F. A., & van der Meer, R. (1991). "Downside Risk". *Journal of Portfolio Management*, 17(4), 27-31.
37/// - Sortino, F. A., & Price, L. N. (1994). "Performance Measurement in a Downside Risk Framework".
38///   *Journal of Investing*, 3(3), 59-64.
39#[repr(C)]
40#[derive(Debug, Clone)]
41#[cfg_attr(
42    feature = "python",
43    pyo3::pyclass(module = "nautilus_trader.analysis", from_py_object)
44)]
45#[cfg_attr(
46    feature = "python",
47    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.analysis")
48)]
49pub struct SortinoRatio {
50    period: usize,
51}
52
53impl SortinoRatio {
54    /// Creates a new [`SortinoRatio`] instance.
55    #[must_use]
56    pub fn new(period: Option<usize>) -> Self {
57        Self {
58            period: period.unwrap_or(252),
59        }
60    }
61}
62
63impl Display for SortinoRatio {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        write!(f, "Sortino Ratio ({} days)", self.period)
66    }
67}
68
69impl PortfolioStatistic for SortinoRatio {
70    type Item = f64;
71
72    fn name(&self) -> String {
73        self.to_string()
74    }
75
76    fn calculate_from_returns(&self, raw_returns: &Returns) -> Option<Self::Item> {
77        if !self.check_valid_returns(raw_returns) {
78            return Some(f64::NAN);
79        }
80
81        let returns = self.downsample_to_daily_bins(raw_returns);
82
83        // Match `calculate_std`: a single observation cannot estimate dispersion
84        if returns.len() < 2 {
85            return Some(f64::NAN);
86        }
87
88        let total_n = returns.len() as f64;
89        let mean = returns.values().sum::<f64>() / total_n;
90
91        let downside = (returns
92            .values()
93            .filter(|&&x| x < 0.0)
94            .map(|x| x.powi(2))
95            .sum::<f64>()
96            / total_n)
97            .sqrt();
98
99        if downside < f64::EPSILON {
100            return Some(f64::NAN);
101        }
102
103        let annualized_ratio = (mean / downside) * (self.period as f64).sqrt();
104
105        Some(annualized_ratio)
106    }
107    fn calculate_from_realized_pnls(&self, _realized_pnls: &[f64]) -> Option<Self::Item> {
108        None
109    }
110
111    fn calculate_from_positions(&self, _positions: &[Position]) -> Option<Self::Item> {
112        None
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use std::collections::BTreeMap;
119
120    use nautilus_core::{UnixNanos, approx_eq};
121    use rstest::rstest;
122
123    use super::*;
124
125    fn create_returns(values: &[f64]) -> BTreeMap<UnixNanos, f64> {
126        let mut new_return = BTreeMap::new();
127        let one_day_in_nanos = 86_400_000_000_000;
128        let start_time = 1_600_000_000_000_000_000;
129
130        for (i, &value) in values.iter().enumerate() {
131            let timestamp = start_time + i as u64 * one_day_in_nanos;
132            new_return.insert(UnixNanos::from(timestamp), value);
133        }
134
135        new_return
136    }
137
138    #[rstest]
139    fn test_empty_returns() {
140        let ratio = SortinoRatio::new(None);
141        let returns = create_returns(&[]);
142        let result = ratio.calculate_from_returns(&returns);
143        assert!(result.is_some());
144        assert!(result.unwrap().is_nan());
145    }
146
147    #[rstest]
148    fn test_zero_downside_deviation() {
149        let ratio = SortinoRatio::new(None);
150        let returns = create_returns(&[0.02, 0.03, 0.01]);
151        let result = ratio.calculate_from_returns(&returns);
152        assert!(result.is_some());
153        assert!(result.unwrap().is_nan());
154    }
155
156    #[rstest]
157    #[case(-0.02)]
158    #[case(0.02)]
159    fn test_single_observation_returns_nan(#[case] value: f64) {
160        let ratio = SortinoRatio::new(None);
161        let returns = create_returns(&[value]);
162        let result = ratio.calculate_from_returns(&returns);
163        assert!(result.is_some());
164        assert!(result.unwrap().is_nan());
165    }
166
167    #[rstest]
168    fn test_valid_sortino_ratio() {
169        let ratio = SortinoRatio::new(Some(252));
170        let returns = create_returns(&[-0.01, 0.02, -0.015, 0.005, -0.02]);
171        let result = ratio.calculate_from_returns(&returns);
172        assert!(result.is_some());
173        assert!(approx_eq!(
174            f64,
175            result.unwrap(),
176            -5.273224492824493,
177            epsilon = 1e-9
178        ));
179    }
180
181    #[rstest]
182    fn test_name() {
183        let ratio = SortinoRatio::new(None);
184        assert_eq!(ratio.name(), "Sortino Ratio (252 days)");
185    }
186}