Skip to main content

prospicio_prob/
risk.rs

1//! Risk measures on sorted draws, and the exponential-utility measures
2//! (entropic, Esscher) on any draws.
3//!
4//! These are the single implementation every domain uses: reserving,
5//! aggregate and capital call them instead of computing their own
6//! quantiles (see `docs/design/predictive-distribution.md`).
7//!
8//! Both measures treat the draws as an empirical distribution with mass
9//! `1/n` on each draw, so ties and atoms are handled exactly.
10
11use prospicio_core::{Error, Result};
12
13use crate::distribution::check_probability;
14
15/// Value at risk: the smallest draw `x` with `P(X <= x) >= p`, the inverse
16/// of the empirical distribution function (`numpy.quantile(...,
17/// method="inverted_cdf")`, R `quantile(..., type = 1)`).
18///
19/// `p = 0` gives the smallest draw. `sorted` must be non-empty and sorted
20/// ascending; this is checked only in debug builds.
21///
22/// ```
23/// use prospicio_prob::risk::var_sorted;
24///
25/// let x = [1.0, 2.0, 3.0, 4.0];
26/// assert_eq!(var_sorted(&x, 0.5), Ok(2.0));
27/// assert_eq!(var_sorted(&x, 0.51), Ok(3.0));
28/// ```
29pub fn var_sorted(sorted: &[f64], p: f64) -> Result<f64> {
30    check_probability(p)?;
31    debug_assert!(is_sorted(sorted), "draws must be sorted ascending");
32    Ok(sorted[rank(sorted.len(), p) - 1])
33}
34
35/// Tail value at risk: the mean of the worst `1 - p` of the distribution,
36/// `(1 / (1 - p)) * integral from p to 1 of VaR(u) du`.
37///
38/// When `p * n` is not a whole number the draw at the VaR is counted with
39/// the fractional weight that falls above `p`, so the result is coherent
40/// and moves continuously with `p`. `p = 0` gives the mean and `p = 1` the
41/// largest draw. `sorted` must be non-empty and sorted ascending; this is
42/// checked only in debug builds.
43///
44/// ```
45/// use prospicio_prob::risk::tvar_sorted;
46///
47/// let x = [1.0, 2.0, 3.0, 4.0];
48/// assert_eq!(tvar_sorted(&x, 0.5), Ok(3.5));
49/// // Half of the draw 2 lies above p = 0.375: (0.5 * 2 + 3 + 4) / 2.5.
50/// assert_eq!(tvar_sorted(&x, 0.375), Ok(3.2));
51/// ```
52pub fn tvar_sorted(sorted: &[f64], p: f64) -> Result<f64> {
53    check_probability(p)?;
54    debug_assert!(is_sorted(sorted), "draws must be sorted ascending");
55    let n = sorted.len();
56    if p == 1.0 {
57        return Ok(sorted[n - 1]);
58    }
59    let k = rank(n, p);
60    let nf = n as f64;
61    // Mass of draw k (1-based) that lies above p, then every draw after it.
62    let partial = (k as f64 / nf - p) * sorted[k - 1];
63    let tail: f64 = sorted[k..].iter().sum::<f64>() / nf;
64    Ok((partial + tail) / (1.0 - p))
65}
66
67/// Entropic risk measure `(1/θ) log E[e^(θX)]` of equally likely draws
68/// (losses positive), the certainty equivalent under exponential utility
69/// with risk aversion `θ > 0`. It increases from the mean (`θ → 0`) to the
70/// largest draw (`θ → ∞`); for a normal `X` it is `μ + θσ²/2`. Convex and
71/// translation-invariant, but not positively homogeneous.
72///
73/// ```
74/// use prospicio_prob::risk::entropic;
75///
76/// // Two equally likely losses, 0 and 1, at θ = ln 2: log2((1 + 2)/2).
77/// let r = entropic(&[0.0, 1.0], 2f64.ln()).unwrap();
78/// assert!((r - 1.5f64.log2()).abs() < 1e-15);
79/// ```
80pub fn entropic(draws: &[f64], theta: f64) -> Result<f64> {
81    check_draws(draws)?;
82    if !(theta.is_finite() && theta > 0.0) {
83        return Err(Error::InvalidParameter {
84            name: "theta",
85            value: theta,
86            reason: "must be finite and positive",
87        });
88    }
89    let m = draws.iter().copied().fold(f64::NEG_INFINITY, f64::max);
90    let mean_exp = draws.iter().map(|x| (theta * (x - m)).exp()).sum::<f64>() / draws.len() as f64;
91    Ok(m + mean_exp.ln() / theta)
92}
93
94/// Esscher premium `E[X e^(hX)] / E[e^(hX)]` of equally likely draws: the
95/// mean under the Esscher transform, which tilts probability towards large
96/// losses for `h > 0` (the mean at `h = 0`; `μ + hσ²` for a normal `X`).
97///
98/// ```
99/// use prospicio_prob::risk::esscher;
100///
101/// // Draws 0 and 1 at h = ln 3: weights 1 and 3, so 3/4.
102/// assert!((esscher(&[0.0, 1.0], 3f64.ln()).unwrap() - 0.75).abs() < 1e-15);
103/// ```
104pub fn esscher(draws: &[f64], h: f64) -> Result<f64> {
105    check_draws(draws)?;
106    if !h.is_finite() {
107        return Err(Error::InvalidParameter {
108            name: "h",
109            value: h,
110            reason: "must be finite",
111        });
112    }
113    let w = esscher_weights(draws, h);
114    Ok(w.iter().zip(draws).map(|(w, x)| w * x).sum())
115}
116
117/// Normalized Esscher weights `e^(h xᵢ) / Σ e^(h xⱼ)`, without overflow.
118pub(crate) fn esscher_weights(draws: &[f64], h: f64) -> Vec<f64> {
119    let m = draws
120        .iter()
121        .map(|x| h * x)
122        .fold(f64::NEG_INFINITY, f64::max);
123    let w: Vec<f64> = draws.iter().map(|x| (h * x - m).exp()).collect();
124    let total: f64 = w.iter().sum();
125    w.into_iter().map(|v| v / total).collect()
126}
127
128fn check_draws(draws: &[f64]) -> Result<()> {
129    if draws.is_empty() {
130        return Err(Error::InvalidParameter {
131            name: "draws",
132            value: 0.0,
133            reason: "must not be empty",
134        });
135    }
136    Ok(())
137}
138
139/// Smallest `k` in `1..=n` with `k / n >= p`, using the same division as
140/// the empirical distribution function so the two agree exactly.
141fn rank(n: usize, p: f64) -> usize {
142    let nf = n as f64;
143    let mut k = ((p * nf).ceil() as usize).clamp(1, n);
144    while k > 1 && (k - 1) as f64 / nf >= p {
145        k -= 1;
146    }
147    while k < n && (k as f64 / nf) < p {
148        k += 1;
149    }
150    k
151}
152
153fn is_sorted(x: &[f64]) -> bool {
154    x.windows(2).all(|w| w[0] <= w[1])
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    /// Normal draws at plotting positions: entropic `μ + θσ²/2`, Esscher
162    /// `μ + hσ²`.
163    #[test]
164    fn exponential_utility_measures_match_the_normal() {
165        let n = 200_000;
166        let (mu, sigma) = (3.0, 2.0);
167        let x: Vec<f64> = (0..n)
168            .map(|i| {
169                mu + sigma * prospicio_math::special::norm_quantile((i as f64 + 0.5) / n as f64)
170            })
171            .collect();
172        let theta = 0.4;
173        assert!((entropic(&x, theta).unwrap() - (mu + theta * sigma * sigma / 2.0)).abs() < 2e-3);
174        let h = 0.3;
175        assert!((esscher(&x, h).unwrap() - (mu + h * sigma * sigma)).abs() < 2e-3);
176        assert!((esscher(&x, 0.0).unwrap() - mu).abs() < 1e-9);
177        // Huge draws do not overflow.
178        assert!((entropic(&[1e6, 1e6], 1.0).unwrap() - 1e6).abs() < 1e-6);
179        assert!(entropic(&x, 0.0).is_err() && esscher(&[], 1.0).is_err());
180    }
181    use prospicio_core::Error;
182
183    const X: [f64; 5] = [10.0, 20.0, 30.0, 40.0, 50.0];
184
185    #[test]
186    fn var_steps_at_multiples_of_one_over_n() {
187        assert_eq!(var_sorted(&X, 0.0), Ok(10.0));
188        assert_eq!(var_sorted(&X, 0.2), Ok(10.0));
189        assert_eq!(var_sorted(&X, 0.2000001), Ok(20.0));
190        assert_eq!(var_sorted(&X, 0.6), Ok(30.0));
191        assert_eq!(var_sorted(&X, 1.0), Ok(50.0));
192    }
193
194    #[test]
195    fn rank_is_exact_where_p_times_n_rounds_up() {
196        // 0.95 * 20 = 19.000000000000004 in floating point.
197        assert_eq!(rank(20, 0.95), 19);
198        // 0.7 * 10 = 7.000000000000001.
199        assert_eq!(rank(10, 0.7), 7);
200        assert_eq!(rank(3, 1.0 / 3.0), 1);
201    }
202
203    fn assert_close(got: f64, want: f64) {
204        assert!((got - want).abs() <= 1e-12 * want.abs(), "{got} != {want}");
205    }
206
207    #[test]
208    fn tvar_edges_and_tail_means() {
209        assert_close(tvar_sorted(&X, 0.0).unwrap(), 30.0);
210        assert_close(tvar_sorted(&X, 0.6).unwrap(), 45.0);
211        assert_close(tvar_sorted(&X, 0.8).unwrap(), 50.0);
212        assert_eq!(tvar_sorted(&X, 1.0), Ok(50.0));
213        // (0.1 * 40 + 0.2 * 50) / 0.3.
214        assert_close(tvar_sorted(&X, 0.7).unwrap(), 14.0 / 0.3);
215    }
216
217    #[test]
218    fn tvar_is_at_least_var_and_continuous() {
219        let mut prev = tvar_sorted(&X, 0.0).unwrap();
220        for i in 1..=1000 {
221            let p = i as f64 / 1000.0;
222            let t = tvar_sorted(&X, p).unwrap();
223            assert!(t >= var_sorted(&X, p).unwrap() - 1e-12);
224            assert!(t >= prev - 1e-12, "not monotone at {p}");
225            assert!(t - prev < 0.6, "jump at {p}");
226            prev = t;
227        }
228    }
229
230    #[test]
231    fn ties_count_as_one_atom() {
232        let x = [1.0, 5.0, 5.0, 5.0];
233        assert_eq!(var_sorted(&x, 0.3), Ok(5.0));
234        // 0.15 of the mass at 1 lies above p = 0.1, all of the mass at 5.
235        assert_close(tvar_sorted(&x, 0.1).unwrap(), (0.15 + 3.75) / 0.9);
236    }
237
238    #[test]
239    fn rejects_bad_probability() {
240        assert_eq!(var_sorted(&X, -0.1), Err(Error::InvalidProbability(-0.1)));
241        assert!(tvar_sorted(&X, f64::NAN).is_err());
242    }
243}