Skip to main content

prospicio_prob/
evt.rs

1//! Extreme value tails: a generalized Pareto distribution (GPD) fitted to
2//! the exceedances over a threshold, for VaR and TVaR beyond the draws.
3//!
4//! Peaks over threshold: above a high threshold `u`, `X - u` given
5//! `X > u` is approximately GPD (Pickands–Balkema–de Haan). The tail model
6//! is `P(X > x) = p_u (1 + ξ (x - u) / β)^(-1/ξ)` for `x >= u`, with `p_u`
7//! the share of draws above `u` (see `docs/design/risk.md`).
8
9use prospicio_core::{Error, Result};
10use prospicio_math::roots::bisect;
11
12use crate::distribution::{Distribution, check_probability};
13use crate::severity::Severity;
14
15/// The generalized Pareto distribution with shape `xi`, scale `beta` and
16/// location `u` (0 unless set), on `x >= u` (and `x <= u - beta / xi`
17/// when `xi < 0`), as SciPy's `genpareto(c=xi, loc=u, scale=beta)`.
18///
19/// `P(X > x) = (1 + xi (x - u) / beta)^(-1/xi)`, and
20/// `exp(-(x - u) / beta)` at `xi = 0`.
21///
22/// As a [`Severity`] it has closed-form layer means and second moments
23/// for every `xi`, so it also serves as Riegel's generalized Pareto for
24/// treaty pricing ([`Gpd::riegel`]).
25///
26/// ```
27/// use prospicio_prob::{Distribution, evt::Gpd};
28///
29/// let g = Gpd::new(0.5, 2.0).unwrap();
30/// assert!((g.mean() - 4.0).abs() < 1e-12); // beta / (1 - xi)
31/// assert!((g.cdf(g.quantile(0.99).unwrap()) - 0.99).abs() < 1e-12);
32/// ```
33#[derive(Debug, Clone, Copy, PartialEq)]
34pub struct Gpd {
35    xi: f64,
36    beta: f64,
37    location: f64,
38}
39
40impl Gpd {
41    pub fn new(xi: f64, beta: f64) -> Result<Self> {
42        if !xi.is_finite() {
43            return Err(invalid("xi", xi, "must be finite"));
44        }
45        if !beta.is_finite() || beta <= 0.0 {
46            return Err(invalid("beta", beta, "must be finite and positive"));
47        }
48        Ok(Self {
49            xi,
50            beta,
51            location: 0.0,
52        })
53    }
54
55    /// The same distribution shifted to start at `location` (finite).
56    pub fn shifted(self, location: f64) -> Result<Self> {
57        if !location.is_finite() {
58            return Err(invalid("location", location, "must be finite"));
59        }
60        Ok(Self { location, ..self })
61    }
62
63    /// Riegel's generalized Pareto with threshold `t`, initial alpha
64    /// `alpha_ini` and tail alpha `alpha_tail` (all positive):
65    ///
66    /// ```text
67    /// P(X > x) = (1 + (alpha_ini / alpha_tail) (x / t - 1))^(-alpha_tail),  x >= t,
68    /// ```
69    ///
70    /// whose local Pareto alpha moves from `alpha_ini` at `t` to
71    /// `alpha_tail` as `x` grows. It is this GPD with `xi = 1/alpha_tail`,
72    /// `beta = t/alpha_ini` and location `t`, and matches
73    /// `pGenPareto(x, t, alpha_ini, alpha_tail)` in the R package Pareto.
74    ///
75    /// ```
76    /// use prospicio_prob::{Distribution, Severity, evt::Gpd};
77    ///
78    /// let g = Gpd::riegel(1000.0, 2.0, 1.5).unwrap();
79    /// // P(X > 2000) = (1 + 2/1.5)^-1.5.
80    /// assert!((g.survival(2000.0) - (7.0f64 / 3.0).powf(-1.5)).abs() < 1e-15);
81    /// assert!(g.layer(4000.0, 1000.0) > 0.0);
82    /// ```
83    pub fn riegel(t: f64, alpha_ini: f64, alpha_tail: f64) -> Result<Self> {
84        if !t.is_finite() || t <= 0.0 {
85            return Err(invalid("t", t, "must be finite and positive"));
86        }
87        if !alpha_ini.is_finite() || alpha_ini <= 0.0 {
88            return Err(invalid(
89                "alpha_ini",
90                alpha_ini,
91                "must be finite and positive",
92            ));
93        }
94        if !alpha_tail.is_finite() || alpha_tail <= 0.0 {
95            return Err(invalid(
96                "alpha_tail",
97                alpha_tail,
98                "must be finite and positive",
99            ));
100        }
101        Self::new(1.0 / alpha_tail, t / alpha_ini)?.shifted(t)
102    }
103
104    /// Shape `ξ`: heavier tails as it grows; moments of order `1/ξ` and
105    /// above are infinite.
106    pub fn xi(&self) -> f64 {
107        self.xi
108    }
109
110    /// Scale `β`.
111    pub fn beta(&self) -> f64 {
112        self.beta
113    }
114
115    /// Location `u`, where the support starts.
116    pub fn location(&self) -> f64 {
117        self.location
118    }
119
120    /// `P(X > x)`.
121    fn survival_at(&self, x: f64) -> f64 {
122        self.excess_survival(x - self.location)
123    }
124
125    /// `P(X − u > z)`.
126    fn excess_survival(&self, x: f64) -> f64 {
127        if x <= 0.0 {
128            return 1.0;
129        }
130        let z = self.xi * x / self.beta;
131        if self.xi == 0.0 {
132            return (-x / self.beta).exp();
133        }
134        if z <= -1.0 {
135            return 0.0;
136        }
137        (-z.ln_1p() / self.xi).exp()
138    }
139
140    /// Maximum likelihood fit to exceedances (values over a threshold,
141    /// minus the threshold), all non-negative.
142    ///
143    /// Maximizes the profile likelihood in `θ = ξ / β` (Grimshaw 1993):
144    /// for fixed `θ` the likelihood is maximized by
145    /// `ξ(θ) = mean(ln(1 + θ y))`, leaving a one-dimensional search: a scan
146    /// over `θ` finds the maximum, then bisection on the score pins it. `ξ` is restricted to
147    /// `ξ > -1`, where the maximum likelihood estimate exists. Needs at
148    /// least 3 exceedances, not all equal.
149    ///
150    /// ```
151    /// use prospicio_core::StreamRng;
152    /// use prospicio_prob::{Distribution, evt::Gpd};
153    ///
154    /// let truth = Gpd::new(0.3, 10.0).unwrap();
155    /// let y = truth.sample(&mut StreamRng::new(1, 0), 20_000);
156    /// let fit = Gpd::fit(&y).unwrap();
157    /// assert!((fit.xi() - 0.3).abs() < 0.05);
158    /// assert!((fit.beta() / 10.0 - 1.0).abs() < 0.05);
159    /// ```
160    pub fn fit(exceedances: &[f64]) -> Result<Self> {
161        let y = exceedances;
162        let n = y.len();
163        if n < 3 {
164            return Err(invalid("exceedances", n as f64, "needs at least 3 values"));
165        }
166        if let Some(&bad) = y.iter().find(|v| !v.is_finite() || **v < 0.0) {
167            return Err(invalid(
168                "exceedances",
169                bad,
170                "must be finite and non-negative",
171            ));
172        }
173        let max = y.iter().copied().fold(0.0, f64::max);
174        let mean = y.iter().sum::<f64>() / n as f64;
175        if max == 0.0 || y.iter().all(|&v| v == y[0]) {
176            return Err(invalid("exceedances", max, "must not all be equal"));
177        }
178
179        // Profile log-likelihood per observation in θ; θ = 0 is the
180        // exponential limit.
181        let profile = |theta: f64| -> f64 {
182            if theta == 0.0 {
183                return -mean.ln() - 1.0;
184            }
185            let xi = y.iter().map(|&v| (theta * v).ln_1p()).sum::<f64>() / n as f64;
186            if !xi.is_finite() || xi <= -1.0 || xi == 0.0 {
187                return f64::NEG_INFINITY;
188            }
189            let beta = xi / theta;
190            if beta <= 0.0 {
191                return f64::NEG_INFINITY;
192            }
193            -beta.ln() - (1.0 + xi)
194        };
195
196        // θ ranges over (-1/max, ∞). Scan it on log scales in units of
197        // 1/max: positive θ from 1e-8 to 1e12, negative θ from -1e-8 to
198        // within 1e-12 of the -1/max boundary, ascending.
199        // Twenty points a decade.
200        let decade = |j: i32| 10f64.powf(f64::from(j) / 20.0);
201        let mut thetas: Vec<f64> = Vec::with_capacity(800);
202        thetas.extend((1..=240).rev().map(|j| -(1.0 - decade(-j)) / max));
203        thetas.extend((-160..=-1).rev().map(|j| -decade(j) / max));
204        thetas.extend((-160..=240).map(|j| decade(j) / max));
205        let (mut best_k, mut best) = (0, f64::NEG_INFINITY);
206        for (k, &theta) in thetas.iter().enumerate() {
207            let v = profile(theta);
208            if v > best {
209                best = v;
210                best_k = k;
211            }
212        }
213        if best <= profile(0.0) {
214            return Self::new(0.0, mean);
215        }
216
217        // Refine by bisection on the score (the derivative of the profile
218        // likelihood) between the scan's neighbours of the best point. A
219        // search on the likelihood value would pin the maximum only to
220        // about sqrt(eps); the score pins it to rounding.
221        let score = |theta: f64| -> f64 {
222            let (mut xi, mut dxi) = (0.0, 0.0);
223            for &v in y {
224                xi += (theta * v).ln_1p();
225                dxi += v / (1.0 + theta * v);
226            }
227            let (xi, dxi) = (xi / n as f64, dxi / n as f64);
228            -dxi / xi + 1.0 / theta - dxi
229        };
230        let theta_at = |k: usize| thetas[k];
231        let mut a = theta_at(best_k.saturating_sub(1));
232        let mut b = theta_at((best_k + 1).min(thetas.len() - 1));
233        // The scan skips θ = 0, so a bracket never straddles it unless the
234        // best point is next to it on both sides.
235        if a < 0.0 && b > 0.0 {
236            if theta_at(best_k) > 0.0 {
237                a = theta_at(best_k) * 1e-3;
238            } else {
239                b = theta_at(best_k) * 1e-3;
240            }
241        }
242        let theta = if score(a) > 0.0 && score(b) < 0.0 {
243            bisect(a, b, |t| score(t) > 0.0)
244        } else {
245            // The maximum is at the edge of the scan; keep the best point.
246            theta_at(best_k)
247        };
248        let xi = y.iter().map(|&v| (theta * v).ln_1p()).sum::<f64>() / n as f64;
249        Self::new(xi, xi / theta)
250    }
251}
252
253impl Distribution for Gpd {
254    /// `u + beta / (1 - xi)`; infinite for `xi >= 1`.
255    fn mean(&self) -> f64 {
256        if self.xi >= 1.0 {
257            f64::INFINITY
258        } else {
259            self.location + self.beta / (1.0 - self.xi)
260        }
261    }
262
263    /// `beta^2 / ((1 - xi)^2 (1 - 2 xi))`; infinite for `xi >= 1/2`.
264    fn variance(&self) -> f64 {
265        if self.xi >= 0.5 {
266            f64::INFINITY
267        } else {
268            self.beta * self.beta / ((1.0 - self.xi).powi(2) * (1.0 - 2.0 * self.xi))
269        }
270    }
271
272    fn cdf(&self, x: f64) -> f64 {
273        1.0 - self.survival_at(x)
274    }
275
276    fn survival(&self, x: f64) -> f64 {
277        self.survival_at(x)
278    }
279
280    fn quantile(&self, p: f64) -> Result<f64> {
281        check_probability(p)?;
282        // -ln(1 - p), accurate for small p.
283        let e = -(-p).ln_1p();
284        if self.xi == 0.0 {
285            return Ok(self.location + self.beta * e);
286        }
287        Ok(self.location + self.beta * (self.xi * e).exp_m1() / self.xi)
288    }
289}
290
291impl Gpd {
292    /// End of the support above the location: `-beta / xi` for `xi < 0`.
293    fn excess_end(&self) -> f64 {
294        if self.xi < 0.0 {
295            -self.beta / self.xi
296        } else {
297            f64::INFINITY
298        }
299    }
300
301    /// `(∫_m^b S dx, ∫_m^b (x − m) S dx)` for `u ≤ m ≤ b ≤ ∞`.
302    ///
303    /// Above `m` the excess is again a GPD, with scale `beta + xi (m − u)`
304    /// and weight `S(m)`, so both integrals start at 0 of that GPD.
305    fn excess_moments(&self, m: f64, b: f64) -> (f64, f64) {
306        let z0 = m - self.location;
307        let s0 = self.excess_survival(z0);
308        if s0 == 0.0 || m >= b {
309            return (0.0, 0.0);
310        }
311        let beta = self.beta + self.xi * z0;
312        let c = (b - m).min(self.excess_end() - z0);
313        let (k0, k1) = gpd_partial_moments(self.xi, beta, c);
314        (s0 * k0, s0 * k1)
315    }
316}
317
318impl Severity for Gpd {
319    fn lev(&self, limit: f64) -> f64 {
320        if limit <= 0.0 {
321            return limit;
322        }
323        self.layer(limit, 0.0)
324    }
325
326    fn stop_loss(&self, retention: f64) -> f64 {
327        if retention <= 0.0 {
328            return self.mean() - retention;
329        }
330        self.layer(f64::INFINITY, retention)
331    }
332
333    /// `∫_a^b S(x) dx`: the part below the location pays in full.
334    fn layer(&self, limit: f64, attachment: f64) -> f64 {
335        let a = attachment.max(0.0);
336        let b = a + limit;
337        let below = (b.min(self.location) - a).max(0.0);
338        below + self.excess_moments(a.max(self.location), b).0
339    }
340
341    /// `2 ∫_a^b (x − a) S(x) dx`.
342    fn layer_second_moment(&self, limit: f64, attachment: f64) -> f64 {
343        let a = attachment.max(0.0);
344        let b = a + limit;
345        let below = (b.min(self.location) - a).max(0.0);
346        let m = a.max(self.location);
347        let (k0, k1) = self.excess_moments(m, b);
348        below * below + 2.0 * (k1 + (m - a) * k0)
349    }
350}
351
352/// `(∫_0^c S(w) dw, ∫_0^c w S(w) dw)` for the GPD with shape `xi`, scale
353/// `beta` and location 0, for `0 ≤ c ≤` the end of the support.
354///
355/// With `y = 1 + xi c / beta` and `L = ln y`, the first is
356/// `(beta / xi) L exprel((1 − 1/xi) L)` (no cancellation for any `xi`),
357/// and the second is
358/// `(beta / xi)^2 L (exprel((2 − 1/xi) L) − exprel((1 − 1/xi) L))`. For
359/// `|xi| < 1/4` the second is written without `1/xi` as
360/// `beta^2 (1 − A (1 + (1 − xi) c / beta)) / ((1 − xi)(1 − 2 xi))` with
361/// `A = S(c) y`, which is also the `xi = 0` (exponential) case. For
362/// `c / beta` small both forms cancel, so the second uses its Taylor
363/// series there.
364fn gpd_partial_moments(xi: f64, beta: f64, c: f64) -> (f64, f64) {
365    let full = c == f64::INFINITY || (xi < 0.0 && c >= -beta / xi);
366    if full {
367        let k0 = if xi < 1.0 {
368            beta / (1.0 - xi)
369        } else {
370            f64::INFINITY
371        };
372        let k1 = if xi < 0.5 {
373            beta * beta / ((1.0 - xi) * (1.0 - 2.0 * xi))
374        } else {
375            f64::INFINITY
376        };
377        return (k0, k1);
378    }
379    if c <= 0.0 {
380        return (0.0, 0.0);
381    }
382    let r = c / beta;
383    let l = (xi * r).ln_1p();
384    let k0 = if xi == 0.0 {
385        beta * -(-r).exp_m1()
386    } else {
387        beta / xi * l * exprel((1.0 - 1.0 / xi) * l)
388    };
389    let k1 = if r * xi.abs().max(1.0) < 0.05 {
390        // S(w) = Σ_n (−1)^n Π_{j<n} (1 + j xi) (w/beta)^n / n!, integrated
391        // against w: terms shrink by at most 0.05 each.
392        let mut coef = 1.0;
393        let mut sum = 0.0;
394        for n in 0..14 {
395            sum += coef / (f64::from(n) + 2.0);
396            coef *= -(1.0 + f64::from(n) * xi) * r / (f64::from(n) + 1.0);
397        }
398        c * c * sum
399    } else if xi.abs() < 0.25 {
400        let a = if xi == 0.0 {
401            (-r).exp()
402        } else {
403            ((1.0 - 1.0 / xi) * l).exp()
404        };
405        beta * beta * (1.0 - a * (1.0 + (1.0 - xi) * r)) / ((1.0 - xi) * (1.0 - 2.0 * xi))
406    } else {
407        let q = 1.0 - 1.0 / xi;
408        (beta / xi).powi(2) * l * (exprel((q + 1.0) * l) - exprel(q * l))
409    };
410    (k0, k1)
411}
412
413/// `(e^z − 1) / z`, and 1 at `z = 0`.
414fn exprel(z: f64) -> f64 {
415    if z == 0.0 { 1.0 } else { z.exp_m1() / z }
416}
417
418/// A peaks-over-threshold tail: draws above `threshold` modelled by a GPD.
419///
420/// `P(X > x) = p_u · S_GPD(x - u)` for `x >= u`, where `p_u` is the share
421/// of draws above the threshold. VaR and TVaR at levels `p >= 1 - p_u`
422/// come from the GPD, so they extend smoothly past the largest draw.
423///
424/// ```
425/// use prospicio_core::StreamRng;
426/// use prospicio_prob::{Distribution, Lognormal, Sampled, evt::PotTail};
427///
428/// let d = Lognormal::new(0.0, 1.0).unwrap();
429/// let s = Sampled::new(d.sample(&mut StreamRng::new(3, 0), 100_000)).unwrap();
430/// let tail = PotTail::fit(&s, 0.95).unwrap();
431/// // The 99.9% quantile of LN(0, 1) is 21.98.
432/// let q = tail.var(0.999).unwrap();
433/// assert!((q / 21.98 - 1.0).abs() < 0.05, "{q}");
434/// ```
435#[derive(Debug, Clone, PartialEq)]
436pub struct PotTail {
437    threshold: f64,
438    p_exceed: f64,
439    gpd: Gpd,
440}
441
442impl PotTail {
443    /// Fits a GPD to the draws above the empirical `level` quantile of
444    /// `draws` (for example `0.95` for the top 5%).
445    pub fn fit(draws: &impl crate::Empirical, level: f64) -> Result<Self> {
446        check_probability(level)?;
447        let sorted = draws.sorted();
448        let threshold = crate::risk::var_sorted(sorted, level)?;
449        let above: Vec<f64> = sorted
450            .iter()
451            .filter(|&&x| x > threshold)
452            .map(|&x| x - threshold)
453            .collect();
454        let gpd = Gpd::fit(&above)?;
455        Ok(Self {
456            threshold,
457            p_exceed: above.len() as f64 / sorted.len() as f64,
458            gpd,
459        })
460    }
461
462    /// A tail from known parts; `gpd` models the exceedances, so its
463    /// location must be 0.
464    pub fn new(threshold: f64, p_exceed: f64, gpd: Gpd) -> Result<Self> {
465        if gpd.location != 0.0 {
466            return Err(invalid(
467                "location",
468                gpd.location,
469                "the exceedance GPD must have location 0",
470            ));
471        }
472        if !threshold.is_finite() {
473            return Err(invalid("threshold", threshold, "must be finite"));
474        }
475        if !(p_exceed > 0.0 && p_exceed <= 1.0) {
476            return Err(invalid("p_exceed", p_exceed, "must be in (0, 1]"));
477        }
478        Ok(Self {
479            threshold,
480            p_exceed,
481            gpd,
482        })
483    }
484
485    pub fn threshold(&self) -> f64 {
486        self.threshold
487    }
488
489    /// Share of draws above the threshold, `p_u`.
490    pub fn p_exceed(&self) -> f64 {
491        self.p_exceed
492    }
493
494    pub fn gpd(&self) -> &Gpd {
495        &self.gpd
496    }
497
498    /// `P(X > x)` for `x >= threshold`.
499    pub fn survival(&self, x: f64) -> f64 {
500        self.p_exceed * self.gpd.survival(x - self.threshold)
501    }
502
503    /// VaR at `p`, for `p >= 1 - p_exceed`:
504    /// `u + β ((p_u / (1 - p))^ξ - 1) / ξ`.
505    pub fn var(&self, p: f64) -> Result<f64> {
506        self.check_level(p)?;
507        // P(Y > y) = (1 - p) / p_u for the exceedance Y.
508        // Clamped: at p = 1 - p_u rounding can leave q a hair below 0.
509        let q = (1.0 - (1.0 - p) / self.p_exceed).max(0.0);
510        Ok(self.threshold + self.gpd.quantile(q)?)
511    }
512
513    /// TVaR at `p`, for `p >= 1 - p_exceed` and `ξ < 1`:
514    /// `(VaR + β - ξ u) / (1 - ξ)`, the GPD's mean excess added to the VaR.
515    pub fn tvar(&self, p: f64) -> Result<f64> {
516        let var = self.var(p)?;
517        let (xi, beta) = (self.gpd.xi, self.gpd.beta);
518        if xi >= 1.0 {
519            return Ok(f64::INFINITY);
520        }
521        Ok(var + (beta + xi * (var - self.threshold)) / (1.0 - xi))
522    }
523
524    fn check_level(&self, p: f64) -> Result<()> {
525        check_probability(p)?;
526        if p < 1.0 - self.p_exceed {
527            return Err(invalid(
528                "p",
529                p,
530                "is below the tail: must be at least 1 - p_exceed",
531            ));
532        }
533        Ok(())
534    }
535}
536
537fn invalid(name: &'static str, value: f64, reason: &'static str) -> Error {
538    Error::InvalidParameter {
539        name,
540        value,
541        reason,
542    }
543}
544
545/// The empirical mean-excess function `e(u) = E[X - u | X > u]` at each
546/// threshold, with the number of draws above it: `(u, e(u), n_u)`, `e(u)`
547/// NaN where no draw exceeds `u`.
548///
549/// Above a threshold where a GPD fits, `e(u)` is linear in `u` with slope
550/// `ξ / (1 - ξ)`, so the plot of `e(u)` against `u` is the classic aid to
551/// choosing a threshold: pick `u` where it turns straight.
552///
553/// ```
554/// use prospicio_prob::evt::mean_excess;
555///
556/// let e = mean_excess(&[1.0, 2.0, 3.0, 4.0], &[2.0, 4.0]);
557/// assert_eq!(e[0], (2.0, 1.5, 2)); // (3 - 2 + 4 - 2) / 2
558/// assert_eq!(e[1].2, 0);
559/// ```
560pub fn mean_excess(draws: &[f64], thresholds: &[f64]) -> Vec<(f64, f64, usize)> {
561    let mut sorted = draws.to_vec();
562    sorted.sort_by(f64::total_cmp);
563    // Suffix sums from the top, so each threshold costs a binary search.
564    let mut suffix = vec![0.0; sorted.len() + 1];
565    for i in (0..sorted.len()).rev() {
566        suffix[i] = suffix[i + 1] + sorted[i];
567    }
568    thresholds
569        .iter()
570        .map(|&u| {
571            let first = sorted.partition_point(|&x| x <= u);
572            let n = sorted.len() - first;
573            let e = if n == 0 {
574                f64::NAN
575            } else {
576                suffix[first] / n as f64 - u
577            };
578            (u, e, n)
579        })
580        .collect()
581}
582
583/// Hill estimates of the tail index `ξ` (`1/α` for a Pareto tail) from the
584/// `k` largest draws, for each `k` in `ks`:
585/// `ξ̂_k = (1/k) Σ_{i=1..k} ln X_(n-i+1) - ln X_(n-k)`.
586///
587/// A plot of `ξ̂_k` against `k` that settles over a range of `k` suggests
588/// a heavy (Pareto-type) tail with that index. Fails if a `k` is 0 or
589/// leaves no draw below the top `k`, or the `k + 1` largest draws are not
590/// all positive.
591///
592/// ```
593/// use prospicio_prob::evt::hill;
594///
595/// // Exact Pareto quantiles with α = 2: ξ̂ is close to 1/2.
596/// let n = 10_000;
597/// let x: Vec<f64> = (1..=n).map(|i| (1.0 - (i as f64 - 0.5) / n as f64).powf(-0.5)).collect();
598/// let xi = hill(&x, &[1000]).unwrap()[0];
599/// assert!((xi - 0.5).abs() < 0.01);
600/// ```
601pub fn hill(draws: &[f64], ks: &[usize]) -> Result<Vec<f64>> {
602    let mut sorted = draws.to_vec();
603    sorted.sort_by(|a, b| b.total_cmp(a)); // descending
604    ks.iter()
605        .map(|&k| {
606            if k == 0 || k >= sorted.len() {
607                return Err(Error::InvalidParameter {
608                    name: "k",
609                    value: k as f64,
610                    reason: "must be at least 1 and below the number of draws",
611                });
612            }
613            if sorted[k].is_nan() || sorted[k] <= 0.0 {
614                return Err(Error::InvalidParameter {
615                    name: "draws",
616                    value: sorted[k],
617                    reason: "the k + 1 largest must be positive",
618                });
619            }
620            let threshold = sorted[k].ln();
621            Ok(sorted[..k].iter().map(|x| x.ln() - threshold).sum::<f64>() / k as f64)
622        })
623        .collect()
624}
625
626#[cfg(test)]
627mod tests {
628    use super::*;
629    use prospicio_core::StreamRng;
630
631    #[test]
632    fn gpd_closed_forms() {
633        let g = Gpd::new(0.25, 4.0).unwrap();
634        assert!((g.mean() - 4.0 / 0.75).abs() < 1e-12);
635        assert!((g.variance() - 16.0 / (0.5625 * 0.5)).abs() < 1e-12);
636        // Exponential limit.
637        let e = Gpd::new(0.0, 2.0).unwrap();
638        assert!((e.cdf(2.0) - (1.0 - (-1f64).exp())).abs() < 1e-15);
639        assert!((e.quantile(0.5).unwrap() - 2.0 * 2f64.ln()).abs() < 1e-15);
640        // Bounded support for xi < 0: x <= beta / |xi|.
641        let b = Gpd::new(-0.5, 1.0).unwrap();
642        assert_eq!(b.survival(2.0), 0.0);
643        assert!((b.quantile(1.0).unwrap() - 2.0).abs() < 1e-15);
644        assert_eq!(Gpd::new(1.5, 1.0).unwrap().mean(), f64::INFINITY);
645        for p in [1e-12, 0.1, 0.5, 0.99, 1.0 - 1e-12] {
646            let x = g.quantile(p).unwrap();
647            assert!((g.cdf(x) - p).abs() < 1e-12 * p.max(1e-3), "p = {p}");
648        }
649        assert!(Gpd::new(0.1, 0.0).is_err());
650    }
651
652    #[test]
653    fn fit_recovers_parameters() {
654        for (xi, beta) in [(0.5, 1.0), (0.0, 3.0), (-0.3, 2.0), (1.2, 0.5)] {
655            let truth = Gpd::new(xi, beta).unwrap();
656            let y = truth.sample(&mut StreamRng::new(11, 0), 50_000);
657            let fit = Gpd::fit(&y).unwrap();
658            assert!((fit.xi() - xi).abs() < 0.03, "xi {xi}: {fit:?}");
659            assert!(
660                (fit.beta() / beta - 1.0).abs() < 0.04,
661                "beta {beta}: {fit:?}"
662            );
663        }
664    }
665
666    #[test]
667    fn fit_rejects_degenerate_input() {
668        assert!(Gpd::fit(&[1.0, 2.0]).is_err());
669        assert!(Gpd::fit(&[1.0, 1.0, 1.0]).is_err());
670        assert!(Gpd::fit(&[1.0, -1.0, 2.0]).is_err());
671    }
672
673    #[test]
674    fn pot_tail_is_exact_for_a_gpd_tail() {
675        // A tail from known parts: VaR and TVaR from the closed forms.
676        let tail = PotTail::new(10.0, 0.05, Gpd::new(0.25, 2.0).unwrap()).unwrap();
677        // At p = 0.95 the VaR is the threshold.
678        assert!((tail.var(0.95).unwrap() - 10.0).abs() < 1e-12);
679        // TVaR at the threshold is u + mean excess = 10 + 2 / 0.75.
680        assert!((tail.tvar(0.95).unwrap() - (10.0 + 2.0 / 0.75)).abs() < 1e-12);
681        // P(X > VaR(p)) = 1 - p.
682        for p in [0.96, 0.99, 0.9999] {
683            let v = tail.var(p).unwrap();
684            assert!((tail.survival(v) - (1.0 - p)).abs() < 1e-14);
685        }
686        assert!(tail.var(0.9).is_err());
687    }
688
689    fn close(a: f64, b: f64, rel: f64) -> bool {
690        (a - b).abs() <= rel * b.abs().max(1e-300)
691    }
692
693    /// `∫_a^b x^k S(x) dx` by composite Gauss–Legendre on a log scale,
694    /// split at the kink at the location.
695    fn quad(g: &Gpd, k: i32, a: f64, b: f64) -> f64 {
696        let u = g.location();
697        if a < u && u < b {
698            return quad_smooth(g, k, a, u) + quad_smooth(g, k, u, b);
699        }
700        quad_smooth(g, k, a, b)
701    }
702
703    fn quad_smooth(g: &Gpd, k: i32, a: f64, b: f64) -> f64 {
704        // 5-point Gauss–Legendre nodes and weights on [-1, 1].
705        const X: [f64; 5] = [
706            0.0,
707            -0.538_469_310_105_683_1,
708            0.538_469_310_105_683_1,
709            -0.906_179_845_938_664,
710            0.906_179_845_938_664,
711        ];
712        const W: [f64; 5] = [
713            0.568_888_888_888_888_9,
714            0.478_628_670_499_366_5,
715            0.478_628_670_499_366_5,
716            0.236_926_885_056_189_1,
717            0.236_926_885_056_189_1,
718        ];
719        let n = 4000;
720        let (la, lb) = (a.ln(), b.ln());
721        let h = (lb - la) / f64::from(n);
722        let mut sum = 0.0;
723        for i in 0..n {
724            let mid = la + (f64::from(i) + 0.5) * h;
725            for (x, w) in X.iter().zip(W) {
726                let v = (mid + 0.5 * h * x).exp();
727                sum += w * 0.5 * h * v * v.powi(k) * g.survival(v);
728            }
729        }
730        sum
731    }
732
733    #[test]
734    fn riegel_matches_its_definition() {
735        let g = Gpd::riegel(1000.0, 2.0, 1.5).unwrap();
736        assert!(close(g.xi(), 1.0 / 1.5, 1e-15));
737        assert!(close(g.beta(), 500.0, 1e-15));
738        assert_eq!(g.location(), 1000.0);
739        for x in [1000.0, 1500.0, 1e4, 1e7] {
740            let want = (1.0f64 + (2.0 / 1.5) * (x / 1000.0 - 1.0)).powf(-1.5);
741            assert!(close(g.survival(x), want, 1e-14));
742        }
743        assert_eq!(g.survival(999.0), 1.0);
744        assert!(close(g.cdf(g.quantile(0.9).unwrap()), 0.9, 1e-14));
745        assert!(close(g.mean(), 1000.0 + 500.0 / (1.0 - 1.0 / 1.5), 1e-15));
746        // Equal alphas give the Pareto.
747        let p = crate::Pareto::new(1000.0, 2.5).unwrap();
748        let r = Gpd::riegel(1000.0, 2.5, 2.5).unwrap();
749        for (l, a) in [(4000.0f64, 1000.0), (1e5, 5e4), (f64::INFINITY, 2000.0)] {
750            assert!(close(r.layer(l, a), p.layer(l, a), 1e-13));
751            assert!(close(
752                r.layer_second_moment(l.min(1e9), a),
753                p.layer_second_moment(l.min(1e9), a),
754                1e-12
755            ));
756        }
757        assert!(Gpd::riegel(0.0, 1.0, 1.0).is_err());
758        assert!(Gpd::riegel(1.0, 0.0, 1.0).is_err());
759        assert!(Gpd::riegel(1.0, 1.0, -1.0).is_err());
760        assert!(PotTail::new(0.0, 0.1, r).is_err());
761    }
762
763    #[test]
764    fn layer_moments_match_quadrature() {
765        for (xi, beta, u) in [
766            (0.0, 100.0, 0.0),
767            (1e-9, 100.0, 0.0),
768            (0.1, 100.0, 50.0),
769            (-0.2, 100.0, 0.0),
770            (-0.6, 100.0, 10.0),
771            (0.25, 100.0, 0.0),
772            (0.5, 100.0, 0.0),
773            (0.8, 100.0, 0.0),
774            (1.0, 100.0, 0.0),
775            (1.7, 100.0, 20.0),
776        ] {
777            let g = Gpd::new(xi, beta).unwrap().shifted(u).unwrap();
778            for (l, a) in [
779                (1.0f64, 30.0f64),
780                (10.0, 30.0),
781                (200.0, 30.0),
782                (500.0, 100.0),
783                (2e4, 1e3),
784            ] {
785                let b = (a + l).min(u + g.excess_end());
786                if a >= b {
787                    assert_eq!(g.layer(l, a), 0.0);
788                    continue;
789                }
790                let m1 = quad(&g, 0, a, b);
791                let m2 = 2.0 * (quad(&g, 1, a, b) - a * m1);
792                assert!(close(g.layer(l, a), m1, 1e-12), "{xi} {l} xs {a}");
793                assert!(
794                    close(g.layer_second_moment(l, a), m2, 1e-11),
795                    "{xi} {l} xs {a}: {} {m2}",
796                    g.layer_second_moment(l, a)
797                );
798            }
799        }
800    }
801
802    #[test]
803    fn partial_moments_are_continuous_across_branches() {
804        // Series below r = 0.05, rational form for |xi| < 1/4, exprel form above.
805        for xi in [-0.3, -0.25, -0.2, 0.0, 0.2, 0.25, 0.3, 2.0] {
806            let below =
807                gpd_partial_moments(xi, 1.0, 0.05 / f64::max(1.0, f64::abs(xi)) * (1.0 - 1e-9));
808            let above =
809                gpd_partial_moments(xi, 1.0, 0.05 / f64::max(1.0, f64::abs(xi)) * (1.0 + 1e-9));
810            assert!(close(below.1, above.1, 1e-8), "{xi} {below:?} {above:?}");
811        }
812        for c in [0.1, 1.0, 3.0] {
813            let lo = gpd_partial_moments(0.25 * (1.0 - 1e-12), 1.0, c);
814            let hi = gpd_partial_moments(0.25, 1.0, c);
815            assert!(close(lo.1, hi.1, 1e-11), "{c}");
816        }
817        // Exponential: ∫_0^c w e^-w dw = 1 − e^-c (1 + c).
818        let (k0, k1) = gpd_partial_moments(0.0, 1.0, 2.0);
819        assert!(close(k0, 1.0 - (-2.0f64).exp(), 1e-15));
820        assert!(close(k1, 1.0 - 3.0 * (-2.0f64).exp(), 1e-15));
821    }
822
823    #[test]
824    fn severity_identities() {
825        for g in [
826            Gpd::riegel(1000.0, 1.2, 2.5).unwrap(),
827            Gpd::new(-0.3, 50.0).unwrap(),
828            Gpd::new(0.0, 50.0).unwrap().shifted(10.0).unwrap(),
829        ] {
830            for d in [5.0, 100.0, 1500.0, 1e4] {
831                assert!(
832                    close(g.lev(d) + g.stop_loss(d), g.mean(), 1e-12),
833                    "{g:?} {d}"
834                );
835            }
836            let m2 = g.layer_second_moment(f64::INFINITY, 0.0);
837            let loc = g.location();
838            let raw2 = g.variance() + g.mean() * g.mean();
839            assert!(close(m2, raw2, 1e-12), "{g:?} {m2} {raw2} {loc}");
840        }
841        let heavy = Gpd::riegel(1.0, 1.0, 0.9).unwrap();
842        assert_eq!(heavy.stop_loss(10.0), f64::INFINITY);
843        assert!(heavy.layer(10.0, 10.0).is_finite());
844    }
845}