use greeners::{CovarianceType, GreenersError, OLS};
use ndarray::{Array1, Array2};
use greeners::linalg::LinalgInverse as _;
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct GRSTest {
pub grs_f_stat: f64,
pub p_value: f64,
pub df1: usize,
pub df2: usize,
pub t_eff: usize,
pub n_assets: usize,
pub k_factors: usize,
pub alpha_quad_form: f64,
pub denominator: f64,
pub alphas: HashMap<String, f64>,
pub sigma_eps: Array2<f64>,
pub asset_names: Vec<String>,
}
impl GRSTest {
pub fn fit(
returns_excess: &Array2<f64>,
factors: &Array2<f64>,
cov_type: CovarianceType,
asset_names: Option<Vec<String>>,
) -> Result<Self, GreenersError> {
let (t, n) = returns_excess.dim();
let (t_factors, k) = factors.dim();
if t != t_factors {
return Err(GreenersError::ShapeMismatch(format!(
"Returns has {} observations but factors has {}",
t, t_factors
)));
}
if t <= n + k + 1 {
return Err(GreenersError::ShapeMismatch(format!(
"Need T > N + K + 1 for valid GRS test. Got T={}, N={}, K={}",
t, n, k
)));
}
let asset_names = asset_names.unwrap_or_else(|| {
(0..n)
.map(|i| format!("Asset{}", i + 1))
.collect::<Vec<String>>()
});
if asset_names.len() != n {
return Err(GreenersError::ShapeMismatch(format!(
"Expected {} asset names but got {}",
n,
asset_names.len()
)));
}
let mut x_with_intercept = Array2::zeros((t, k + 1));
x_with_intercept.column_mut(0).fill(1.0); for i in 0..k {
x_with_intercept
.column_mut(i + 1)
.assign(&factors.column(i));
}
let mut alphas = HashMap::new();
let mut residuals_list = Vec::new();
let mut valid_assets = Vec::new();
for (j, asset_name) in asset_names.iter().enumerate() {
let y = returns_excess.column(j).to_owned();
match OLS::fit(&y, &x_with_intercept, cov_type.clone()) {
Ok(model) => {
let alpha = model.params[0]; alphas.insert(asset_name.clone(), alpha);
let resid = model.residuals(&y, &x_with_intercept);
residuals_list.push(resid);
valid_assets.push(asset_name.clone());
}
Err(_) => {
continue;
}
}
}
if valid_assets.is_empty() {
return Err(GreenersError::ShapeMismatch(
"No assets could be successfully estimated".to_string(),
));
}
let n_eff = valid_assets.len();
let mut residuals = Array2::zeros((t, n_eff));
for (j, resid) in residuals_list.iter().enumerate() {
residuals.column_mut(j).assign(resid);
}
let sigma_eps = compute_covariance_matrix(&residuals);
let mut alpha_vec = Array1::zeros(n_eff);
for (j, name) in valid_assets.iter().enumerate() {
alpha_vec[j] = alphas[name];
}
let mu_f = compute_mean_vector(factors);
let sigma_f = compute_covariance_matrix(factors);
let (grs_f_stat, p_value, alpha_quad_form, denominator, df1, df2) =
compute_grs_statistic(&alpha_vec, &sigma_eps, &mu_f, &sigma_f, t, n_eff, k)?;
Ok(GRSTest {
grs_f_stat,
p_value,
df1,
df2,
t_eff: t,
n_assets: n_eff,
k_factors: k,
alpha_quad_form,
denominator,
alphas,
sigma_eps,
asset_names: valid_assets,
})
}
pub fn reject_model(&self, alpha: f64) -> bool {
self.p_value < alpha
}
pub fn get_alpha(&self, asset: &str) -> Option<f64> {
self.alphas.get(asset).copied()
}
pub fn model_quality_classification(&self) -> &str {
if self.p_value >= 0.10 {
"Good Fit (all alphas jointly zero)"
} else if self.p_value >= 0.05 {
"Marginal Fit (borderline rejection)"
} else if self.p_value >= 0.01 {
"Poor Fit (rejected at 5%)"
} else {
"Very Poor Fit (rejected at 1%)"
}
}
pub fn to_csv_string(&self) -> String {
let mut csv = String::new();
csv.push_str("Metric,Value\n");
csv.push_str(&format!("GRS F-Statistic,{:.6}\n", self.grs_f_stat));
csv.push_str(&format!("P-value,{:.6}\n", self.p_value));
csv.push_str(&format!("DF Numerator (N),{}\n", self.df1));
csv.push_str(&format!("DF Denominator (T-N-K),{}\n", self.df2));
csv.push_str(&format!("N Assets,{}\n", self.n_assets));
csv.push_str(&format!("K Factors,{}\n", self.k_factors));
csv.push_str(&format!("T Observations,{}\n", self.t_eff));
csv.push_str(&format!("Alpha Quad Form,{:.8}\n", self.alpha_quad_form));
csv.push_str(&format!("Denominator,{:.8}\n", self.denominator));
csv.push_str(&format!(
"Model Quality,{}\n",
self.model_quality_classification()
));
csv.push_str("\nAsset,Alpha\n");
for name in &self.asset_names {
if let Some(alpha) = self.alphas.get(name) {
csv.push_str(&format!("{},{:.8}\n", name, alpha));
}
}
csv
}
pub fn summary_table(&self) -> String {
let mut table = String::new();
table.push_str(&format!("{}\n", "=".repeat(70)));
table.push_str("GRS TEST (Gibbons, Ross & Shanken, 1989)\n");
table.push_str(&format!("{}\n\n", "=".repeat(70)));
table.push_str(&format!(
"GRS F-Statistic: {:.4}\n",
self.grs_f_stat
));
table.push_str(&format!("P-value: {:.4}\n", self.p_value));
table.push_str(&format!(
"Degrees of Freedom: F({}, {})\n",
self.df1, self.df2
));
table.push_str(&format!("N Assets: {}\n", self.n_assets));
table.push_str(&format!("K Factors: {}\n", self.k_factors));
table.push_str(&format!("T Observations: {}\n\n", self.t_eff));
table.push_str(&format!(
"Model Quality: {}\n\n",
self.model_quality_classification()
));
table.push_str(&format!("{}\n", "=".repeat(70)));
table.push_str("INTERPRETATION\n");
table.push_str(&format!("{}\n\n", "=".repeat(70)));
table.push_str("H₀: All alphas are jointly zero (α₁ = α₂ = ... = αₙ = 0)\n");
table.push_str("Hₐ: At least one alpha ≠ 0\n\n");
if self.reject_model(0.05) {
table.push_str("✗ Reject H₀ at 5% significance level\n");
table.push_str(" At least one asset has significant pricing error\n");
table.push_str(" Model fails to jointly price all assets\n");
} else {
table.push_str("✓ Do not reject H₀ at 5% significance level\n");
table.push_str(" All alphas are jointly not significantly different from zero\n");
table.push_str(" Model adequately prices the cross-section of assets\n");
}
table.push_str(&format!("\n{}\n", "=".repeat(70)));
table
}
}
fn compute_mean_vector(data: &Array2<f64>) -> Array1<f64> {
let (_t, k) = data.dim();
let mut means = Array1::zeros(k);
for j in 0..k {
means[j] = data.column(j).mean().unwrap();
}
means
}
fn compute_covariance_matrix(data: &Array2<f64>) -> Array2<f64> {
let (t, n) = data.dim();
let mut cov_matrix = Array2::zeros((n, n));
let means: Vec<f64> = (0..n).map(|j| data.column(j).mean().unwrap()).collect();
for i in 0..n {
for j in 0..n {
let col_i = data.column(i);
let col_j = data.column(j);
let cov = col_i
.iter()
.zip(col_j.iter())
.map(|(xi, xj)| (xi - means[i]) * (xj - means[j]))
.sum::<f64>()
/ (t - 1) as f64;
cov_matrix[[i, j]] = cov;
}
}
cov_matrix
}
fn compute_grs_statistic(
alpha: &Array1<f64>,
sigma_eps: &Array2<f64>,
mu_f: &Array1<f64>,
sigma_f: &Array2<f64>,
t: usize,
n: usize,
k: usize,
) -> Result<(f64, f64, f64, f64, usize, usize), GreenersError> {
let sigma_eps_inv = match sigma_eps.inv() {
Ok(inv) => inv,
Err(_) => {
let mut regularized = sigma_eps.clone();
for i in 0..n {
regularized[[i, i]] += 1e-8;
}
regularized
.inv()
.map_err(|_| GreenersError::SingularMatrix)?
}
};
let sigma_f_inv = match sigma_f.inv() {
Ok(inv) => inv,
Err(_) => {
let mut regularized = sigma_f.clone();
for i in 0..k {
regularized[[i, i]] += 1e-8;
}
regularized
.inv()
.map_err(|_| GreenersError::SingularMatrix)?
}
};
let temp = sigma_eps_inv.dot(alpha);
let alpha_quad_form: f64 = alpha.dot(&temp);
let temp_f = sigma_f_inv.dot(mu_f);
let factor_term: f64 = mu_f.dot(&temp_f);
let denominator = 1.0 + factor_term;
if denominator <= 0.0 {
return Err(GreenersError::ShapeMismatch(
"GRS denominator is non-positive - numerical instability".to_string(),
));
}
let df1 = n;
let df2 = t - n - k;
let f_stat = ((t - n - k) as f64 / n as f64) * (alpha_quad_form / denominator);
let p_value = f_cdf_complement(f_stat, df1, df2);
Ok((f_stat, p_value, alpha_quad_form, denominator, df1, df2))
}
fn f_cdf_complement(x: f64, df1: usize, df2: usize) -> f64 {
if x <= 0.0 {
return 1.0;
}
let d1 = df1 as f64;
let d2 = df2 as f64;
let y = d1 * x / (d1 * x + d2);
incomplete_beta_complement(d2 / 2.0, d1 / 2.0, y)
}
fn incomplete_beta_complement(a: f64, b: f64, x: f64) -> f64 {
if x <= 0.0 {
return 1.0;
}
if x >= 1.0 {
return 0.0;
}
1.0 - incomplete_beta(a, b, x)
}
fn incomplete_beta(a: f64, b: f64, x: f64) -> f64 {
if x <= 0.0 {
return 0.0;
}
if x >= 1.0 {
return 1.0;
}
let ln_beta_ab = ln_beta(a, b);
let front = (a * x.ln() + b * (1.0 - x).ln() - ln_beta_ab).exp();
let cf = beta_continued_fraction(a, b, x);
front * cf / a
}
fn ln_beta(a: f64, b: f64) -> f64 {
ln_gamma(a) + ln_gamma(b) - ln_gamma(a + b)
}
fn beta_continued_fraction(a: f64, b: f64, x: f64) -> f64 {
let max_iter = 100;
let epsilon = 1e-10;
let qab = a + b;
let qap = a + 1.0;
let qam = a - 1.0;
let mut c = 1.0;
let mut d = 1.0 - qab * x / qap;
if d.abs() < 1e-30 {
d = 1e-30;
}
d = 1.0 / d;
let mut h = d;
for m in 1..max_iter {
let m_f = m as f64;
let m2 = 2.0 * m_f;
let aa = m_f * (b - m_f) * x / ((qam + m2) * (a + m2));
d = 1.0 + aa * d;
if d.abs() < 1e-30 {
d = 1e-30;
}
c = 1.0 + aa / c;
if c.abs() < 1e-30 {
c = 1e-30;
}
d = 1.0 / d;
h *= d * c;
let aa = -(a + m_f) * (qab + m_f) * x / ((a + m2) * (qap + m2));
d = 1.0 + aa * d;
if d.abs() < 1e-30 {
d = 1e-30;
}
c = 1.0 + aa / c;
if c.abs() < 1e-30 {
c = 1e-30;
}
d = 1.0 / d;
let delta = d * c;
h *= delta;
if (delta - 1.0).abs() < epsilon {
break;
}
}
h
}
fn ln_gamma(x: f64) -> f64 {
if x > 10.0 {
return (x - 0.5) * x.ln() - x + 0.5 * (2.0 * std::f64::consts::PI).ln() + 1.0 / (12.0 * x)
- 1.0 / (360.0 * x.powi(3));
}
let coefficients = [
76.18009172947146,
-86.50532032941677,
24.01409824083091,
-1.231739572450155,
0.1208650973866179e-2,
-0.5395239384953e-5,
];
let mut y = x;
let mut tmp = x + 5.5;
tmp -= (x + 0.5) * tmp.ln();
let mut ser = 1.000000000190015;
for &coef in coefficients.iter() {
y += 1.0;
ser += coef / y;
}
-tmp + (2.5066282746310005 * ser / x).ln()
}
#[cfg(test)]
mod tests {
use super::*;
use greeners::CovarianceType;
use ndarray::Array2;
#[test]
fn test_grs_basic() {
let t = 120;
let n = 10;
let k = 3;
let mut rng = 12345u64;
let mut rand = || {
rng = rng.wrapping_mul(1103515245).wrapping_add(12345);
((rng / 65536) % 32768) as f64 / 32768.0 - 0.5
};
let factors = Array2::from_shape_fn((t, k), |_| rand() * 0.02);
let returns = Array2::from_shape_fn((t, n), |_| rand() * 0.03);
let result = GRSTest::fit(&returns, &factors, CovarianceType::HC3, None).unwrap();
assert!(result.grs_f_stat >= 0.0);
assert!(result.p_value >= 0.0 && result.p_value <= 1.0);
assert_eq!(result.n_assets, n);
assert_eq!(result.k_factors, k);
assert_eq!(result.t_eff, t);
assert_eq!(result.df1, n);
assert_eq!(result.df2, t - n - k);
}
#[test]
fn test_grs_well_specified_model() {
let t = 150;
let n = 8;
let k = 2;
let mut rng = 42u64;
let mut rand = || {
rng = rng.wrapping_mul(1103515245).wrapping_add(12345);
((rng / 65536) % 32768) as f64 / 32768.0 - 0.5
};
let factors = Array2::from_shape_fn((t, k), |_| rand() * 0.02);
let mut returns = Array2::zeros((t, n));
for i in 0..t {
for j in 0..n {
let beta1 = 0.8 + j as f64 * 0.1;
let beta2 = 0.5 + j as f64 * 0.05;
returns[[i, j]] =
factors[[i, 0]] * beta1 + factors[[i, 1]] * beta2 + rand() * 0.001;
}
}
let result = GRSTest::fit(&returns, &factors, CovarianceType::HC3, None).unwrap();
assert!(
result.p_value > 0.05,
"Should not reject well-specified model: p-value = {}",
result.p_value
);
}
#[test]
fn test_grs_asset_names() {
let t = 100;
let n = 5;
let k = 2;
let mut rng = 9999u64;
let mut rand = || {
rng = rng.wrapping_mul(1103515245).wrapping_add(12345);
((rng / 65536) % 32768) as f64 / 32768.0 - 0.5
};
let factors = Array2::from_shape_fn((t, k), |_| rand() * 0.02);
let returns = Array2::from_shape_fn((t, n), |_| rand() * 0.03);
let asset_names = vec![
"Stock A".to_string(),
"Stock B".to_string(),
"Stock C".to_string(),
"Stock D".to_string(),
"Stock E".to_string(),
];
let result = GRSTest::fit(
&returns,
&factors,
CovarianceType::HC3,
Some(asset_names.clone()),
)
.unwrap();
for name in &asset_names {
assert!(result.get_alpha(name).is_some());
}
}
#[test]
fn test_reject_model() {
let t = 120;
let n = 12;
let k = 2;
let mut rng = 7777u64;
let mut rand = || {
rng = rng.wrapping_mul(1103515245).wrapping_add(12345);
((rng / 65536) % 32768) as f64 / 32768.0 - 0.5
};
let factors = Array2::from_shape_fn((t, k), |_| rand() * 0.02);
let returns = Array2::from_shape_fn((t, n), |_| rand() * 0.03);
let result = GRSTest::fit(&returns, &factors, CovarianceType::HC3, None).unwrap();
let reject_at_10pct = result.reject_model(0.10);
let reject_at_5pct = result.reject_model(0.05);
let reject_at_1pct = result.reject_model(0.01);
if reject_at_1pct {
assert!(reject_at_5pct);
assert!(reject_at_10pct);
}
}
#[test]
fn test_csv_export() {
let t = 80;
let n = 6;
let k = 2;
let mut rng = 5555u64;
let mut rand = || {
rng = rng.wrapping_mul(1103515245).wrapping_add(12345);
((rng / 65536) % 32768) as f64 / 32768.0 - 0.5
};
let factors = Array2::from_shape_fn((t, k), |_| rand() * 0.02);
let returns = Array2::from_shape_fn((t, n), |_| rand() * 0.03);
let result = GRSTest::fit(&returns, &factors, CovarianceType::HC3, None).unwrap();
let csv = result.to_csv_string();
assert!(csv.contains("GRS F-Statistic"));
assert!(csv.contains("P-value"));
assert!(csv.contains("Asset,Alpha"));
}
#[test]
fn test_summary_table() {
let t = 100;
let n = 8;
let k = 3;
let mut rng = 3333u64;
let mut rand = || {
rng = rng.wrapping_mul(1103515245).wrapping_add(12345);
((rng / 65536) % 32768) as f64 / 32768.0 - 0.5
};
let factors = Array2::from_shape_fn((t, k), |_| rand() * 0.02);
let returns = Array2::from_shape_fn((t, n), |_| rand() * 0.03);
let result = GRSTest::fit(&returns, &factors, CovarianceType::HC3, None).unwrap();
let summary = result.summary_table();
assert!(summary.contains("GRS TEST"));
assert!(summary.contains("GRS F-Statistic"));
assert!(summary.contains("INTERPRETATION"));
}
}