use nalgebra::DMatrix;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum CovarianceError {
#[error("insufficient observations: T={observations} < N={assets} (rank-deficient)")]
InsufficientObservations { observations: usize, assets: usize },
#[error("empty returns matrix")]
EmptyMatrix,
#[error("zero variance for asset at column index {column}")]
ZeroVariance { column: usize },
#[error("half-life must be positive, got {0}")]
InvalidHalfLife(f64),
}
#[derive(Debug, Clone)]
pub struct CovarianceResult {
pub correlation: DMatrix<f64>,
pub std_devs: Vec<f64>,
pub observations: usize,
pub assets: usize,
pub q: f64,
}
#[allow(clippy::cast_precision_loss)]
pub fn correlation_matrix(returns: &DMatrix<f64>) -> Result<CovarianceResult, CovarianceError> {
let (num_obs, num_assets) = returns.shape();
if num_obs == 0 || num_assets == 0 {
return Err(CovarianceError::EmptyMatrix);
}
if num_obs < 2 {
return Err(CovarianceError::InsufficientObservations {
observations: num_obs,
assets: num_assets,
});
}
let (standardized, std_devs) = standardize(returns)?;
let corr = (standardized.transpose() * &standardized) / num_obs as f64;
Ok(CovarianceResult {
correlation: corr,
std_devs,
observations: num_obs,
assets: num_assets,
q: num_obs as f64 / num_assets as f64,
})
}
#[allow(clippy::cast_precision_loss)]
pub fn ewm_correlation_matrix(
returns: &DMatrix<f64>,
half_life: f64,
) -> Result<CovarianceResult, CovarianceError> {
if half_life <= 0.0 || !half_life.is_finite() {
return Err(CovarianceError::InvalidHalfLife(half_life));
}
let (num_obs, num_assets) = returns.shape();
if num_obs == 0 || num_assets == 0 {
return Err(CovarianceError::EmptyMatrix);
}
if num_obs < 2 {
return Err(CovarianceError::InsufficientObservations {
observations: num_obs,
assets: num_assets,
});
}
let decay = (-(2.0_f64.ln()) / half_life).exp();
let weights: Vec<f64> = (0..num_obs)
.map(|idx| decay.powf((num_obs - 1 - idx) as f64))
.collect();
let weight_sum: f64 = weights.iter().sum();
let mut means = vec![0.0_f64; num_assets];
for (obs_idx, weight) in weights.iter().enumerate() {
for col in 0..num_assets {
means[col] += weight * returns[(obs_idx, col)];
}
}
for mean in &mut means {
*mean /= weight_sum;
}
let mut variances = vec![0.0_f64; num_assets];
for (obs_idx, weight) in weights.iter().enumerate() {
for col in 0..num_assets {
let diff = returns[(obs_idx, col)] - means[col];
variances[col] += weight * diff * diff;
}
}
for var in &mut variances {
*var /= weight_sum;
}
for (col, &var) in variances.iter().enumerate() {
if var < f64::EPSILON {
return Err(CovarianceError::ZeroVariance { column: col });
}
}
let std_devs: Vec<f64> = variances.iter().map(|var| var.sqrt()).collect();
let mut corr = DMatrix::zeros(num_assets, num_assets);
for (obs_idx, weight) in weights.iter().enumerate() {
for row in 0..num_assets {
let std_row = (returns[(obs_idx, row)] - means[row]) / std_devs[row];
for col in row..num_assets {
let std_col = (returns[(obs_idx, col)] - means[col]) / std_devs[col];
let contribution = weight * std_row * std_col;
corr[(row, col)] += contribution;
if row != col {
corr[(col, row)] += contribution;
}
}
}
}
corr /= weight_sum;
Ok(CovarianceResult {
correlation: corr,
std_devs,
observations: num_obs,
assets: num_assets,
q: num_obs as f64 / num_assets as f64,
})
}
#[must_use]
pub fn covariance_from_correlation(correlation: &DMatrix<f64>, std_devs: &[f64]) -> DMatrix<f64> {
let n = correlation.nrows();
let mut cov = correlation.clone();
for i in 0..n {
for j in 0..n {
cov[(i, j)] *= std_devs[i] * std_devs[j];
}
}
cov
}
#[allow(clippy::cast_precision_loss)]
fn standardize(returns: &DMatrix<f64>) -> Result<(DMatrix<f64>, Vec<f64>), CovarianceError> {
let (num_obs, num_assets) = returns.shape();
let mut result = returns.clone();
let mut std_devs = Vec::with_capacity(num_assets);
for col in 0..num_assets {
let column = returns.column(col);
let mean = column.mean();
let variance = column.iter().map(|&val| (val - mean).powi(2)).sum::<f64>() / num_obs as f64;
if variance < f64::EPSILON {
return Err(CovarianceError::ZeroVariance { column: col });
}
let std_dev = variance.sqrt();
std_devs.push(std_dev);
for row in 0..num_obs {
result[(row, col)] = (result[(row, col)] - mean) / std_dev;
}
}
Ok((result, std_devs))
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
#[test]
fn test_correlation_identity() {
#[rustfmt::skip]
let returns = DMatrix::from_row_slice(4, 2, &[
1.0, 0.0,
-1.0, 0.0,
0.0, 1.0,
0.0, -1.0,
]);
let result = correlation_matrix(&returns).unwrap();
assert_eq!(result.assets, 2);
assert_eq!(result.observations, 4);
assert_relative_eq!(result.q, 2.0, epsilon = 1e-10);
assert_relative_eq!(result.correlation[(0, 0)], 1.0, epsilon = 1e-10);
assert_relative_eq!(result.correlation[(1, 1)], 1.0, epsilon = 1e-10);
assert_relative_eq!(result.correlation[(0, 1)], 0.0, epsilon = 1e-10);
assert_relative_eq!(result.correlation[(1, 0)], 0.0, epsilon = 1e-10);
}
#[test]
fn test_correlation_perfect() {
#[rustfmt::skip]
let returns = DMatrix::from_row_slice(4, 2, &[
1.0, 1.0,
2.0, 2.0,
3.0, 3.0,
4.0, 4.0,
]);
let result = correlation_matrix(&returns).unwrap();
assert_relative_eq!(result.correlation[(0, 0)], 1.0, epsilon = 1e-10);
assert_relative_eq!(result.correlation[(1, 1)], 1.0, epsilon = 1e-10);
assert_relative_eq!(result.correlation[(0, 1)], 1.0, epsilon = 1e-10);
}
#[test]
fn test_trace_equals_n() {
#[rustfmt::skip]
let returns = DMatrix::from_row_slice(5, 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,
]);
let result = correlation_matrix(&returns).unwrap();
let trace: f64 = (0..3).map(|i| result.correlation[(i, i)]).sum();
assert_relative_eq!(trace, 3.0, epsilon = 1e-10);
}
#[test]
fn test_symmetry() {
#[rustfmt::skip]
let returns = DMatrix::from_row_slice(5, 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,
]);
let result = correlation_matrix(&returns).unwrap();
for row in 0..3 {
for col in 0..3 {
assert_relative_eq!(
result.correlation[(row, col)],
result.correlation[(col, row)],
epsilon = 1e-14
);
}
}
}
#[test]
fn test_empty_matrix() {
let returns = DMatrix::<f64>::zeros(0, 0);
assert!(correlation_matrix(&returns).is_err());
}
#[test]
fn test_insufficient_observations() {
let returns = DMatrix::from_row_slice(1, 2, &[1.0, 2.0]);
assert!(correlation_matrix(&returns).is_err());
}
#[test]
fn test_zero_variance() {
#[rustfmt::skip]
let returns = DMatrix::from_row_slice(3, 2, &[
1.0, 5.0,
2.0, 5.0,
3.0, 5.0,
]);
assert!(matches!(
correlation_matrix(&returns),
Err(CovarianceError::ZeroVariance { column: 1 })
));
}
#[test]
fn test_ewm_invalid_half_life() {
let returns = DMatrix::from_row_slice(3, 2, &[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
assert!(ewm_correlation_matrix(&returns, 0.0).is_err());
assert!(ewm_correlation_matrix(&returns, -1.0).is_err());
assert!(ewm_correlation_matrix(&returns, f64::NAN).is_err());
}
#[test]
fn test_ewm_large_halflife_approximates_equal() {
#[rustfmt::skip]
let returns = DMatrix::from_row_slice(5, 2, &[
0.01, -0.02,
-0.01, 0.01,
0.02, 0.00,
-0.03, 0.02,
0.01, -0.01,
]);
let equal = correlation_matrix(&returns).unwrap();
let ewm = ewm_correlation_matrix(&returns, 1e6).unwrap();
for row in 0..2 {
for col in 0..2 {
assert_relative_eq!(
equal.correlation[(row, col)],
ewm.correlation[(row, col)],
epsilon = 1e-4
);
}
}
}
}