nautilus_analysis/statistics/
value_at_risk.rs1use std::fmt::Display;
19
20use nautilus_core::correctness::check_predicate_true;
21use nautilus_model::position::Position;
22
23use crate::{Returns, statistic::PortfolioStatistic};
24
25#[expect(
44 clippy::doc_markdown,
45 reason = "citation contains proper nouns with intra-word capitals"
46)]
47#[repr(C)]
48#[derive(Debug, Clone)]
49#[cfg_attr(
50 feature = "python",
51 pyo3::pyclass(module = "nautilus_trader.analysis", from_py_object)
52)]
53#[cfg_attr(
54 feature = "python",
55 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.analysis")
56)]
57pub struct ValueAtRisk {
58 confidence: f64,
60}
61
62impl ValueAtRisk {
63 pub fn new_checked(confidence: Option<f64>) -> anyhow::Result<Self> {
69 let confidence = confidence.unwrap_or(0.95);
70 check_predicate_true(
71 confidence.is_finite() && confidence > 0.0 && confidence < 1.0,
72 "confidence must be finite and in the range (0, 1)",
73 )?;
74 Ok(Self { confidence })
75 }
76
77 #[must_use]
83 pub fn new(confidence: Option<f64>) -> Self {
84 Self::new_checked(confidence).expect("Invalid `confidence` for `ValueAtRisk`")
85 }
86}
87
88impl Display for ValueAtRisk {
89 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 write!(f, "Value at Risk (confidence {})", self.confidence)
91 }
92}
93
94pub(crate) fn percentile_linear(sorted_values: &[f64], q: f64) -> f64 {
99 debug_assert!(
100 !sorted_values.is_empty(),
101 "percentile requires a non-empty slice"
102 );
103 let n = sorted_values.len();
104
105 let rank = (q / 100.0) * (n - 1) as f64;
106 let lower = rank.floor() as usize;
107 let upper = rank.ceil() as usize;
108 if lower == upper {
109 return sorted_values[lower];
110 }
111
112 let weight = rank - lower as f64;
113 (sorted_values[upper] - sorted_values[lower]).mul_add(weight, sorted_values[lower])
114}
115
116impl PortfolioStatistic for ValueAtRisk {
117 type Item = f64;
118
119 fn name(&self) -> String {
120 self.to_string()
121 }
122
123 fn calculate_from_returns(&self, raw_returns: &Returns) -> Option<Self::Item> {
124 if !self.check_valid_returns(raw_returns) {
125 return Some(f64::NAN);
126 }
127
128 let returns = self.downsample_to_daily_bins(raw_returns);
129 let mut values: Vec<f64> = returns.values().copied().collect();
130 values.sort_by(f64::total_cmp);
131
132 let alpha = 1.0 - self.confidence;
133 Some(percentile_linear(&values, alpha * 100.0))
134 }
135
136 fn calculate_from_realized_pnls(&self, _realized_pnls: &[f64]) -> Option<Self::Item> {
137 None
138 }
139
140 fn calculate_from_positions(&self, _positions: &[Position]) -> Option<Self::Item> {
141 None
142 }
143}
144
145#[cfg(test)]
146mod tests {
147 use std::collections::BTreeMap;
148
149 use nautilus_core::{UnixNanos, approx_eq};
150 use rstest::rstest;
151
152 use super::*;
153
154 fn create_returns(values: &[f64]) -> BTreeMap<UnixNanos, f64> {
155 let mut new_return = BTreeMap::new();
156 let one_day_in_nanos = 86_400_000_000_000;
157 let start_time = 1_600_000_000_000_000_000;
158
159 for (i, &value) in values.iter().enumerate() {
160 let timestamp = start_time + i as u64 * one_day_in_nanos;
161 new_return.insert(UnixNanos::from(timestamp), value);
162 }
163
164 new_return
165 }
166
167 #[rstest]
168 fn test_name() {
169 let var = ValueAtRisk::new(None);
170 assert_eq!(var.name(), "Value at Risk (confidence 0.95)");
171 }
172
173 #[rstest]
174 fn test_empty_returns() {
175 let var = ValueAtRisk::new(None);
176 let returns = create_returns(&[]);
177 let result = var.calculate_from_returns(&returns);
178 assert!(result.is_some());
179 assert!(result.unwrap().is_nan());
180 }
181
182 #[rstest]
183 fn test_value_at_risk_calculation() {
184 let var = ValueAtRisk::new(Some(0.95));
188 let returns = create_returns(&[
189 0.02, -0.05, 0.01, -0.08, 0.03, -0.02, 0.04, -0.10, 0.015, -0.03,
190 ]);
191 let result = var.calculate_from_returns(&returns).unwrap();
192 assert!(approx_eq!(f64, result, -0.091, epsilon = 1e-12));
193 }
194
195 #[rstest]
196 #[case(Some(0.0))]
197 #[case(Some(1.0))]
198 #[case(Some(1.5))]
199 #[case(Some(-0.5))]
200 #[case(Some(f64::NAN))]
201 #[case(Some(f64::INFINITY))]
202 fn test_new_checked_rejects_invalid_confidence(#[case] confidence: Option<f64>) {
203 assert!(ValueAtRisk::new_checked(confidence).is_err());
204 }
205
206 #[rstest]
207 #[case(None)]
208 #[case(Some(0.5))]
209 #[case(Some(0.99))]
210 fn test_new_checked_accepts_valid_confidence(#[case] confidence: Option<f64>) {
211 assert!(ValueAtRisk::new_checked(confidence).is_ok());
212 }
213}