Skip to main content

prospicio_prob/
capital.rs

1//! Capital allocation and diversification for a joint
2//! [`PredictiveDistribution`].
3//!
4//! The portfolio is the [`total`](PredictiveDistribution::total) of the
5//! components, and its capital is a distortion risk measure `ρ` of that
6//! total. An allocation splits `ρ(S)` back to the components. Methods
7//! differ in what they reward; see [`AllocationMethod`] and
8//! `docs/design/risk.md`.
9
10use prospicio_core::{Error, Result};
11
12use crate::distortion::Distortion;
13use crate::predictive::PredictiveDistribution;
14use crate::sampled::Empirical;
15
16/// How [`PredictiveDistribution::capital`] splits the portfolio's risk
17/// measure `ρ(S)`, `S = Σ X_j`, between components `X_j`.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum AllocationMethod {
20    /// Euler (co-measure) allocation, `∂ρ(S + h X_j)/∂h` at `h = 0`: the
21    /// component's own draws weighted by the distortion weights of the
22    /// total's ranks. For TVaR these are the CoTVaRs,
23    /// `E[X_j | S in its top 1 − p]`. The only method that is
24    /// consistent with marginal changes to the portfolio. Sums to `ρ(S)`.
25    Euler,
26    /// `ρ(S) Cov(X_j, S) / Var(S)`: the variance's Euler allocation scaled
27    /// to `ρ(S)`. Looks at the whole distribution, not the tail. Sums to
28    /// `ρ(S)`.
29    Covariance,
30    /// `ρ(S) ρ(X_j) / Σ_k ρ(X_k)`, the stand-alone measures scaled to the
31    /// total (the "haircut" allocation). Ignores dependence beyond the
32    /// total. Sums to `ρ(S)`.
33    Proportional,
34    /// `ρ(S) − ρ(S − X_j)`, what the portfolio's measure falls by without
35    /// the component (Merton–Perold). Does *not* sum to `ρ(S)`; the
36    /// shortfall is the capital no single component is responsible for.
37    Marginal,
38    /// The Shapley value of the game `v(T) = ρ(Σ_{j∈T} X_j)`: each
39    /// component's marginal contribution averaged over every order of
40    /// joining. Sums to `ρ(S)`. Needs `ρ` of all `2^m` sub-portfolios, so
41    /// it is limited to 12 components.
42    Shapley,
43}
44
45/// The result of [`PredictiveDistribution::capital`].
46#[derive(Debug, Clone, PartialEq)]
47pub struct Allocation {
48    pub method: AllocationMethod,
49    /// `ρ(S)`, the portfolio's measure.
50    pub total: f64,
51    /// `ρ(X_j)` per component, in
52    /// [`components`](PredictiveDistribution::components) order.
53    pub standalone: Vec<f64>,
54    /// Allocated capital per component, in the same order.
55    pub allocated: Vec<f64>,
56}
57
58impl Allocation {
59    /// `Σ_j ρ(X_j) − ρ(S)`: the capital saved by holding the components
60    /// together. Non-negative for a subadditive measure (every
61    /// [`Distortion`] is).
62    pub fn diversification_benefit(&self) -> f64 {
63        self.standalone.iter().sum::<f64>() - self.total
64    }
65
66    /// `ρ(X_j)` less its allocation, per component: each component's share
67    /// of the diversification benefit.
68    pub fn diversification(&self) -> Vec<f64> {
69        self.standalone
70            .iter()
71            .zip(&self.allocated)
72            .map(|(s, a)| s - a)
73            .collect()
74    }
75}
76
77/// Most components [`AllocationMethod::Shapley`] accepts.
78pub const SHAPLEY_MAX_COMPONENTS: usize = 12;
79
80impl PredictiveDistribution {
81    /// The risk measure `ρ = d` of the total, each component's stand-alone
82    /// measure, and the total's allocation to the components by `method`.
83    ///
84    /// Components must add up to the portfolio being allocated (segments,
85    /// not a tower result holding gross, ceded and net side by side).
86    ///
87    /// ```
88    /// use prospicio_prob::capital::AllocationMethod;
89    /// use prospicio_prob::{Distortion, KeyValue, PredictiveDistribution, Provenance};
90    ///
91    /// // Two lines over four simulations; the totals are 3, 5, 7, 9.
92    /// let pd = PredictiveDistribution::from_draws(
93    ///     vec!["lob".into()],
94    ///     vec![vec![KeyValue::from("motor")], vec![KeyValue::from("property")]],
95    ///     vec![1.0, 2.0, 4.0, 1.0, 2.0, 5.0, 3.0, 6.0],
96    ///     Provenance::new("example"),
97    /// )
98    /// .unwrap();
99    /// let tvar = Distortion::tvar(0.5).unwrap();
100    /// let a = pd.capital(&tvar, AllocationMethod::Euler).unwrap();
101    /// assert_eq!(a.total, 8.0);
102    /// assert_eq!(a.standalone, [3.5, 5.5]);
103    /// assert_eq!(a.allocated, [2.5, 5.5]);
104    /// assert_eq!(a.diversification_benefit(), 1.0);
105    /// ```
106    ///
107    /// Fails for [`AllocationMethod::Covariance`] when the total does not
108    /// vary, for [`AllocationMethod::Proportional`] when the stand-alone
109    /// measures sum to 0, and for [`AllocationMethod::Shapley`] with more
110    /// than [`SHAPLEY_MAX_COMPONENTS`] components.
111    pub fn capital(&self, d: &Distortion, method: AllocationMethod) -> Result<Allocation> {
112        let m = self.n_components();
113        let draws = self.draw_matrix();
114        let column = |j: usize| -> Vec<f64> { draws.iter().skip(j).step_by(m).copied().collect() };
115        let total = self.total().distortion(d);
116        let standalone: Vec<f64> = (0..m).map(|j| measure(d, column(j))).collect();
117        let allocated = match method {
118            AllocationMethod::Euler => self.allocate(d),
119            AllocationMethod::Covariance => {
120                let s = self.total().draws();
121                let n = s.len() as f64;
122                let mean_s = s.iter().sum::<f64>() / n;
123                let var_s = s.iter().map(|x| (x - mean_s).powi(2)).sum::<f64>() / n;
124                if var_s.is_nan() || var_s <= 0.0 {
125                    return Err(invalid("draws", var_s, "the total must vary"));
126                }
127                (0..m)
128                    .map(|j| {
129                        let x = column(j);
130                        let mean_x = x.iter().sum::<f64>() / n;
131                        let cov = x
132                            .iter()
133                            .zip(s)
134                            .map(|(a, b)| (a - mean_x) * (b - mean_s))
135                            .sum::<f64>()
136                            / n;
137                        total * cov / var_s
138                    })
139                    .collect()
140            }
141            AllocationMethod::Proportional => {
142                let sum: f64 = standalone.iter().sum();
143                if sum == 0.0 || !sum.is_finite() {
144                    return Err(invalid(
145                        "standalone",
146                        sum,
147                        "must have a finite, non-zero sum",
148                    ));
149                }
150                standalone.iter().map(|r| total * r / sum).collect()
151            }
152            AllocationMethod::Marginal => {
153                let s = self.total().draws();
154                (0..m)
155                    .map(|j| {
156                        let without: Vec<f64> = s
157                            .iter()
158                            .zip(column(j))
159                            .map(|(total, x)| total - x)
160                            .collect();
161                        total - measure(d, without)
162                    })
163                    .collect()
164            }
165            AllocationMethod::Shapley => {
166                if m > SHAPLEY_MAX_COMPONENTS {
167                    return Err(invalid(
168                        "components",
169                        m as f64,
170                        "Shapley allocation takes at most 12 components",
171                    ));
172                }
173                shapley(d, draws, m)
174            }
175        };
176        Ok(Allocation {
177            method,
178            total,
179            standalone,
180            allocated,
181        })
182    }
183}
184
185/// `ρ` of equally likely draws, in any order.
186fn measure(d: &Distortion, mut draws: Vec<f64>) -> f64 {
187    draws.sort_by(f64::total_cmp);
188    d.apply_sorted(&draws)
189}
190
191/// Shapley values of `v(T) = ρ(Σ_{j∈T} X_j)` from all `2^m` coalitions;
192/// `draws` is row-major, `m` per simulation.
193fn shapley(d: &Distortion, draws: &[f64], m: usize) -> Vec<f64> {
194    let n = draws.len() / m;
195    let coalitions = 1usize << m;
196    let mut value = vec![0.0; coalitions];
197    for (mask, v) in value.iter_mut().enumerate().skip(1) {
198        let sums = (0..n)
199            .map(|i| {
200                let row = &draws[i * m..(i + 1) * m];
201                (0..m).filter(|j| mask >> j & 1 == 1).map(|j| row[j]).sum()
202            })
203            .collect();
204        *v = measure(d, sums);
205    }
206    // weight(|T|) = |T|! (m − |T| − 1)! / m!
207    let factorial = |k: usize| (1..=k).map(|i| i as f64).product::<f64>();
208    let weight: Vec<f64> = (0..m)
209        .map(|t| factorial(t) * factorial(m - t - 1) / factorial(m))
210        .collect();
211    (0..m)
212        .map(|j| {
213            (0..coalitions)
214                .filter(|mask| mask >> j & 1 == 0)
215                .map(|mask| {
216                    let size = mask.count_ones() as usize;
217                    weight[size] * (value[mask | 1 << j] - value[mask])
218                })
219                .sum()
220        })
221        .collect()
222}
223
224fn invalid(name: &'static str, value: f64, reason: &'static str) -> Error {
225    Error::InvalidParameter {
226        name,
227        value,
228        reason,
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use crate::{GaussianCopula, KeyValue, Lognormal, Provenance, copula};
236
237    fn three_lines() -> PredictiveDistribution {
238        let marginals = [
239            Lognormal::from_mean_cv(10.0, 0.3).unwrap(),
240            Lognormal::from_mean_cv(20.0, 0.8).unwrap(),
241            Lognormal::from_mean_cv(5.0, 2.0).unwrap(),
242        ];
243        let corr = [1.0, 0.5, 0.2, 0.5, 1.0, 0.4, 0.2, 0.4, 1.0];
244        let c = GaussianCopula::new(&corr, 3).unwrap();
245        copula::simulate(
246            &c,
247            &[&marginals[0], &marginals[1], &marginals[2]],
248            vec!["line".into()],
249            ["a", "b", "c"]
250                .iter()
251                .map(|&l| vec![KeyValue::from(l)])
252                .collect(),
253            50_000,
254            11,
255            Provenance::new("test"),
256        )
257        .unwrap()
258    }
259
260    fn close(a: f64, b: f64) -> bool {
261        (a - b).abs() <= 1e-9 * b.abs().max(1.0)
262    }
263
264    #[test]
265    fn full_allocations_sum_to_the_total() {
266        let pd = three_lines();
267        let d = Distortion::tvar(0.99).unwrap();
268        for method in [
269            AllocationMethod::Euler,
270            AllocationMethod::Covariance,
271            AllocationMethod::Proportional,
272            AllocationMethod::Shapley,
273        ] {
274            let a = pd.capital(&d, method).unwrap();
275            assert!(close(a.allocated.iter().sum(), a.total), "{method:?}");
276            assert!(a.diversification_benefit() > 0.0);
277        }
278    }
279
280    #[test]
281    fn euler_matches_allocate_and_marginal_falls_short() {
282        let pd = three_lines();
283        let d = Distortion::wang(0.5).unwrap();
284        let euler = pd.capital(&d, AllocationMethod::Euler).unwrap();
285        assert_eq!(euler.allocated, pd.allocate(&d));
286        let marginal = pd.capital(&d, AllocationMethod::Marginal).unwrap();
287        // Subadditivity: each marginal is at most the stand-alone measure,
288        // and together they leave capital unallocated.
289        for (m, s) in marginal.allocated.iter().zip(&marginal.standalone) {
290            assert!(m <= s);
291        }
292        assert!(marginal.allocated.iter().sum::<f64>() < marginal.total);
293    }
294
295    #[test]
296    fn shapley_by_hand_for_two_components() {
297        // Two components: φ_1 = (ρ(X1) + ρ(S) − ρ(X2)) / 2.
298        let pd = three_lines();
299        let d = Distortion::tvar(0.95).unwrap();
300        let pd2 = {
301            let m = pd.n_components();
302            let draws: Vec<f64> = pd
303                .draw_matrix()
304                .chunks_exact(m)
305                .flat_map(|r| [r[0], r[1] + r[2]])
306                .collect();
307            PredictiveDistribution::from_draws(
308                vec!["line".into()],
309                vec![vec![KeyValue::from("a")], vec![KeyValue::from("bc")]],
310                draws,
311                Provenance::new("test"),
312            )
313            .unwrap()
314        };
315        let a = pd2.capital(&d, AllocationMethod::Shapley).unwrap();
316        let want = 0.5 * (a.standalone[0] + a.total - a.standalone[1]);
317        assert!(close(a.allocated[0], want));
318    }
319
320    #[test]
321    fn comonotonic_components_have_no_benefit() {
322        // X2 = 2 X1 in every simulation: TVaR is additive.
323        let x: Vec<f64> = (0..1000).map(|i| f64::from(i) * 0.37 % 11.0).collect();
324        let draws: Vec<f64> = x.iter().flat_map(|&v| [v, 2.0 * v]).collect();
325        let pd = PredictiveDistribution::from_draws(
326            vec!["line".into()],
327            vec![vec![KeyValue::from("a")], vec![KeyValue::from("b")]],
328            draws,
329            Provenance::new("test"),
330        )
331        .unwrap();
332        let d = Distortion::tvar(0.9).unwrap();
333        for method in [
334            AllocationMethod::Euler,
335            AllocationMethod::Covariance,
336            AllocationMethod::Shapley,
337        ] {
338            let a = pd.capital(&d, method).unwrap();
339            assert!(a.diversification_benefit().abs() < 1e-9);
340            assert!(close(a.allocated[0], a.standalone[0]), "{method:?}");
341        }
342    }
343
344    #[test]
345    fn rejects_degenerate_inputs() {
346        let pd = PredictiveDistribution::from_draws(
347            vec!["line".into()],
348            vec![vec![KeyValue::from("a")], vec![KeyValue::from("b")]],
349            vec![1.0, -1.0, 2.0, -2.0],
350            Provenance::new("test"),
351        )
352        .unwrap();
353        let d = Distortion::tvar(0.5).unwrap();
354        assert!(pd.capital(&d, AllocationMethod::Covariance).is_err());
355    }
356}