Skip to main content

nautilus_analysis/statistics/
expected_shortfall.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//! Expected Shortfall (Conditional Value at Risk) 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, statistics::value_at_risk::percentile_linear};
24
25/// Calculates the historical Expected Shortfall (Conditional Value at Risk) of
26/// portfolio returns.
27///
28/// Expected Shortfall is the average of the losses that occur beyond the
29/// [`ValueAtRisk`](crate::statistics::value_at_risk::ValueAtRisk) threshold at a
30/// given confidence level - the mean of the worst
31/// `1 - confidence` tail of the return distribution. It is a coherent risk
32/// measure and captures tail severity that `VaR` alone does not.
33///
34/// `ES(c) = mean( r | r <= VaR(c) )`
35///
36/// `confidence` defaults to `0.95`. The result is expressed as a return (e.g.
37/// `-0.05` is a 5% expected tail loss); it is always less than or equal to the
38/// corresponding `VaR`. Returns `NaN` for an empty series.
39///
40/// # References
41///
42/// - Acerbi, C., & Tasche, D. (2002). "Expected Shortfall: A Natural Coherent Alternative
43///   to Value at Risk". *Economic Notes*, 31(2), 379-388.
44/// - Rockafellar, R. T., & Uryasev, S. (2000). "Optimization of Conditional Value-at-Risk".
45///   *Journal of Risk*, 2(3), 21-41.
46#[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    /// The confidence level `c` in `(0, 1)` (default: 0.95).
58    confidence: f64,
59}
60
61impl ExpectedShortfall {
62    /// Creates a new checked [`ExpectedShortfall`] instance.
63    ///
64    /// # Errors
65    ///
66    /// Returns an error if `confidence` is not finite and in the range `(0, 1)`.
67    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    /// Creates a new [`ExpectedShortfall`] instance.
77    ///
78    /// # Panics
79    ///
80    /// Panics if `confidence` is not finite and in the range `(0, 1)`.
81    #[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        // Downsample and sort once; both the VaR threshold and the tail it bounds
106        // are taken from this same daily-binned, value-sorted sample, keeping them
107        // consistent by construction and avoiding a second downsample + sort.
108        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        // The tail is the sorted prefix of returns at or below the VaR threshold.
119        // A historical quantile always satisfies `var >= values[0]`, so at least
120        // the minimum bin qualifies and the tail is non-empty.
121        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        // VaR(0.95) = -0.091 (see value_at_risk tests); the only return at or below
178        // that threshold is -0.10, so ES = mean([-0.10]) = -0.10.
179        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        // ES is always <= VaR (it averages the tail beyond the threshold).
190        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        // At confidence 0.60 the VaR threshold is -0.024, so four returns
205        // (-0.10, -0.08, -0.05, -0.03) fall at or below it and ES averages all
206        // four: mean = -0.26 / 4 = -0.065. Exercises the multi-element tail mean
207        // (the other tests each produce a single-element tail).
208        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}