Skip to main content

fin_primitives/risk/
correlation_matrix.rs

1//! Correlation matrix estimation and shrinkage.
2//!
3//! Provides:
4//! - [`CorrelationMatrix`]: pairwise Pearson correlation from return series
5//! - [`LedoitWolfShrinkage`]: oracle approximating shrinkage toward the identity
6//! - [`DccGarch`]: simplified DCC-GARCH rolling and EWMA correlation utilities
7
8/// Symmetric correlation matrix.
9#[derive(Debug, Clone)]
10pub struct CorrelationMatrix {
11    /// Row-major correlation values; element `[i * n + j]` is `corr(i, j)`.
12    pub matrix: Vec<Vec<f64>>,
13    /// Dimension (number of assets).
14    pub n: usize,
15}
16
17impl CorrelationMatrix {
18    /// Compute pairwise Pearson correlation from a matrix of return series.
19    ///
20    /// `returns[i]` is the time-series of returns for asset `i`.
21    /// All series must have the same length.
22    pub fn from_returns(returns: &[Vec<f64>]) -> Self {
23        let n = returns.len();
24        if n == 0 {
25            return Self { matrix: vec![], n: 0 };
26        }
27
28        let t = returns[0].len();
29
30        // Compute means
31        let means: Vec<f64> = returns
32            .iter()
33            .map(|r| r.iter().sum::<f64>() / t.max(1) as f64)
34            .collect();
35
36        // Compute standard deviations
37        let stds: Vec<f64> = returns
38            .iter()
39            .zip(means.iter())
40            .map(|(r, &m)| {
41                let var = r.iter().map(|&x| (x - m).powi(2)).sum::<f64>() / t.max(1) as f64;
42                var.sqrt()
43            })
44            .collect();
45
46        let mut matrix = vec![vec![0.0_f64; n]; n];
47        for i in 0..n {
48            matrix[i][i] = 1.0;
49            for j in (i + 1)..n {
50                if stds[i] < 1e-12 || stds[j] < 1e-12 {
51                    matrix[i][j] = 0.0;
52                    matrix[j][i] = 0.0;
53                    continue;
54                }
55                let cov: f64 = returns[i]
56                    .iter()
57                    .zip(returns[j].iter())
58                    .map(|(&xi, &xj)| (xi - means[i]) * (xj - means[j]))
59                    .sum::<f64>()
60                    / t.max(1) as f64;
61                let corr = cov / (stds[i] * stds[j]);
62                matrix[i][j] = corr;
63                matrix[j][i] = corr;
64            }
65        }
66
67        Self { matrix, n }
68    }
69
70    /// Get the correlation between assets `i` and `j`.
71    pub fn get(&self, i: usize, j: usize) -> f64 {
72        self.matrix[i][j]
73    }
74
75    /// Test positive definiteness using Sylvester's criterion.
76    ///
77    /// Checks that all leading principal minors are strictly positive.
78    /// Uses Gaussian elimination to compute determinants.
79    pub fn is_positive_definite(&self) -> bool {
80        let n = self.n;
81        if n == 0 {
82            return false;
83        }
84        // Check each leading minor
85        for k in 1..=n {
86            // Extract the k×k leading submatrix
87            let det = leading_minor_det(&self.matrix, k);
88            if det <= 0.0 {
89                return false;
90            }
91        }
92        true
93    }
94
95    /// Approximate the three largest eigenvalues via power iteration with deflation.
96    ///
97    /// Returns up to three eigenvalues (fewer if the matrix is smaller).
98    pub fn eigenvalues_approx(&self) -> Vec<f64> {
99        let n = self.n;
100        let max_k = 3.min(n);
101        let mut eigenvalues = Vec::with_capacity(max_k);
102        // Work on a copy of the matrix for deflation
103        let mut a = self.matrix.clone();
104
105        for _ in 0..max_k {
106            // Power iteration
107            let mut v = vec![1.0_f64; n];
108            let mut lambda = 0.0_f64;
109            for _ in 0..200 {
110                let av = mat_vec_mul(&a, &v);
111                let norm = vec_norm(&av);
112                if norm < 1e-12 {
113                    break;
114                }
115                let new_v: Vec<f64> = av.iter().map(|&x| x / norm).collect();
116                // Rayleigh quotient
117                let av2 = mat_vec_mul(&a, &new_v);
118                lambda = new_v.iter().zip(av2.iter()).map(|(&vi, &avi)| vi * avi).sum();
119                v = new_v;
120            }
121            eigenvalues.push(lambda);
122            // Deflation: A ← A - lambda * v * v^T
123            for i in 0..n {
124                for j in 0..n {
125                    a[i][j] -= lambda * v[i] * v[j];
126                }
127            }
128        }
129
130        eigenvalues
131    }
132
133    /// Approximate condition number: ratio of largest to smallest (absolute) eigenvalue.
134    pub fn condition_number(&self) -> f64 {
135        let eigs = self.eigenvalues_approx();
136        if eigs.is_empty() {
137            return 1.0;
138        }
139        let max_eig = eigs.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
140        let min_eig = eigs.iter().cloned().fold(f64::INFINITY, f64::min);
141        if min_eig.abs() < 1e-12 {
142            return f64::INFINITY;
143        }
144        max_eig.abs() / min_eig.abs()
145    }
146}
147
148/// Compute the determinant of the k×k leading principal submatrix via Gaussian elimination.
149fn leading_minor_det(matrix: &[Vec<f64>], k: usize) -> f64 {
150    // Copy the submatrix
151    let mut a: Vec<Vec<f64>> = (0..k).map(|i| matrix[i][..k].to_vec()).collect();
152    let mut det = 1.0_f64;
153    for col in 0..k {
154        // Find pivot
155        let mut pivot_row = None;
156        for row in col..k {
157            if a[row][col].abs() > 1e-12 {
158                pivot_row = Some(row);
159                break;
160            }
161        }
162        let pr = match pivot_row {
163            Some(r) => r,
164            None => return 0.0,
165        };
166        if pr != col {
167            a.swap(col, pr);
168            det = -det;
169        }
170        det *= a[col][col];
171        let pivot = a[col][col];
172        for row in (col + 1)..k {
173            let factor = a[row][col] / pivot;
174            for c in col..k {
175                let sub = factor * a[col][c];
176                a[row][c] -= sub;
177            }
178        }
179    }
180    det
181}
182
183/// Multiply matrix `a` by vector `v`.
184fn mat_vec_mul(a: &[Vec<f64>], v: &[f64]) -> Vec<f64> {
185    a.iter()
186        .map(|row| row.iter().zip(v.iter()).map(|(&aij, &vj)| aij * vj).sum())
187        .collect()
188}
189
190/// Euclidean norm of a vector.
191fn vec_norm(v: &[f64]) -> f64 {
192    v.iter().map(|&x| x * x).sum::<f64>().sqrt()
193}
194
195// ---------------------------------------------------------------------------
196// Ledoit-Wolf shrinkage
197// ---------------------------------------------------------------------------
198
199/// Ledoit-Wolf analytical shrinkage toward the identity matrix.
200pub struct LedoitWolfShrinkage;
201
202impl LedoitWolfShrinkage {
203    /// Shrink the sample correlation matrix toward the identity.
204    ///
205    /// Returns the shrunk `CorrelationMatrix` and the optimal shrinkage coefficient `alpha`.
206    pub fn shrink(
207        sample_corr: &CorrelationMatrix,
208        returns: &[Vec<f64>],
209    ) -> (CorrelationMatrix, f64) {
210        let alpha = Self::optimal_alpha(returns);
211        let target = Self::target_identity(sample_corr.n);
212        let blended = Self::blend(sample_corr, &target, alpha);
213        (blended, alpha)
214    }
215
216    /// Compute the Oracle Approximating Shrinkage (OAS) coefficient.
217    ///
218    /// Uses a simplified formula: `alpha = (1 - 2/p) * rho_bar / ((T - 1)(1 - rho_bar))`,
219    /// clamped to `[0, 1]`.
220    pub fn optimal_alpha(returns: &[Vec<f64>]) -> f64 {
221        let p = returns.len();
222        if p < 2 {
223            return 0.0;
224        }
225        let t = returns[0].len();
226        if t < 2 {
227            return 0.0;
228        }
229
230        // Compute sample correlations to get average off-diagonal
231        let sample = CorrelationMatrix::from_returns(returns);
232        let mut rho_sum = 0.0_f64;
233        let mut count = 0_usize;
234        for i in 0..p {
235            for j in (i + 1)..p {
236                rho_sum += sample.get(i, j).abs();
237                count += 1;
238            }
239        }
240        let rho_bar = if count > 0 { rho_sum / count as f64 } else { 0.0 };
241
242        let denom = (t as f64 - 1.0) * (1.0 - rho_bar);
243        if denom.abs() < 1e-12 {
244            return 0.5;
245        }
246
247        let alpha = ((1.0 - 2.0 / p as f64) * rho_bar) / denom;
248        alpha.clamp(0.0, 1.0)
249    }
250
251    /// Construct an `n × n` identity correlation matrix.
252    pub fn target_identity(n: usize) -> CorrelationMatrix {
253        let mut matrix = vec![vec![0.0_f64; n]; n];
254        for i in 0..n {
255            matrix[i][i] = 1.0;
256        }
257        CorrelationMatrix { matrix, n }
258    }
259
260    /// Blend sample and target matrices: `(1-alpha) * sample + alpha * target`.
261    pub fn blend(
262        sample: &CorrelationMatrix,
263        target: &CorrelationMatrix,
264        alpha: f64,
265    ) -> CorrelationMatrix {
266        let n = sample.n;
267        let alpha = alpha.clamp(0.0, 1.0);
268        let mut matrix = vec![vec![0.0_f64; n]; n];
269        for i in 0..n {
270            for j in 0..n {
271                matrix[i][j] =
272                    (1.0 - alpha) * sample.matrix[i][j] + alpha * target.matrix[i][j];
273            }
274        }
275        CorrelationMatrix { matrix, n }
276    }
277}
278
279// ---------------------------------------------------------------------------
280// DCC-GARCH utilities
281// ---------------------------------------------------------------------------
282
283/// Simplified DCC-GARCH correlation utilities.
284pub struct DccGarch;
285
286impl DccGarch {
287    /// Compute rolling Pearson correlation between two series using a sliding window.
288    ///
289    /// Returns a `Vec<f64>` of length `series_a.len() - window + 1`.
290    pub fn rolling_correlation(series_a: &[f64], series_b: &[f64], window: usize) -> Vec<f64> {
291        let len = series_a.len().min(series_b.len());
292        if window == 0 || len < window {
293            return vec![];
294        }
295        let mut results = Vec::with_capacity(len - window + 1);
296        for start in 0..=(len - window) {
297            let a = &series_a[start..start + window];
298            let b = &series_b[start..start + window];
299            results.push(pearson_correlation(a, b));
300        }
301        results
302    }
303
304    /// Compute exponentially weighted moving average correlation.
305    ///
306    /// Uses decay factor `lambda` (e.g. 0.94 for RiskMetrics).
307    pub fn ewma_correlation(series_a: &[f64], series_b: &[f64], lambda: f64) -> f64 {
308        let len = series_a.len().min(series_b.len());
309        if len == 0 {
310            return 0.0;
311        }
312
313        // EWMA means
314        let mut mean_a = 0.0_f64;
315        let mut mean_b = 0.0_f64;
316        let mut weight_sum = 0.0_f64;
317
318        let mut w = 1.0_f64;
319        for k in (0..len).rev() {
320            mean_a += w * series_a[k];
321            mean_b += w * series_b[k];
322            weight_sum += w;
323            w *= lambda;
324        }
325        mean_a /= weight_sum;
326        mean_b /= weight_sum;
327
328        // EWMA covariance and variances
329        let mut cov = 0.0_f64;
330        let mut var_a = 0.0_f64;
331        let mut var_b = 0.0_f64;
332        w = 1.0_f64;
333        let mut ws = 0.0_f64;
334        for k in (0..len).rev() {
335            let da = series_a[k] - mean_a;
336            let db = series_b[k] - mean_b;
337            cov += w * da * db;
338            var_a += w * da * da;
339            var_b += w * db * db;
340            ws += w;
341            w *= lambda;
342        }
343        if ws > 0.0 {
344            cov /= ws;
345            var_a /= ws;
346            var_b /= ws;
347        }
348
349        let denom = (var_a * var_b).sqrt();
350        if denom < 1e-12 {
351            0.0
352        } else {
353            (cov / denom).clamp(-1.0, 1.0)
354        }
355    }
356
357    /// Simplified DCC correlation update equation.
358    ///
359    /// `q_t = (1 - a - b) * rho_bar + a * epsilon_{t-1}^2 + b * q_{t-1}`
360    /// where `rho_bar` is a long-run target (approximated here as `prev_corr`).
361    pub fn dcc_update(prev_corr: f64, a: f64, b: f64, epsilon_t: f64) -> f64 {
362        let rho_bar = prev_corr;
363        let q_t = (1.0 - a - b) * rho_bar + a * epsilon_t.powi(2) + b * prev_corr;
364        q_t.clamp(-1.0, 1.0)
365    }
366}
367
368/// Compute Pearson correlation between two slices of equal length.
369fn pearson_correlation(a: &[f64], b: &[f64]) -> f64 {
370    let n = a.len();
371    if n == 0 {
372        return 0.0;
373    }
374    let mean_a = a.iter().sum::<f64>() / n as f64;
375    let mean_b = b.iter().sum::<f64>() / n as f64;
376    let mut cov = 0.0_f64;
377    let mut var_a = 0.0_f64;
378    let mut var_b = 0.0_f64;
379    for (&ai, &bi) in a.iter().zip(b.iter()) {
380        let da = ai - mean_a;
381        let db = bi - mean_b;
382        cov += da * db;
383        var_a += da * da;
384        var_b += db * db;
385    }
386    let denom = (var_a * var_b).sqrt();
387    if denom < 1e-12 {
388        0.0
389    } else {
390        (cov / denom).clamp(-1.0, 1.0)
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    fn sample_returns() -> Vec<Vec<f64>> {
399        vec![
400            vec![0.01, -0.02, 0.03, 0.01, -0.01],
401            vec![0.02, -0.01, 0.02, 0.00, -0.02],
402        ]
403    }
404
405    #[test]
406    fn correlation_diagonal_is_one() {
407        let cm = CorrelationMatrix::from_returns(&sample_returns());
408        assert!((cm.get(0, 0) - 1.0).abs() < 1e-9);
409        assert!((cm.get(1, 1) - 1.0).abs() < 1e-9);
410    }
411
412    #[test]
413    fn correlation_is_symmetric() {
414        let cm = CorrelationMatrix::from_returns(&sample_returns());
415        assert!((cm.get(0, 1) - cm.get(1, 0)).abs() < 1e-12);
416    }
417
418    #[test]
419    fn identity_is_positive_definite() {
420        let id = LedoitWolfShrinkage::target_identity(3);
421        assert!(id.is_positive_definite());
422    }
423
424    #[test]
425    fn shrink_alpha_in_range() {
426        let returns = sample_returns();
427        let alpha = LedoitWolfShrinkage::optimal_alpha(&returns);
428        assert!(alpha >= 0.0 && alpha <= 1.0);
429    }
430
431    #[test]
432    fn rolling_correlation_length() {
433        let a = vec![1.0, 2.0, 3.0, 4.0, 5.0];
434        let b = vec![5.0, 4.0, 3.0, 2.0, 1.0];
435        let rc = DccGarch::rolling_correlation(&a, &b, 3);
436        assert_eq!(rc.len(), 3);
437    }
438
439    #[test]
440    fn ewma_correlation_range() {
441        let a = vec![0.01, -0.02, 0.03, 0.01];
442        let b = vec![0.02, -0.01, 0.02, 0.00];
443        let c = DccGarch::ewma_correlation(&a, &b, 0.94);
444        assert!(c >= -1.0 && c <= 1.0);
445    }
446}