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    /// Reusable scratch buffer to avoid allocating per `update`.
48    scratch: Vec<f64>,
49}
50
51impl ValueAtRisk {
52    /// Construct a new rolling historical `VaR`.
53    ///
54    /// # Errors
55    /// Returns [`Error::InvalidPeriod`] if `period < 2`, or if
56    /// `confidence` is outside the open interval `(0, 1)`.
57    pub fn new(period: usize, confidence: f64) -> Result<Self> {
58        if period < 2 {
59            return Err(Error::InvalidPeriod {
60                message: "value-at-risk needs period >= 2",
61            });
62        }
63        if period > crate::error::MAX_PERIOD {
64            return Err(Error::InvalidPeriod {
65                message: crate::error::PERIOD_ABOVE_MAX,
66            });
67        }
68        if !confidence.is_finite() || confidence <= 0.0 || confidence >= 1.0 {
69            return Err(Error::InvalidPeriod {
70                message: "confidence must lie strictly between 0 and 1",
71            });
72        }
73        Ok(Self {
74            period,
75            confidence,
76            window: VecDeque::with_capacity(period),
77            scratch: Vec::with_capacity(period),
78        })
79    }
80
81    /// Configured window length.
82    pub const fn period(&self) -> usize {
83        self.period
84    }
85
86    /// Configured confidence level.
87    pub const fn confidence(&self) -> f64 {
88        self.confidence
89    }
90}
91
92/// Linear-interpolated percentile (type 7 / `NumPy` default) on a sorted slice.
93fn percentile_sorted(sorted: &[f64], q: f64) -> f64 {
94    let n = sorted.len();
95    let pos = q * (n - 1) as f64;
96    let lo = pos.floor() as usize;
97    let hi = pos.ceil() as usize;
98    if lo == hi {
99        sorted[lo]
100    } else {
101        let frac = pos - lo as f64;
102        sorted[lo] + (sorted[hi] - sorted[lo]) * frac
103    }
104}
105
106impl Indicator for ValueAtRisk {
107    type Input = f64;
108    type Output = f64;
109
110    #[inline]
111    fn update(&mut self, input: f64) -> Option<f64> {
112        if !input.is_finite() {
113            return None;
114        }
115        if self.window.len() == self.period {
116            self.window.pop_front();
117        }
118        self.window.push_back(input);
119        if self.window.len() < self.period {
120            return None;
121        }
122        self.scratch.clear();
123        self.scratch.extend(self.window.iter().copied());
124        self.scratch.sort_unstable_by(f64::total_cmp);
125        let q = 1.0 - self.confidence;
126        let cut = percentile_sorted(&self.scratch, q);
127        // Loss magnitude (sign-flipped); 0 if quantile is non-negative.
128        Some((-cut).max(0.0))
129    }
130
131    fn reset(&mut self) {
132        self.window.clear();
133        self.scratch.clear();
134    }
135
136    #[inline]
137    fn warmup_period(&self) -> usize {
138        self.period
139    }
140
141    #[inline]
142    fn is_ready(&self) -> bool {
143        self.window.len() == self.period
144    }
145
146    #[inline]
147    fn name(&self) -> &'static str {
148        "ValueAtRisk"
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::traits::BatchExt;
156    use approx::assert_relative_eq;
157
158    #[test]
159    fn rejects_invalid_params() {
160        assert!(matches!(
161            ValueAtRisk::new(1, 0.95),
162            Err(Error::InvalidPeriod { .. })
163        ));
164        assert!(matches!(
165            ValueAtRisk::new(20, 0.0),
166            Err(Error::InvalidPeriod { .. })
167        ));
168        assert!(matches!(
169            ValueAtRisk::new(20, 1.0),
170            Err(Error::InvalidPeriod { .. })
171        ));
172        assert!(matches!(
173            ValueAtRisk::new(20, f64::NAN),
174            Err(Error::InvalidPeriod { .. })
175        ));
176    }
177
178    #[test]
179    fn accessors_and_metadata() {
180        let v = ValueAtRisk::new(100, 0.95).unwrap();
181        assert_eq!(v.period(), 100);
182        assert_relative_eq!(v.confidence(), 0.95, epsilon = 1e-12);
183        assert_eq!(v.name(), "ValueAtRisk");
184        assert_eq!(v.warmup_period(), 100);
185    }
186
187    #[test]
188    fn reference_value() {
189        // returns = -5,-4,-3,-2,-1,0,1,2,3,4 (each *0.01), confidence 0.95.
190        // q = 0.05, sorted positions 0..9, pos = 0.05*9 = 0.45,
191        // -> -0.05 + (-0.04 - (-0.05))*0.45 = -0.05 + 0.0045 = -0.0455.
192        // VaR = 0.0455.
193        let mut v = ValueAtRisk::new(10, 0.95).unwrap();
194        let returns: Vec<f64> = (-5..5).map(|i| f64::from(i) * 0.01).collect();
195        let out = v.batch(&returns);
196        assert_relative_eq!(out[9].unwrap(), 0.0455, epsilon = 1e-9);
197    }
198
199    #[test]
200    fn all_positive_returns_yield_zero() {
201        let mut v = ValueAtRisk::new(5, 0.95).unwrap();
202        let out = v.batch(&[0.01, 0.02, 0.03, 0.04, 0.05]);
203        assert_eq!(out[4], Some(0.0));
204    }
205
206    #[test]
207    fn ignores_non_finite_input() {
208        let mut v = ValueAtRisk::new(3, 0.95).unwrap();
209        assert_eq!(v.update(f64::NAN), None);
210        assert_eq!(v.update(f64::INFINITY), None);
211    }
212
213    #[test]
214    fn reset_clears_state() {
215        let mut v = ValueAtRisk::new(3, 0.95).unwrap();
216        v.batch(&[-0.01, -0.02, -0.03]);
217        assert!(v.is_ready());
218        v.reset();
219        assert!(!v.is_ready());
220        assert_eq!(v.update(0.01), None);
221    }
222
223    #[test]
224    fn batch_equals_streaming() {
225        let returns: Vec<f64> = (0..50).map(|i| (f64::from(i) * 0.2).sin() * 0.02).collect();
226        let batch = ValueAtRisk::new(10, 0.95).unwrap().batch(&returns);
227        let mut s = ValueAtRisk::new(10, 0.95).unwrap();
228        let streamed: Vec<_> = returns.iter().map(|r| s.update(*r)).collect();
229        assert_eq!(batch, streamed);
230    }
231
232    #[test]
233    fn integer_position_quantile_branch() {
234        // period=5, confidence=0.75 -> q=0.25, n-1=4 -> pos=1.0 (integer),
235        // so the percentile helper takes the `lo == hi` branch.
236        let mut v = ValueAtRisk::new(5, 0.75).unwrap();
237        let out = v.batch(&[-0.05, -0.04, -0.03, -0.02, -0.01]);
238        // sorted = same order; sorted[1] = -0.04, so VaR = 0.04 exactly.
239        assert_relative_eq!(out[4].unwrap(), 0.04, epsilon = 1e-12);
240    }
241}