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