nautilus_analysis/statistics/
sortino_ratio.rs1use std::fmt::Display;
17
18use nautilus_model::position::Position;
19
20use crate::{Returns, statistic::PortfolioStatistic};
21
22#[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 #[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 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}