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.core.nautilus_pyo3.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 let total_n = returns.len() as f64;
83 let mean = returns.values().sum::<f64>() / total_n;
84
85 let downside = (returns
86 .values()
87 .filter(|&&x| x < 0.0)
88 .map(|x| x.powi(2))
89 .sum::<f64>()
90 / total_n)
91 .sqrt();
92
93 if downside < f64::EPSILON {
94 return Some(f64::NAN);
95 }
96
97 let annualized_ratio = (mean / downside) * (self.period as f64).sqrt();
98
99 Some(annualized_ratio)
100 }
101 fn calculate_from_realized_pnls(&self, _realized_pnls: &[f64]) -> Option<Self::Item> {
102 None
103 }
104
105 fn calculate_from_positions(&self, _positions: &[Position]) -> Option<Self::Item> {
106 None
107 }
108}
109
110#[cfg(test)]
111mod tests {
112 use std::collections::BTreeMap;
113
114 use nautilus_core::{UnixNanos, approx_eq};
115 use rstest::rstest;
116
117 use super::*;
118
119 fn create_returns(values: &[f64]) -> BTreeMap<UnixNanos, f64> {
120 let mut new_return = BTreeMap::new();
121 let one_day_in_nanos = 86_400_000_000_000;
122 let start_time = 1_600_000_000_000_000_000;
123
124 for (i, &value) in values.iter().enumerate() {
125 let timestamp = start_time + i as u64 * one_day_in_nanos;
126 new_return.insert(UnixNanos::from(timestamp), value);
127 }
128
129 new_return
130 }
131
132 #[rstest]
133 fn test_empty_returns() {
134 let ratio = SortinoRatio::new(None);
135 let returns = create_returns(&[]);
136 let result = ratio.calculate_from_returns(&returns);
137 assert!(result.is_some());
138 assert!(result.unwrap().is_nan());
139 }
140
141 #[rstest]
142 fn test_zero_downside_deviation() {
143 let ratio = SortinoRatio::new(None);
144 let returns = create_returns(&[0.02, 0.03, 0.01]);
145 let result = ratio.calculate_from_returns(&returns);
146 assert!(result.is_some());
147 assert!(result.unwrap().is_nan());
148 }
149
150 #[rstest]
151 fn test_valid_sortino_ratio() {
152 let ratio = SortinoRatio::new(Some(252));
153 let returns = create_returns(&[-0.01, 0.02, -0.015, 0.005, -0.02]);
154 let result = ratio.calculate_from_returns(&returns);
155 assert!(result.is_some());
156 assert!(approx_eq!(
157 f64,
158 result.unwrap(),
159 -5.273224492824493,
160 epsilon = 1e-9
161 ));
162 }
163
164 #[rstest]
165 fn test_name() {
166 let ratio = SortinoRatio::new(None);
167 assert_eq!(ratio.name(), "Sortino Ratio (252 days)");
168 }
169}