use nalgebra::{DMatrix, DVector};
use thiserror::Error;
use super::eigen::EigenDecomposition;
#[derive(Debug, Error)]
pub enum ShrinkageError {
#[error("need T ≥ 2, got {0}")]
InsufficientObservations(usize),
#[error("empty returns matrix")]
EmptyReturns,
}
#[derive(Debug, Clone)]
pub struct LinearShrinkageResult {
pub matrix: DMatrix<f64>,
pub intensity: f64,
}
#[derive(Debug, Clone)]
pub struct NonlinearShrinkageResult {
pub matrix: DMatrix<f64>,
pub eigenvalues: DVector<f64>,
}
#[allow(clippy::cast_precision_loss)]
pub fn linear_shrinkage(
returns: &DMatrix<f64>,
sample_corr: &DMatrix<f64>,
) -> Result<LinearShrinkageResult, ShrinkageError> {
let (num_obs, num_assets) = returns.shape();
if num_obs == 0 || num_assets == 0 {
return Err(ShrinkageError::EmptyReturns);
}
if num_obs < 2 {
return Err(ShrinkageError::InsufficientObservations(num_obs));
}
let num_obs_f = num_obs as f64;
let mut sum_off_diag = 0.0;
let mut count_off_diag = 0_u64;
for row in 0..num_assets {
for col in (row + 1)..num_assets {
sum_off_diag += sample_corr[(row, col)];
count_off_diag += 1;
}
}
let mean_corr = if count_off_diag > 0 {
sum_off_diag / count_off_diag as f64
} else {
0.0
};
let mut target = DMatrix::from_element(num_assets, num_assets, mean_corr);
for idx in 0..num_assets {
target[(idx, idx)] = 1.0;
}
let diff = sample_corr - ⌖
let frob_sq: f64 = diff.iter().map(|val| val * val).sum();
let mut pi_hat = 0.0;
for obs in 0..num_obs {
for row in 0..num_assets {
for col in 0..num_assets {
let cross = returns[(obs, row)] * returns[(obs, col)];
let err = cross - sample_corr[(row, col)];
pi_hat += err * err;
}
}
}
pi_hat /= num_obs_f;
let rho_hat = pi_hat;
let gamma_hat = frob_sq;
let kappa = if gamma_hat > f64::EPSILON {
(pi_hat - rho_hat) / gamma_hat
} else {
0.0
};
let intensity = (kappa / num_obs_f).clamp(0.0, 1.0);
let shrunk = &target * intensity + sample_corr * (1.0 - intensity);
Ok(LinearShrinkageResult {
matrix: shrunk,
intensity,
})
}
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn nonlinear_shrinkage(eigen: &EigenDecomposition, num_obs: usize) -> NonlinearShrinkageResult {
let num_assets = eigen.n;
let num_obs_f = num_obs as f64;
let num_assets_f = num_assets as f64;
let ratio = num_assets_f / num_obs_f;
let eigenvalues = &eigen.eigenvalues;
let mut shrunk_eigenvalues = DVector::zeros(num_assets);
for idx in 0..num_assets {
let lambda = eigenvalues[idx];
let mut hilbert = 0.0;
for jdx in 0..num_assets {
if jdx != idx {
let diff = eigenvalues[jdx] - lambda;
if diff.abs() > f64::EPSILON {
hilbert += 1.0 / diff;
}
}
}
hilbert /= num_assets_f;
let hilbert_term = lambda * hilbert;
let denom = std::f64::consts::PI.powi(2) * lambda.powi(2) * hilbert.powi(2) * ratio.powi(2)
+ (1.0 - ratio - ratio * hilbert_term).powi(2);
shrunk_eigenvalues[idx] = if denom > f64::EPSILON {
lambda / denom
} else {
lambda
};
}
let original_trace = eigenvalues.sum();
let shrunk_trace: f64 = shrunk_eigenvalues.iter().sum();
if shrunk_trace > f64::EPSILON {
shrunk_eigenvalues *= original_trace / shrunk_trace;
}
let lambda_diag = DMatrix::from_diagonal(&shrunk_eigenvalues);
let matrix = &eigen.eigenvectors * lambda_diag * eigen.eigenvectors.transpose();
NonlinearShrinkageResult {
matrix,
eigenvalues: shrunk_eigenvalues,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::math::eigen::eigendecompose;
use crate::math::sample_covariance::correlation_matrix;
use approx::assert_relative_eq;
fn sample_data() -> (DMatrix<f64>, DMatrix<f64>) {
#[rustfmt::skip]
let returns = DMatrix::from_row_slice(10, 3, &[
0.01, -0.02, 0.03,
-0.01, 0.01, -0.02,
0.02, 0.00, 0.01,
-0.03, 0.02, 0.00,
0.01, -0.01, 0.02,
0.00, 0.03, -0.01,
-0.02, 0.01, 0.03,
0.03, -0.02, -0.01,
-0.01, 0.00, 0.02,
0.02, 0.01, -0.03,
]);
let cov = correlation_matrix(&returns).unwrap();
let (num_obs, num_assets) = returns.shape();
let num_obs_f = f64::from(u32::try_from(num_obs).expect("num_obs fits in u32"));
let mut standardized = returns.clone();
for col in 0..num_assets {
let column = returns.column(col);
let mean = column.mean();
let var = column.iter().map(|&val| (val - mean).powi(2)).sum::<f64>() / num_obs_f;
let std = var.sqrt();
for row in 0..num_obs {
standardized[(row, col)] = (standardized[(row, col)] - mean) / std;
}
}
(standardized, cov.correlation)
}
#[test]
fn test_linear_intensity_bounded() {
let (returns, corr) = sample_data();
let result = linear_shrinkage(&returns, &corr).unwrap();
assert!(result.intensity >= 0.0);
assert!(result.intensity <= 1.0);
}
#[test]
fn test_linear_symmetry() {
let (returns, corr) = sample_data();
let result = linear_shrinkage(&returns, &corr).unwrap();
for row in 0..3 {
for col in 0..3 {
assert_relative_eq!(
result.matrix[(row, col)],
result.matrix[(col, row)],
epsilon = 1e-14
);
}
}
}
#[test]
fn test_linear_diagonal_ones() {
let (returns, corr) = sample_data();
let result = linear_shrinkage(&returns, &corr).unwrap();
for idx in 0..3 {
assert_relative_eq!(result.matrix[(idx, idx)], 1.0, epsilon = 1e-10);
}
}
#[test]
fn test_linear_trace() {
let (returns, corr) = sample_data();
let result = linear_shrinkage(&returns, &corr).unwrap();
let trace: f64 = (0..3).map(|i| result.matrix[(i, i)]).sum();
assert_relative_eq!(trace, 3.0, epsilon = 1e-10);
}
#[test]
fn test_linear_empty() {
let returns = DMatrix::<f64>::zeros(0, 0);
let corr = DMatrix::<f64>::zeros(0, 0);
assert!(linear_shrinkage(&returns, &corr).is_err());
}
#[test]
fn test_nonlinear_symmetry() {
let (_, corr) = sample_data();
let eigen = eigendecompose(&corr).unwrap();
let result = nonlinear_shrinkage(&eigen, 10);
for row in 0..3 {
for col in 0..3 {
assert_relative_eq!(
result.matrix[(row, col)],
result.matrix[(col, row)],
epsilon = 1e-12
);
}
}
}
#[test]
fn test_nonlinear_trace() {
let (_, corr) = sample_data();
let eigen = eigendecompose(&corr).unwrap();
let result = nonlinear_shrinkage(&eigen, 10);
let trace: f64 = result.eigenvalues.iter().sum();
assert_relative_eq!(trace, 3.0, epsilon = 1e-10);
}
#[test]
fn test_nonlinear_psd() {
let (_, corr) = sample_data();
let eigen = eigendecompose(&corr).unwrap();
let result = nonlinear_shrinkage(&eigen, 10);
for idx in 0..result.eigenvalues.len() {
assert!(
result.eigenvalues[idx] >= -1e-10,
"eigenvalue {} is negative: {}",
idx,
result.eigenvalues[idx]
);
}
}
#[test]
fn test_condition_number_improvement() {
let (returns, corr) = sample_data();
let eigen = eigendecompose(&corr).unwrap();
let raw_cond = eigen.eigenvalues[0] / eigen.eigenvalues[eigen.n - 1];
let linear = linear_shrinkage(&returns, &corr).unwrap();
let linear_eigen = eigendecompose(&linear.matrix).unwrap();
let linear_cond =
linear_eigen.eigenvalues[0] / linear_eigen.eigenvalues[linear_eigen.n - 1];
let nonlinear = nonlinear_shrinkage(&eigen, 10);
let nl_eigen = eigendecompose(&nonlinear.matrix).unwrap();
let nl_cond = nl_eigen.eigenvalues[0] / nl_eigen.eigenvalues[nl_eigen.n - 1];
assert!(
linear_cond <= raw_cond + 1e-10,
"linear shrinkage should not worsen condition: raw={raw_cond}, linear={linear_cond}"
);
assert!(
nl_cond <= raw_cond + 1e-10,
"nonlinear shrinkage should not worsen condition: raw={raw_cond}, nl={nl_cond}"
);
}
}