Skip to main content

wickra_core/indicators/
value_at_risk.rs

1//! Rolling historical Value-at-Risk (`VaR`).
2
3use std::collections::VecDeque;
4
5use crate::error::{Error, Result};
6use crate::traits::Indicator;
7
8/// Rolling historical Value-at-Risk.
9///
10/// Input is treated as a period return. Over the trailing window of `period`
11/// returns the indicator reports the empirical lower-tail quantile at the
12/// given `confidence` level (e.g. `0.95` = the 95 %-confident worst-case
13/// loss). The output is the **magnitude** of that loss, sign-flipped to be a
14/// non-negative number (so a 5 % `VaR` is reported as `0.05`, not `-0.05`):
15///
16/// ```text
17/// q       = (1 − confidence)
18/// VaR_t   = − percentile(returns over window, q · 100)   if it is negative
19/// VaR_t   = 0                                            otherwise
20/// ```
21///
22/// `percentile` uses linear interpolation between the two closest order
23/// statistics ("type 7" in R / `NumPy` default). If the q-quantile of the
24/// window is itself non-negative (a window where every return was at or above
25/// zero) the indicator returns `0.0` — there is no loss to report.
26///
27/// Each `update` is O(period · log period) due to the window-sort. Good
28/// enough for the typical `period ≤ 252` rolling-VaR workflow.
29///
30/// # Example
31///
32/// ```
33/// use wickra_core::{Indicator, ValueAtRisk};
34///
35/// let mut var = ValueAtRisk::new(100, 0.95).unwrap();
36/// let mut last = None;
37/// for i in 0..120 {
38///     last = var.update((f64::from(i) * 0.1).sin() * 0.02);
39/// }
40/// assert!(last.is_some());
41/// ```
42#[derive(Debug, Clone)]
43pub struct ValueAtRisk {
44    period: usize,
45    confidence: f64,
46    window: VecDeque<f64>,
47}
48
49impl ValueAtRisk {
50    /// Construct a new rolling historical `VaR`.
51    ///
52    /// # Errors
53    /// Returns [`Error::InvalidPeriod`] if `period < 2`, or if
54    /// `confidence` is outside the open interval `(0, 1)`.
55    pub fn new(period: usize, confidence: f64) -> Result<Self> {
56        if period < 2 {
57            return Err(Error::InvalidPeriod {
58                message: "value-at-risk needs period >= 2",
59            });
60        }
61        if !confidence.is_finite() || confidence <= 0.0 || confidence >= 1.0 {
62            return Err(Error::InvalidPeriod {
63                message: "confidence must lie strictly between 0 and 1",
64            });
65        }
66        Ok(Self {
67            period,
68            confidence,
69            window: VecDeque::with_capacity(period),
70        })
71    }
72
73    /// Configured window length.
74    pub const fn period(&self) -> usize {
75        self.period
76    }
77
78    /// Configured confidence level.
79    pub const fn confidence(&self) -> f64 {
80        self.confidence
81    }
82}
83
84/// Linear-interpolated percentile (type 7 / `NumPy` default) on a sorted slice.
85fn percentile_sorted(sorted: &[f64], q: f64) -> f64 {
86    let n = sorted.len();
87    let pos = q * (n - 1) as f64;
88    let lo = pos.floor() as usize;
89    let hi = pos.ceil() as usize;
90    if lo == hi {
91        sorted[lo]
92    } else {
93        let frac = pos - lo as f64;
94        sorted[lo] + (sorted[hi] - sorted[lo]) * frac
95    }
96}
97
98impl Indicator for ValueAtRisk {
99    type Input = f64;
100    type Output = f64;
101
102    fn update(&mut self, input: f64) -> Option<f64> {
103        if !input.is_finite() {
104            return None;
105        }
106        if self.window.len() == self.period {
107            self.window.pop_front();
108        }
109        self.window.push_back(input);
110        if self.window.len() < self.period {
111            return None;
112        }
113        let mut sorted: Vec<f64> = self.window.iter().copied().collect();
114        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
115        let q = 1.0 - self.confidence;
116        let cut = percentile_sorted(&sorted, q);
117        // Loss magnitude (sign-flipped); 0 if quantile is non-negative.
118        Some((-cut).max(0.0))
119    }
120
121    fn reset(&mut self) {
122        self.window.clear();
123    }
124
125    fn warmup_period(&self) -> usize {
126        self.period
127    }
128
129    fn is_ready(&self) -> bool {
130        self.window.len() == self.period
131    }
132
133    fn name(&self) -> &'static str {
134        "ValueAtRisk"
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::traits::BatchExt;
142    use approx::assert_relative_eq;
143
144    #[test]
145    fn rejects_invalid_params() {
146        assert!(matches!(
147            ValueAtRisk::new(1, 0.95),
148            Err(Error::InvalidPeriod { .. })
149        ));
150        assert!(matches!(
151            ValueAtRisk::new(20, 0.0),
152            Err(Error::InvalidPeriod { .. })
153        ));
154        assert!(matches!(
155            ValueAtRisk::new(20, 1.0),
156            Err(Error::InvalidPeriod { .. })
157        ));
158        assert!(matches!(
159            ValueAtRisk::new(20, f64::NAN),
160            Err(Error::InvalidPeriod { .. })
161        ));
162    }
163
164    #[test]
165    fn accessors_and_metadata() {
166        let v = ValueAtRisk::new(100, 0.95).unwrap();
167        assert_eq!(v.period(), 100);
168        assert_relative_eq!(v.confidence(), 0.95, epsilon = 1e-12);
169        assert_eq!(v.name(), "ValueAtRisk");
170        assert_eq!(v.warmup_period(), 100);
171    }
172
173    #[test]
174    fn reference_value() {
175        // returns = -5,-4,-3,-2,-1,0,1,2,3,4 (each *0.01), confidence 0.95.
176        // q = 0.05, sorted positions 0..9, pos = 0.05*9 = 0.45,
177        // -> -0.05 + (-0.04 - (-0.05))*0.45 = -0.05 + 0.0045 = -0.0455.
178        // VaR = 0.0455.
179        let mut v = ValueAtRisk::new(10, 0.95).unwrap();
180        let returns: Vec<f64> = (-5..5).map(|i| f64::from(i) * 0.01).collect();
181        let out = v.batch(&returns);
182        assert_relative_eq!(out[9].unwrap(), 0.0455, epsilon = 1e-9);
183    }
184
185    #[test]
186    fn all_positive_returns_yield_zero() {
187        let mut v = ValueAtRisk::new(5, 0.95).unwrap();
188        let out = v.batch(&[0.01, 0.02, 0.03, 0.04, 0.05]);
189        assert_eq!(out[4], Some(0.0));
190    }
191
192    #[test]
193    fn ignores_non_finite_input() {
194        let mut v = ValueAtRisk::new(3, 0.95).unwrap();
195        assert_eq!(v.update(f64::NAN), None);
196        assert_eq!(v.update(f64::INFINITY), None);
197    }
198
199    #[test]
200    fn reset_clears_state() {
201        let mut v = ValueAtRisk::new(3, 0.95).unwrap();
202        v.batch(&[-0.01, -0.02, -0.03]);
203        assert!(v.is_ready());
204        v.reset();
205        assert!(!v.is_ready());
206        assert_eq!(v.update(0.01), None);
207    }
208
209    #[test]
210    fn batch_equals_streaming() {
211        let returns: Vec<f64> = (0..50).map(|i| (f64::from(i) * 0.2).sin() * 0.02).collect();
212        let batch = ValueAtRisk::new(10, 0.95).unwrap().batch(&returns);
213        let mut s = ValueAtRisk::new(10, 0.95).unwrap();
214        let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect();
215        assert_eq!(batch, streamed);
216    }
217
218    #[test]
219    fn integer_position_quantile_branch() {
220        // period=5, confidence=0.75 -> q=0.25, n-1=4 -> pos=1.0 (integer),
221        // so the percentile helper takes the `lo == hi` branch.
222        let mut v = ValueAtRisk::new(5, 0.75).unwrap();
223        let out = v.batch(&[-0.05, -0.04, -0.03, -0.02, -0.01]);
224        // sorted = same order; sorted[1] = -0.04, so VaR = 0.04 exactly.
225        assert_relative_eq!(out[4].unwrap(), 0.04, epsilon = 1e-12);
226    }
227}