use crate::error::FinError;
use std::collections::VecDeque;
#[derive(Debug)]
pub struct CrossAssetCorrelation {
names: Vec<String>,
n: usize,
window: usize,
buf: VecDeque<Vec<f64>>,
}
impl CrossAssetCorrelation {
pub fn new(names: Vec<String>, window: usize) -> Result<Self, FinError> {
if names.len() < 2 {
return Err(FinError::InvalidInput(
"CrossAssetCorrelation requires at least 2 instruments".to_owned(),
));
}
if window < 2 {
return Err(FinError::InvalidPeriod(window));
}
for (i, name) in names.iter().enumerate() {
for (j, other) in names.iter().enumerate() {
if i != j && name == other {
return Err(FinError::InvalidInput(format!(
"duplicate instrument name: '{name}'"
)));
}
}
}
let n = names.len();
Ok(Self { names, n, window, buf: VecDeque::with_capacity(window) })
}
pub fn names(&self) -> &[String] {
&self.names
}
pub fn n_instruments(&self) -> usize {
self.n
}
pub fn window(&self) -> usize {
self.window
}
pub fn sample_count(&self) -> usize {
self.buf.len()
}
pub fn is_ready(&self) -> bool {
self.buf.len() >= self.window
}
pub fn update(&mut self, returns: &[f64]) -> Result<(), FinError> {
if returns.len() != self.n {
return Err(FinError::InvalidInput(format!(
"expected {} returns, got {}",
self.n,
returns.len()
)));
}
for (i, r) in returns.iter().enumerate() {
if !r.is_finite() {
return Err(FinError::InvalidInput(format!(
"return[{i}] is not finite: {r}"
)));
}
}
self.buf.push_back(returns.to_vec());
if self.buf.len() > self.window {
self.buf.pop_front();
}
Ok(())
}
pub fn get(&self, i: usize, j: usize) -> Option<f64> {
if !self.is_ready() || i >= self.n || j >= self.n {
return None;
}
if i == j {
return Some(1.0);
}
let n = self.buf.len() as f64;
let mut sx = 0.0_f64;
let mut sy = 0.0_f64;
let mut sxy = 0.0_f64;
let mut sx2 = 0.0_f64;
let mut sy2 = 0.0_f64;
for row in &self.buf {
let x = row[i];
let y = row[j];
sx += x;
sy += y;
sxy += x * y;
sx2 += x * x;
sy2 += y * y;
}
let num = n * sxy - sx * sy;
let den_sq = (n * sx2 - sx * sx) * (n * sy2 - sy * sy);
if den_sq <= 0.0 {
return None;
}
Some((num / den_sq.sqrt()).clamp(-1.0, 1.0))
}
pub fn index_of(&self, name: &str) -> Option<usize> {
self.names.iter().position(|n| n == name)
}
pub fn correlation_matrix(&self) -> Option<CorrelationMatrix> {
if !self.is_ready() {
return None;
}
let mut mat = vec![0.0_f64; self.n * self.n];
for i in 0..self.n {
for j in 0..self.n {
mat[i * self.n + j] = self.get(i, j).unwrap_or(0.0);
}
}
Some(CorrelationMatrix {
n: self.n,
data: mat,
names: self.names.clone(),
})
}
pub fn reset(&mut self) {
self.buf.clear();
}
}
#[derive(Debug, Clone)]
pub struct CorrelationMatrix {
n: usize,
data: Vec<f64>,
names: Vec<String>,
}
impl CorrelationMatrix {
pub fn get(&self, i: usize, j: usize) -> Option<f64> {
if i >= self.n || j >= self.n {
return None;
}
Some(self.data[i * self.n + j])
}
pub fn data(&self) -> &[f64] {
&self.data
}
pub fn n(&self) -> usize {
self.n
}
pub fn names(&self) -> &[String] {
&self.names
}
pub fn most_correlated_with(&self, i: usize) -> Vec<(usize, f64)> {
if i >= self.n {
return vec![];
}
let mut pairs: Vec<(usize, f64)> = (0..self.n)
.filter(|&j| j != i)
.filter_map(|j| self.get(i, j).map(|r| (j, r)))
.collect();
pairs.sort_by(|a, b| {
b.1.abs()
.partial_cmp(&a.1.abs())
.unwrap_or(std::cmp::Ordering::Equal)
});
pairs
}
}
#[derive(Debug, Clone)]
pub struct PrincipalComponents {
k: usize,
components: Vec<Vec<f64>>,
eigenvalues: Vec<f64>,
total_variance: f64,
}
impl PrincipalComponents {
pub fn from_matrix(matrix: &CorrelationMatrix, k: usize) -> Result<Self, FinError> {
let n = matrix.n();
if n < 2 {
return Err(FinError::InvalidInput(
"PCA requires at least 2 instruments".to_owned(),
));
}
if k == 0 {
return Err(FinError::InvalidInput("k must be at least 1".to_owned()));
}
let k_actual = k.min(n - 1).min(3);
let total_variance = n as f64;
let mut work: Vec<f64> = matrix.data().to_vec();
let mut components = Vec::with_capacity(k_actual);
let mut eigenvalues = Vec::with_capacity(k_actual);
for _ in 0..k_actual {
let (eigval, eigvec) = power_iterate(&work, n, 200, 1e-8);
if eigval <= 0.0 {
break;
}
for i in 0..n {
for j in 0..n {
work[i * n + j] -= eigval * eigvec[i] * eigvec[j];
}
}
eigenvalues.push(eigval);
components.push(eigvec);
}
Ok(Self { k: components.len(), components, eigenvalues, total_variance })
}
pub fn k(&self) -> usize {
self.k
}
pub fn components(&self) -> &[Vec<f64>] {
&self.components
}
pub fn eigenvalues(&self) -> &[f64] {
&self.eigenvalues
}
pub fn explained_variance_ratio(&self) -> Vec<f64> {
if self.total_variance <= 0.0 {
return vec![0.0; self.k];
}
self.eigenvalues.iter().map(|e| e / self.total_variance).collect()
}
pub fn project(&self, returns: &[f64], m: usize) -> Vec<f64> {
let take = m.min(self.k);
self.components[..take]
.iter()
.map(|pc| pc.iter().zip(returns.iter()).map(|(a, b)| a * b).sum())
.collect()
}
}
fn power_iterate(matrix: &[f64], n: usize, max_iter: usize, tol: f64) -> (f64, Vec<f64>) {
let mut v: Vec<f64> = vec![1.0 / (n as f64).sqrt(); n];
for _ in 0..max_iter {
let mut w = vec![0.0_f64; n];
for i in 0..n {
for j in 0..n {
w[i] += matrix[i * n + j] * v[j];
}
}
let norm: f64 = w.iter().map(|x| x * x).sum::<f64>().sqrt();
if norm == 0.0 {
return (0.0, vec![0.0; n]);
}
let new_v: Vec<f64> = w.iter().map(|x| x / norm).collect();
let diff: f64 = v.iter().zip(&new_v).map(|(a, b)| (a - b).powi(2)).sum::<f64>().sqrt();
v = new_v;
if diff < tol {
break;
}
}
let mut eigenvalue = 0.0_f64;
for i in 0..n {
let mut av_i = 0.0_f64;
for j in 0..n {
av_i += matrix[i * n + j] * v[j];
}
eigenvalue += v[i] * av_i;
}
(eigenvalue, v)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_cac(n: usize, window: usize) -> CrossAssetCorrelation {
let names: Vec<String> = (0..n).map(|i| format!("asset{i}")).collect();
CrossAssetCorrelation::new(names, window).unwrap()
}
#[test]
fn test_too_few_assets_fails() {
assert!(CrossAssetCorrelation::new(vec!["A".into()], 10).is_err());
}
#[test]
fn test_duplicate_name_fails() {
assert!(CrossAssetCorrelation::new(
vec!["A".into(), "A".into()],
10
)
.is_err());
}
#[test]
fn test_window_1_fails() {
assert!(CrossAssetCorrelation::new(vec!["A".into(), "B".into()], 1).is_err());
}
#[test]
fn test_not_ready_before_window() {
let mut cac = make_cac(2, 5);
cac.update(&[0.01, 0.02]).unwrap();
assert!(!cac.is_ready());
assert!(cac.correlation_matrix().is_none());
}
#[test]
fn test_perfect_correlation_detected() {
let mut cac = make_cac(2, 5);
for i in 1..=5 {
cac.update(&[i as f64 * 0.01, i as f64 * 0.01]).unwrap();
}
let r = cac.get(0, 1).unwrap();
assert!((r - 1.0).abs() < 1e-9, "r={r}");
}
#[test]
fn test_self_correlation_is_one() {
let mut cac = make_cac(2, 5);
for i in 1..=5 {
cac.update(&[i as f64 * 0.01, i as f64 * 0.02]).unwrap();
}
let r = cac.get(0, 0).unwrap();
assert_eq!(r, 1.0);
}
#[test]
fn test_correlation_matrix_shape() {
let mut cac = make_cac(3, 4);
for i in 1..=4 {
cac.update(&[i as f64, i as f64 * 2.0, i as f64 * 0.5]).unwrap();
}
let mat = cac.correlation_matrix().unwrap();
assert_eq!(mat.data().len(), 9);
assert_eq!(mat.n(), 3);
assert!((mat.get(0, 0).unwrap() - 1.0).abs() < 1e-9);
assert!((mat.get(1, 1).unwrap() - 1.0).abs() < 1e-9);
assert!((mat.get(2, 2).unwrap() - 1.0).abs() < 1e-9);
}
#[test]
fn test_wrong_length_update_fails() {
let mut cac = make_cac(3, 5);
assert!(cac.update(&[0.01, 0.02]).is_err());
}
#[test]
fn test_nan_update_fails() {
let mut cac = make_cac(2, 5);
assert!(cac.update(&[f64::NAN, 0.01]).is_err());
}
#[test]
fn test_index_of() {
let cac = CrossAssetCorrelation::new(
vec!["SPY".into(), "QQQ".into()],
5,
)
.unwrap();
assert_eq!(cac.index_of("SPY"), Some(0));
assert_eq!(cac.index_of("QQQ"), Some(1));
assert_eq!(cac.index_of("MISSING"), None);
}
#[test]
fn test_reset_clears_buffer() {
let mut cac = make_cac(2, 3);
for i in 0..3 {
cac.update(&[i as f64, i as f64 * 2.0]).unwrap();
}
assert!(cac.is_ready());
cac.reset();
assert!(!cac.is_ready());
assert_eq!(cac.sample_count(), 0);
}
#[test]
fn test_most_correlated_with_sorted() {
let mut cac = make_cac(3, 5);
for i in 1..=5 {
let v = i as f64;
cac.update(&[v, v * 2.0, -v]).unwrap();
}
let mat = cac.correlation_matrix().unwrap();
let corrs = mat.most_correlated_with(0);
assert_eq!(corrs.len(), 2);
assert!(corrs[0].1.abs() >= corrs[1].1.abs());
}
#[test]
fn test_pca_explained_variance_sums_to_at_most_one() {
let mut cac = make_cac(3, 10);
for i in 1..=10 {
let v = i as f64;
cac.update(&[v, v * 1.1, v * 0.9]).unwrap();
}
let mat = cac.correlation_matrix().unwrap();
let pca = PrincipalComponents::from_matrix(&mat, 3).unwrap();
let total: f64 = pca.explained_variance_ratio().iter().sum();
assert!(total <= 1.0 + 1e-9, "total explained variance ratio={total}");
assert!(total >= 0.0);
}
#[test]
fn test_pca_k_zero_fails() {
let mut cac = make_cac(2, 5);
for i in 1..=5 {
cac.update(&[i as f64, i as f64 * 2.0]).unwrap();
}
let mat = cac.correlation_matrix().unwrap();
assert!(PrincipalComponents::from_matrix(&mat, 0).is_err());
}
#[test]
fn test_pca_project_length() {
let mut cac = make_cac(3, 10);
for i in 1..=10 {
let v = i as f64;
cac.update(&[v, v * 1.5, -v]).unwrap();
}
let mat = cac.correlation_matrix().unwrap();
let pca = PrincipalComponents::from_matrix(&mat, 2).unwrap();
let proj = pca.project(&[0.01, 0.02, -0.01], 2);
assert!(proj.len() <= 2);
}
#[test]
fn test_pca_first_eigenvalue_largest() {
let mut cac = make_cac(3, 15);
for i in 1..=15 {
let v = i as f64;
cac.update(&[v, v * 1.2, v * 0.8]).unwrap();
}
let mat = cac.correlation_matrix().unwrap();
let pca = PrincipalComponents::from_matrix(&mat, 3).unwrap();
let evs = pca.eigenvalues();
if evs.len() >= 2 {
assert!(evs[0] >= evs[1], "first eigenvalue should be largest");
}
}
}