nautilus_analysis/statistics/
expected_shortfall.rs1use std::fmt::Display;
19
20use nautilus_core::correctness::check_predicate_true;
21use nautilus_model::position::Position;
22
23use crate::{Returns, statistic::PortfolioStatistic, statistics::value_at_risk::percentile_linear};
24
25#[repr(C)]
47#[derive(Debug, Clone)]
48#[cfg_attr(
49 feature = "python",
50 pyo3::pyclass(module = "nautilus_trader.analysis", from_py_object)
51)]
52#[cfg_attr(
53 feature = "python",
54 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.analysis")
55)]
56pub struct ExpectedShortfall {
57 confidence: f64,
59}
60
61impl ExpectedShortfall {
62 pub fn new_checked(confidence: Option<f64>) -> anyhow::Result<Self> {
68 let confidence = confidence.unwrap_or(0.95);
69 check_predicate_true(
70 confidence.is_finite() && confidence > 0.0 && confidence < 1.0,
71 "confidence must be finite and in the range (0, 1)",
72 )?;
73 Ok(Self { confidence })
74 }
75
76 #[must_use]
82 pub fn new(confidence: Option<f64>) -> Self {
83 Self::new_checked(confidence).expect("Invalid `confidence` for `ExpectedShortfall`")
84 }
85}
86
87impl Display for ExpectedShortfall {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 write!(f, "Expected Shortfall (confidence {})", self.confidence)
90 }
91}
92
93impl PortfolioStatistic for ExpectedShortfall {
94 type Item = f64;
95
96 fn name(&self) -> String {
97 self.to_string()
98 }
99
100 fn calculate_from_returns(&self, raw_returns: &Returns) -> Option<Self::Item> {
101 if !self.check_valid_returns(raw_returns) {
102 return Some(f64::NAN);
103 }
104
105 let returns = self.downsample_to_daily_bins(raw_returns);
109 let mut values: Vec<f64> = returns.values().copied().collect();
110 values.sort_by(f64::total_cmp);
111
112 let alpha = 1.0 - self.confidence;
113 let var = percentile_linear(&values, alpha * 100.0);
114 if var.is_nan() {
115 return Some(f64::NAN);
116 }
117
118 let cutoff = values.partition_point(|&r| r <= var);
122 let (sum, count) = values[..cutoff]
123 .iter()
124 .fold((0.0, 0_usize), |(sum, count), &r| (sum + r, count + 1));
125 Some(sum / count as f64)
126 }
127
128 fn calculate_from_realized_pnls(&self, _realized_pnls: &[f64]) -> Option<Self::Item> {
129 None
130 }
131
132 fn calculate_from_positions(&self, _positions: &[Position]) -> Option<Self::Item> {
133 None
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 use std::collections::BTreeMap;
140
141 use nautilus_core::{UnixNanos, approx_eq};
142 use rstest::rstest;
143
144 use super::*;
145 use crate::statistics::value_at_risk::ValueAtRisk;
146
147 fn create_returns(values: &[f64]) -> BTreeMap<UnixNanos, f64> {
148 let mut new_return = BTreeMap::new();
149 let one_day_in_nanos = 86_400_000_000_000;
150 let start_time = 1_600_000_000_000_000_000;
151
152 for (i, &value) in values.iter().enumerate() {
153 let timestamp = start_time + i as u64 * one_day_in_nanos;
154 new_return.insert(UnixNanos::from(timestamp), value);
155 }
156
157 new_return
158 }
159
160 #[rstest]
161 fn test_name() {
162 let es = ExpectedShortfall::new(None);
163 assert_eq!(es.name(), "Expected Shortfall (confidence 0.95)");
164 }
165
166 #[rstest]
167 fn test_empty_returns() {
168 let es = ExpectedShortfall::new(None);
169 let returns = create_returns(&[]);
170 let result = es.calculate_from_returns(&returns);
171 assert!(result.is_some());
172 assert!(result.unwrap().is_nan());
173 }
174
175 #[rstest]
176 fn test_expected_shortfall_calculation() {
177 let es = ExpectedShortfall::new(Some(0.95));
180 let returns = create_returns(&[
181 0.02, -0.05, 0.01, -0.08, 0.03, -0.02, 0.04, -0.10, 0.015, -0.03,
182 ]);
183 let result = es.calculate_from_returns(&returns).unwrap();
184 assert!(approx_eq!(f64, result, -0.10, epsilon = 1e-12));
185 }
186
187 #[rstest]
188 fn test_expected_shortfall_at_most_value_at_risk() {
189 let returns = create_returns(&[
191 0.02, -0.05, 0.01, -0.08, 0.03, -0.02, 0.04, -0.10, 0.015, -0.03,
192 ]);
193 let var = ValueAtRisk::new(Some(0.90))
194 .calculate_from_returns(&returns)
195 .unwrap();
196 let es = ExpectedShortfall::new(Some(0.90))
197 .calculate_from_returns(&returns)
198 .unwrap();
199 assert!(es <= var);
200 }
201
202 #[rstest]
203 fn test_expected_shortfall_averages_multi_element_tail() {
204 let es = ExpectedShortfall::new(Some(0.60));
209 let returns = create_returns(&[
210 0.02, -0.05, 0.01, -0.08, 0.03, -0.02, 0.04, -0.10, 0.015, -0.03,
211 ]);
212 let result = es.calculate_from_returns(&returns).unwrap();
213 assert!(approx_eq!(f64, result, -0.065, epsilon = 1e-12));
214 }
215
216 #[rstest]
217 #[case(Some(0.0))]
218 #[case(Some(1.0))]
219 #[case(Some(1.5))]
220 #[case(Some(-0.5))]
221 #[case(Some(f64::NAN))]
222 #[case(Some(f64::INFINITY))]
223 fn test_new_checked_rejects_invalid_confidence(#[case] confidence: Option<f64>) {
224 assert!(ExpectedShortfall::new_checked(confidence).is_err());
225 }
226
227 #[rstest]
228 #[case(None)]
229 #[case(Some(0.5))]
230 #[case(Some(0.99))]
231 fn test_new_checked_accepts_valid_confidence(#[case] confidence: Option<f64>) {
232 assert!(ExpectedShortfall::new_checked(confidence).is_ok());
233 }
234}