use crate::error::{NumRs2Error, Result};
use scirs2_core::ndarray::{s, Array1, Array2, ArrayView1, ArrayView2, Axis};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum TrendSpec {
None,
Constant,
ConstantTrend,
}
#[derive(Debug, Clone)]
pub struct EngleGrangerResult {
pub adf_statistic: f64,
pub p_value: f64,
pub ols_coefficients: Array1<f64>,
pub residuals: Array1<f64>,
pub critical_values: [f64; 3],
pub cointegrated: bool,
}
#[derive(Debug, Clone)]
pub struct JohansenResult {
pub trace_statistics: Array1<f64>,
pub max_eigenvalue_statistics: Array1<f64>,
pub eigenvalues: Array1<f64>,
pub cointegrating_vectors: Array2<f64>,
pub adjustment_coefficients: Array2<f64>,
pub trace_critical_values: Array2<f64>,
pub max_eig_critical_values: Array2<f64>,
pub rank: usize,
}
#[derive(Debug, Clone)]
pub struct PhillipsOuliarisResult {
pub z_alpha: f64,
pub z_tau: f64,
pub z_alpha_critical_values: [f64; 3],
pub z_tau_critical_values: [f64; 3],
pub cointegrated: bool,
pub residuals: Array1<f64>,
}
#[derive(Debug, Clone)]
pub struct VecmResult {
pub alpha: Array2<f64>,
pub beta: Array2<f64>,
pub gamma: Vec<Array2<f64>>,
pub intercept: Array1<f64>,
pub residuals: Array2<f64>,
pub sigma: Array2<f64>,
pub log_likelihood: f64,
pub rank: usize,
}
fn ols_regression(
y: &ArrayView1<f64>,
x: &ArrayView2<f64>,
) -> Result<(Array1<f64>, Array1<f64>, Array1<f64>)> {
let n = y.len();
let (n_x, k) = x.dim();
if n != n_x {
return Err(NumRs2Error::ValueError(format!(
"y length ({}) must match X rows ({})",
n, n_x
)));
}
if n <= k {
return Err(NumRs2Error::ValueError(
"Insufficient observations for OLS regression".to_string(),
));
}
let xtx = x.t().dot(x);
let xty = x.t().dot(y);
let beta = scirs2_linalg::solve(&xtx.view(), &xty.view(), None).map_err(|_| {
NumRs2Error::ComputationError("Singular matrix in OLS regression".to_string())
})?;
let fitted = x.dot(&beta);
let residuals = y.to_owned() - &fitted;
let rss: f64 = residuals.iter().map(|&r| r * r).sum();
let s2 = rss / (n - k) as f64;
let eye = Array2::<f64>::eye(k);
let xtx_inv = scirs2_linalg::solve_multiple(&xtx.view(), &eye.view(), None).map_err(|_| {
NumRs2Error::ComputationError("Cannot compute (X'X)^{-1} for standard errors".to_string())
})?;
let mut se = Array1::zeros(k);
for i in 0..k {
let var_i = s2 * xtx_inv[[i, i]];
se[i] = if var_i > 0.0 { var_i.sqrt() } else { 0.0 };
}
Ok((beta, residuals, se))
}
fn ols_regression_multi(
y: &ArrayView2<f64>,
x: &ArrayView2<f64>,
) -> Result<(Array2<f64>, Array2<f64>)> {
let (n_y, _m) = y.dim();
let (n_x, _k) = x.dim();
if n_y != n_x {
return Err(NumRs2Error::ValueError(format!(
"Y rows ({}) must match X rows ({})",
n_y, n_x
)));
}
let xtx = x.t().dot(x);
let xty = x.t().dot(y);
let beta = scirs2_linalg::solve_multiple(&xtx.view(), &xty.view(), None).map_err(|_| {
NumRs2Error::ComputationError("Singular matrix in multivariate OLS".to_string())
})?;
let residuals = y.to_owned() - &x.dot(&beta);
Ok((beta, residuals))
}
fn adf_test_residuals(
residuals: &ArrayView1<f64>,
lags: usize,
n_vars: usize,
) -> Result<(f64, f64, [f64; 3])> {
let n = residuals.len();
if n < lags + 3 {
return Err(NumRs2Error::ValueError(
"Insufficient observations for ADF test on residuals".to_string(),
));
}
let mut diff = Array1::zeros(n - 1);
for i in 0..(n - 1) {
diff[i] = residuals[i + 1] - residuals[i];
}
let n_obs = n - lags - 1;
let n_regressors = 1 + lags;
if n_obs <= n_regressors {
return Err(NumRs2Error::ValueError(
"Too many lags for the sample size".to_string(),
));
}
let mut x_mat = Array2::zeros((n_obs, n_regressors));
let mut y_vec = Array1::zeros(n_obs);
for i in 0..n_obs {
let t = i + lags;
y_vec[i] = diff[t];
x_mat[[i, 0]] = residuals[t];
for lag_idx in 1..=lags {
if t >= lag_idx {
x_mat[[i, lag_idx]] = diff[t - lag_idx];
}
}
}
let (beta, resid, se) = ols_regression(&y_vec.view(), &x_mat.view())?;
let gamma = beta[0];
let se_gamma = se[0];
let adf_stat = if se_gamma.abs() > 1e-15 {
gamma / se_gamma
} else {
0.0
};
let critical_values = engle_granger_critical_values(n_vars, n);
let p_value = if adf_stat < critical_values[0] {
0.005
} else if adf_stat < critical_values[1] {
0.025
} else if adf_stat < critical_values[2] {
0.075
} else {
0.50
};
Ok((adf_stat, p_value, critical_values))
}
fn engle_granger_critical_values(n_vars: usize, n: usize) -> [f64; 3] {
let n_f = n as f64;
let inv_n = 1.0 / n_f;
match n_vars {
2 => {
let cv_1 = -3.9001 - 10.534 * inv_n;
let cv_5 = -3.3377 - 5.967 * inv_n;
let cv_10 = -3.0462 - 4.069 * inv_n;
[cv_1, cv_5, cv_10]
}
3 => {
let cv_1 = -4.2981 - 13.790 * inv_n;
let cv_5 = -3.7429 - 8.352 * inv_n;
let cv_10 = -3.4518 - 6.241 * inv_n;
[cv_1, cv_5, cv_10]
}
4 => {
let cv_1 = -4.6493 - 17.188 * inv_n;
let cv_5 = -4.1000 - 11.225 * inv_n;
let cv_10 = -3.8110 - 8.736 * inv_n;
[cv_1, cv_5, cv_10]
}
5 => {
let cv_1 = -4.9695 - 22.504 * inv_n;
let cv_5 = -4.4185 - 14.501 * inv_n;
let cv_10 = -4.1327 - 11.165 * inv_n;
[cv_1, cv_5, cv_10]
}
_ => {
let base_1 = -3.9001 - 0.35 * (n_vars as f64 - 2.0);
let base_5 = -3.3377 - 0.35 * (n_vars as f64 - 2.0);
let base_10 = -3.0462 - 0.35 * (n_vars as f64 - 2.0);
[
base_1 - 10.0 * inv_n,
base_5 - 6.0 * inv_n,
base_10 - 4.0 * inv_n,
]
}
}
}
pub fn engle_granger_test(
data: &ArrayView2<f64>,
lags: usize,
trend: TrendSpec,
) -> Result<EngleGrangerResult> {
let (t, k) = data.dim();
if k < 2 {
return Err(NumRs2Error::ValueError(
"Engle-Granger test requires at least 2 variables".to_string(),
));
}
if t < 20 {
return Err(NumRs2Error::ValueError(
"Engle-Granger test requires at least 20 observations".to_string(),
));
}
let y = data.column(0);
let n_design_cols = match trend {
TrendSpec::None => k - 1,
TrendSpec::Constant => k,
TrendSpec::ConstantTrend => k + 1,
};
let mut x_mat = Array2::zeros((t, n_design_cols));
let mut col_idx = 0;
if trend == TrendSpec::Constant || trend == TrendSpec::ConstantTrend {
for i in 0..t {
x_mat[[i, col_idx]] = 1.0;
}
col_idx += 1;
}
if trend == TrendSpec::ConstantTrend {
for i in 0..t {
x_mat[[i, col_idx]] = (i + 1) as f64;
}
col_idx += 1;
}
for j in 1..k {
for i in 0..t {
x_mat[[i, col_idx]] = data[[i, j]];
}
col_idx += 1;
}
let (coefficients, residuals, _se) = ols_regression(&y, &x_mat.view())?;
let (adf_stat, p_value, critical_values) = adf_test_residuals(&residuals.view(), lags, k)?;
let cointegrated = adf_stat < critical_values[1];
Ok(EngleGrangerResult {
adf_statistic: adf_stat,
p_value,
ols_coefficients: coefficients,
residuals,
critical_values,
cointegrated,
})
}
fn johansen_trace_critical_values(k: usize, trend: TrendSpec) -> Array2<f64> {
let mut cv = Array2::zeros((k, 3));
let tables = match trend {
TrendSpec::None => {
vec![
[2.69, 3.76, 6.65], [13.33, 15.41, 20.04], [26.79, 29.68, 35.65], [43.95, 47.21, 54.46], [64.84, 68.52, 76.07], [89.48, 94.15, 103.18], ]
}
TrendSpec::Constant => {
vec![
[7.52, 9.24, 12.97],
[17.85, 19.96, 24.60],
[32.00, 34.91, 41.07],
[49.65, 53.12, 60.16],
[71.86, 76.07, 84.45],
[97.18, 102.14, 111.01],
]
}
TrendSpec::ConstantTrend => {
vec![
[10.49, 12.53, 16.31],
[22.76, 25.32, 30.45],
[39.06, 42.44, 48.45],
[59.14, 62.99, 70.05],
[83.20, 87.31, 96.58],
[110.42, 114.90, 124.75],
]
}
};
for r in 0..k {
let p_minus_r = k - r;
if p_minus_r >= 1 && p_minus_r <= tables.len() {
let row = &tables[p_minus_r - 1];
cv[[r, 0]] = row[0];
cv[[r, 1]] = row[1];
cv[[r, 2]] = row[2];
} else {
let scale = p_minus_r as f64;
cv[[r, 0]] = 7.5 * scale;
cv[[r, 1]] = 9.0 * scale;
cv[[r, 2]] = 12.0 * scale;
}
}
cv
}
fn johansen_max_eig_critical_values(k: usize, trend: TrendSpec) -> Array2<f64> {
let mut cv = Array2::zeros((k, 3));
let tables = match trend {
TrendSpec::None => {
vec![
[2.69, 3.76, 6.65],
[12.07, 14.07, 18.63],
[18.60, 20.97, 25.52],
[24.73, 27.07, 32.24],
[30.67, 33.46, 38.77],
[36.76, 39.37, 44.59],
]
}
TrendSpec::Constant => {
vec![
[7.52, 9.24, 12.97],
[13.75, 15.67, 20.20],
[19.77, 22.00, 26.81],
[25.56, 28.14, 33.24],
[31.66, 33.32, 39.43],
[37.45, 39.43, 44.59],
]
}
TrendSpec::ConstantTrend => {
vec![
[10.49, 12.53, 16.31],
[16.85, 18.96, 23.65],
[23.11, 25.54, 30.34],
[29.12, 31.46, 36.65],
[34.75, 37.52, 42.36],
[40.91, 43.97, 49.51],
]
}
};
for r in 0..k {
let p_minus_r = k - r;
if p_minus_r >= 1 && p_minus_r <= tables.len() {
let row = &tables[p_minus_r - 1];
cv[[r, 0]] = row[0];
cv[[r, 1]] = row[1];
cv[[r, 2]] = row[2];
} else {
let scale = p_minus_r as f64;
cv[[r, 0]] = 7.0 * scale;
cv[[r, 1]] = 8.5 * scale;
cv[[r, 2]] = 11.0 * scale;
}
}
cv
}
pub fn johansen_test(
data: &ArrayView2<f64>,
lags: usize,
trend: TrendSpec,
) -> Result<JohansenResult> {
let (t, k) = data.dim();
if k < 2 {
return Err(NumRs2Error::ValueError(
"Johansen test requires at least 2 variables".to_string(),
));
}
if t < 2 * k + lags + 5 {
return Err(NumRs2Error::ValueError(format!(
"Insufficient observations ({}) for Johansen test with {} variables and {} lags",
t, k, lags
)));
}
let lags_vecm = if lags > 0 { lags } else { 1 };
let mut delta_y = Array2::zeros((t - 1, k));
for i in 0..(t - 1) {
for j in 0..k {
delta_y[[i, j]] = data[[i + 1, j]] - data[[i, j]];
}
}
let n_eff = t - lags_vecm;
if n_eff <= k + lags_vecm {
return Err(NumRs2Error::ValueError(
"Effective sample size too small".to_string(),
));
}
let n_obs = n_eff - 1;
let mut y0 = Array2::zeros((n_obs, k)); let mut y1 = Array2::zeros((n_obs, k));
for i in 0..n_obs {
let t_idx = i + lags_vecm;
for j in 0..k {
y0[[i, j]] = delta_y[[t_idx - 1, j]]; y1[[i, j]] = data[[t_idx - 1, j]]; }
}
let n_det = match trend {
TrendSpec::None => 0,
TrendSpec::Constant => 1,
TrendSpec::ConstantTrend => 2,
};
let n_lagged_diffs = (lags_vecm - 1) * k;
let n_x_cols = n_lagged_diffs + n_det;
let (r0, r1) = if n_x_cols > 0 {
let mut x_mat = Array2::zeros((n_obs, n_x_cols));
let mut col = 0;
if trend == TrendSpec::Constant || trend == TrendSpec::ConstantTrend {
for i in 0..n_obs {
x_mat[[i, col]] = 1.0;
}
col += 1;
}
if trend == TrendSpec::ConstantTrend {
for i in 0..n_obs {
x_mat[[i, col]] = (i + 1) as f64;
}
col += 1;
}
for lag in 1..lags_vecm {
for j in 0..k {
for i in 0..n_obs {
let t_idx = i + lags_vecm;
if t_idx > lag {
x_mat[[i, col]] = delta_y[[t_idx - 1 - lag, j]];
}
}
col += 1;
}
}
let (_b0, res0) = ols_regression_multi(&y0.view(), &x_mat.view())?;
let (_b1, res1) = ols_regression_multi(&y1.view(), &x_mat.view())?;
(res0, res1)
} else {
(y0.clone(), y1.clone())
};
let n_f = n_obs as f64;
let s00 = r0.t().dot(&r0) / n_f;
let s01 = r0.t().dot(&r1) / n_f;
let s10 = r1.t().dot(&r0) / n_f;
let s11 = r1.t().dot(&r1) / n_f;
let eye_k = Array2::<f64>::eye(k);
let s00_reg = &s00 + &(&eye_k * 1e-10);
let s00_inv =
scirs2_linalg::solve_multiple(&s00_reg.view(), &eye_k.view(), None).map_err(|_| {
NumRs2Error::ComputationError("Cannot invert S00 in Johansen procedure".to_string())
})?;
let s11_reg = &s11 + &(&eye_k * 1e-10);
let s11_inv =
scirs2_linalg::solve_multiple(&s11_reg.view(), &eye_k.view(), None).map_err(|_| {
NumRs2Error::ComputationError("Cannot invert S11 in Johansen procedure".to_string())
})?;
let m = s11_inv.dot(&s10).dot(&s00_inv).dot(&s01);
let (eigenvalues_complex, eigenvectors_complex) =
scirs2_linalg::eig(&m.view(), None).map_err(|_| {
NumRs2Error::ComputationError(
"Eigenvalue decomposition failed in Johansen procedure".to_string(),
)
})?;
let mut eig_pairs: Vec<(f64, usize)> = eigenvalues_complex
.iter()
.enumerate()
.map(|(i, c)| (c.re.clamp(0.0, 1.0), i)) .collect();
eig_pairs.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
let mut eigenvalues = Array1::zeros(k);
let mut cointegrating_vectors = Array2::zeros((k, k));
for (new_idx, &(eval, old_idx)) in eig_pairs.iter().enumerate() {
eigenvalues[new_idx] = eval;
for row in 0..k {
cointegrating_vectors[[row, new_idx]] = eigenvectors_complex[[row, old_idx]].re;
}
}
let mut trace_stats = Array1::zeros(k);
let mut max_eig_stats = Array1::zeros(k);
for r in 0..k {
let mut trace_sum = 0.0;
for i in r..k {
let lambda_i = eigenvalues[i].clamp(1e-15, 1.0 - 1e-15);
trace_sum += (1.0 - lambda_i).ln();
}
trace_stats[r] = -(n_obs as f64) * trace_sum;
if r < k {
let lambda_r = eigenvalues[r].clamp(1e-15, 1.0 - 1e-15);
max_eig_stats[r] = -(n_obs as f64) * (1.0 - lambda_r).ln();
}
}
let trace_cv = johansen_trace_critical_values(k, trend);
let max_eig_cv = johansen_max_eig_critical_values(k, trend);
let mut rank = 0;
for r in 0..k {
if trace_stats[r] > trace_cv[[r, 1]] {
rank = r + 1;
} else {
break;
}
}
let beta = cointegrating_vectors.slice(s![.., ..k]).to_owned();
let beta_s11_beta = beta.t().dot(&s11).dot(&beta);
let eye_full = Array2::<f64>::eye(k);
let beta_s11_beta_inv =
scirs2_linalg::solve_multiple(&beta_s11_beta.view(), &eye_full.view(), None)
.unwrap_or_else(|_| eye_full.clone());
let alpha = s01.dot(&beta).dot(&beta_s11_beta_inv);
Ok(JohansenResult {
trace_statistics: trace_stats,
max_eigenvalue_statistics: max_eig_stats,
eigenvalues,
cointegrating_vectors: beta,
adjustment_coefficients: alpha,
trace_critical_values: trace_cv,
max_eig_critical_values: max_eig_cv,
rank,
})
}
fn long_run_variance(residuals: &ArrayView1<f64>, bandwidth: usize) -> f64 {
let n = residuals.len();
let mean = residuals.iter().sum::<f64>() / n as f64;
let gamma0: f64 = residuals.iter().map(|&r| (r - mean).powi(2)).sum::<f64>() / n as f64;
let mut lrv = gamma0;
for lag in 1..=bandwidth.min(n - 1) {
let weight = 1.0 - lag as f64 / (bandwidth + 1) as f64; let mut gamma_lag = 0.0;
for i in lag..n {
gamma_lag += (residuals[i] - mean) * (residuals[i - lag] - mean);
}
gamma_lag /= n as f64;
lrv += 2.0 * weight * gamma_lag;
}
lrv.max(1e-15) }
pub fn phillips_ouliaris_test(
data: &ArrayView2<f64>,
trend: TrendSpec,
bandwidth: Option<usize>,
) -> Result<PhillipsOuliarisResult> {
let (t, k) = data.dim();
if k < 2 {
return Err(NumRs2Error::ValueError(
"Phillips-Ouliaris test requires at least 2 variables".to_string(),
));
}
if t < 20 {
return Err(NumRs2Error::ValueError(
"Phillips-Ouliaris test requires at least 20 observations".to_string(),
));
}
let y = data.column(0);
let n_det = match trend {
TrendSpec::None => 0,
TrendSpec::Constant => 1,
TrendSpec::ConstantTrend => 2,
};
let n_regressors = k - 1 + n_det;
let mut x_mat = Array2::zeros((t, n_regressors));
let mut col = 0;
if trend == TrendSpec::Constant || trend == TrendSpec::ConstantTrend {
for i in 0..t {
x_mat[[i, col]] = 1.0;
}
col += 1;
}
if trend == TrendSpec::ConstantTrend {
for i in 0..t {
x_mat[[i, col]] = (i + 1) as f64;
}
col += 1;
}
for j in 1..k {
for i in 0..t {
x_mat[[i, col]] = data[[i, j]];
}
col += 1;
}
let (_coefficients, residuals, _se) = ols_regression(&y, &x_mat.view())?;
let bw = bandwidth.unwrap_or_else(|| (4.0 * (t as f64 / 100.0).powf(0.25)).floor() as usize);
let bw = bw.max(1).min(t / 2);
let n_resid = residuals.len();
let mut sum_et_et1 = 0.0;
let mut sum_et1_sq = 0.0;
for i in 1..n_resid {
sum_et_et1 += residuals[i] * residuals[i - 1];
sum_et1_sq += residuals[i - 1] * residuals[i - 1];
}
let rho_hat = if sum_et1_sq.abs() > 1e-15 {
sum_et_et1 / sum_et1_sq
} else {
0.0
};
let mut u_hat = Array1::zeros(n_resid - 1);
for i in 1..n_resid {
u_hat[i - 1] = residuals[i] - rho_hat * residuals[i - 1];
}
let sigma_u_sq: f64 = u_hat.iter().map(|&u| u * u).sum::<f64>() / (n_resid - 1) as f64;
let omega_sq = long_run_variance(&u_hat.view(), bw);
let lambda = (omega_sq - sigma_u_sq) / 2.0;
let t_f = t as f64;
let n_alpha = t_f * (rho_hat - 1.0) - lambda * t_f / sum_et1_sq;
let z_alpha = n_alpha;
let se_rho = if sum_et1_sq > 1e-15 {
(sigma_u_sq / sum_et1_sq).sqrt()
} else {
1.0
};
let t_stat = (rho_hat - 1.0) / se_rho;
let correction = lambda / (omega_sq.sqrt() * se_rho * sum_et1_sq.sqrt());
let z_tau = t_stat - correction;
let z_alpha_cv = match k {
2 => [-28.32, -20.49, -17.04],
3 => [-37.08, -28.30, -24.38],
4 => [-46.37, -36.74, -32.10],
_ => {
let scale = k as f64;
[-10.0 * scale, -8.0 * scale, -6.5 * scale]
}
};
let z_tau_cv = match k {
2 => [-3.90, -3.34, -3.05],
3 => [-4.30, -3.74, -3.45],
4 => [-4.65, -4.10, -3.81],
_ => {
let adj = 0.35 * (k as f64 - 2.0);
[-3.90 - adj, -3.34 - adj, -3.05 - adj]
}
};
let cointegrated = z_tau < z_tau_cv[1];
Ok(PhillipsOuliarisResult {
z_alpha,
z_tau,
z_alpha_critical_values: z_alpha_cv,
z_tau_critical_values: z_tau_cv,
cointegrated,
residuals,
})
}
pub fn estimate_vecm(
data: &ArrayView2<f64>,
lags: usize,
rank: usize,
beta: Option<&ArrayView2<f64>>,
) -> Result<VecmResult> {
let (t, k) = data.dim();
if rank == 0 || rank >= k {
return Err(NumRs2Error::ValueError(format!(
"Cointegration rank must be between 1 and {} (exclusive)",
k
)));
}
if t < 2 * k + lags + 5 {
return Err(NumRs2Error::ValueError(
"Insufficient observations for VECM estimation".to_string(),
));
}
let beta_matrix = if let Some(b) = beta {
let (b_rows, b_cols) = b.dim();
if b_rows != k || b_cols != rank {
return Err(NumRs2Error::ValueError(format!(
"Beta must be ({} x {}), got ({} x {})",
k, rank, b_rows, b_cols
)));
}
b.to_owned()
} else {
let joh_result = johansen_test(data, lags.max(1), TrendSpec::Constant)?;
joh_result
.cointegrating_vectors
.slice(s![.., ..rank])
.to_owned()
};
let mut delta_y = Array2::zeros((t - 1, k));
for i in 0..(t - 1) {
for j in 0..k {
delta_y[[i, j]] = data[[i + 1, j]] - data[[i, j]];
}
}
let lags_eff = lags.max(1);
let n_obs = t - lags_eff - 1;
if n_obs <= rank + k * (lags_eff - 1) + 1 {
return Err(NumRs2Error::ValueError(
"Effective sample size too small for VECM estimation".to_string(),
));
}
let mut y_dep = Array2::zeros((n_obs, k));
for i in 0..n_obs {
let t_idx = i + lags_eff;
for j in 0..k {
y_dep[[i, j]] = delta_y[[t_idx, j]];
}
}
let n_gamma_lags = lags_eff.saturating_sub(1);
let n_regressors = rank + k * n_gamma_lags + 1; let mut x_mat = Array2::zeros((n_obs, n_regressors));
for i in 0..n_obs {
let t_idx = i + lags_eff;
let mut col = 0;
for r in 0..rank {
let mut z = 0.0;
for j in 0..k {
z += beta_matrix[[j, r]] * data[[t_idx, j]];
}
x_mat[[i, col]] = z;
col += 1;
}
for lag in 1..=n_gamma_lags {
for j in 0..k {
if t_idx >= lag {
x_mat[[i, col]] = delta_y[[t_idx - lag, j]];
}
col += 1;
}
}
x_mat[[i, col]] = 1.0;
}
let (b_hat, residuals) = ols_regression_multi(&y_dep.view(), &x_mat.view())?;
let mut alpha = Array2::zeros((k, rank));
for j in 0..k {
for r in 0..rank {
alpha[[j, r]] = b_hat[[r, j]];
}
}
let mut gamma_matrices = Vec::with_capacity(n_gamma_lags);
for lag in 0..n_gamma_lags {
let mut gamma_i = Array2::zeros((k, k));
let start_col = rank + lag * k;
for j in 0..k {
for m in 0..k {
gamma_i[[j, m]] = b_hat[[start_col + m, j]];
}
}
gamma_matrices.push(gamma_i);
}
let intercept_col = rank + n_gamma_lags * k;
let mut intercept = Array1::zeros(k);
for j in 0..k {
intercept[j] = b_hat[[intercept_col, j]];
}
let sigma = residuals.t().dot(&residuals) / n_obs as f64;
let log_likelihood = compute_mvn_log_likelihood(&residuals.view(), &sigma.view());
Ok(VecmResult {
alpha,
beta: beta_matrix,
gamma: gamma_matrices,
intercept,
residuals,
sigma,
log_likelihood,
rank,
})
}
fn compute_mvn_log_likelihood(residuals: &ArrayView2<f64>, sigma: &ArrayView2<f64>) -> f64 {
let (n, k) = residuals.dim();
let det_sigma = scirs2_linalg::det(&sigma.view(), None)
.unwrap_or(1e-10)
.max(1e-10);
let log_det = det_sigma.ln();
let eye_k = Array2::<f64>::eye(k);
let sigma_inv = scirs2_linalg::solve_multiple(&sigma.view(), &eye_k.view(), None)
.unwrap_or_else(|_| {
let mut diag = Array2::<f64>::zeros((k, k));
for i in 0..k {
diag[[i, i]] = 1.0 / sigma[[i, i]].max(1e-10);
}
diag
});
let mut quad_form = 0.0;
for i in 0..n {
let r = residuals.row(i);
let temp = sigma_inv.dot(&r);
for j in 0..k {
quad_form += r[j] * temp[j];
}
}
-0.5 * (n as f64 * (k as f64 * (2.0 * std::f64::consts::PI).ln() + log_det) + quad_form)
}
pub fn reduced_rank_regression(
y: &ArrayView2<f64>,
z: &ArrayView2<f64>,
x: Option<&ArrayView2<f64>>,
rank: usize,
) -> Result<(Array2<f64>, Array2<f64>)> {
let (n, p) = y.dim();
let (_n_z, q) = z.dim();
if rank > p.min(q) {
return Err(NumRs2Error::ValueError(format!(
"Rank {} exceeds min(p={}, q={})",
rank, p, q
)));
}
let (r0, r1) = if let Some(x_mat) = x {
let (_b0, res0) = ols_regression_multi(y, x_mat)?;
let (_b1, res1) = ols_regression_multi(z, x_mat)?;
(res0, res1)
} else {
(y.to_owned(), z.to_owned())
};
let n_f = n as f64;
let s00 = r0.t().dot(&r0) / n_f;
let s01 = r0.t().dot(&r1) / n_f;
let s11 = r1.t().dot(&r1) / n_f;
let eye_p = Array2::<f64>::eye(p);
let eye_q = Array2::<f64>::eye(q);
let s00_reg = &s00 + &(&eye_p * 1e-10);
let s11_reg = &s11 + &(&eye_q * 1e-10);
let s00_inv =
scirs2_linalg::solve_multiple(&s00_reg.view(), &eye_p.view(), None).map_err(|_| {
NumRs2Error::ComputationError(
"Cannot invert S00 in reduced rank regression".to_string(),
)
})?;
let s11_inv =
scirs2_linalg::solve_multiple(&s11_reg.view(), &eye_q.view(), None).map_err(|_| {
NumRs2Error::ComputationError(
"Cannot invert S11 in reduced rank regression".to_string(),
)
})?;
let mat = s11_inv.dot(&s01.t()).dot(&s00_inv).dot(&s01);
let (_eigenvalues_c, eigenvectors_c) = scirs2_linalg::eig(&mat.view(), None).map_err(|_| {
NumRs2Error::ComputationError("Eigenvalue decomposition failed in RRR".to_string())
})?;
let mut beta = Array2::zeros((q, rank));
for j in 0..rank {
for i in 0..q {
beta[[i, j]] = eigenvectors_c[[i, j]].re;
}
}
let bsb = beta.t().dot(&s11).dot(&beta);
let eye_r = Array2::<f64>::eye(rank);
let bsb_inv = scirs2_linalg::solve_multiple(&bsb.view(), &eye_r.view(), None).unwrap_or(eye_r);
let alpha = s01.dot(&beta).dot(&bsb_inv);
Ok((alpha, beta))
}
#[cfg(test)]
mod tests {
use super::*;
use scirs2_core::ndarray::{Array1, Array2};
use scirs2_core::random::{Rng, SeedableRng, StdRng};
fn generate_cointegrated_pair(n: usize, beta: f64, seed: u64) -> Array2<f64> {
let mut rng = StdRng::seed_from_u64(seed);
let mut data = Array2::zeros((n, 2));
let mut y2 = 0.0;
let mut noise = 0.0;
for i in 0..n {
let eps1: f64 = rng.gen_range(-0.5..0.5);
let eps2: f64 = rng.gen_range(-0.5..0.5);
y2 += eps1;
noise = 0.3 * noise + eps2;
data[[i, 0]] = beta * y2 + noise;
data[[i, 1]] = y2;
}
data
}
fn generate_independent_walks(n: usize, seed: u64) -> Array2<f64> {
let mut rng = StdRng::seed_from_u64(seed);
let mut data = Array2::zeros((n, 2));
let mut y1 = 0.0;
let mut y2 = 0.0;
for i in 0..n {
let eps1: f64 = rng.gen_range(-0.5..0.5);
let eps2: f64 = rng.gen_range(-0.5..0.5);
y1 += eps1;
y2 += eps2;
data[[i, 0]] = y1;
data[[i, 1]] = y2;
}
data
}
fn generate_cointegrated_system(n: usize, k: usize, rank: usize, seed: u64) -> Array2<f64> {
let mut rng = StdRng::seed_from_u64(seed);
let mut data = Array2::zeros((n, k));
let n_trends = k - rank;
let mut trends = Array2::zeros((n, n_trends.max(1)));
for j in 0..n_trends.max(1) {
let mut val = 0.0;
for i in 0..n {
val += rng.gen_range(-0.5..0.5);
trends[[i, j]] = val;
}
}
for j in 0..k {
let mut noise = 0.0;
for i in 0..n {
let eps: f64 = rng.gen_range(-0.3..0.3);
noise = 0.2 * noise + eps;
let trend_idx = j % n_trends.max(1);
let weight = 1.0 + 0.5 * (j as f64);
data[[i, j]] = weight * trends[[i, trend_idx]] + noise;
}
}
data
}
#[test]
fn test_engle_granger_cointegrated() {
let data = generate_cointegrated_pair(300, 2.0, 42);
let result = engle_granger_test(&data.view(), 2, TrendSpec::Constant);
assert!(result.is_ok(), "Engle-Granger test should succeed");
let eg = result.expect("should not fail");
assert!(
eg.adf_statistic < -1.5,
"ADF stat {} should be quite negative for cointegrated pair",
eg.adf_statistic
);
assert!(eg.residuals.len() == 300);
}
#[test]
fn test_engle_granger_independent() {
let data = generate_independent_walks(300, 123);
let result = engle_granger_test(&data.view(), 2, TrendSpec::Constant);
assert!(result.is_ok());
let eg = result.expect("should not fail");
assert!(
eg.adf_statistic > -5.0,
"ADF stat {} should not be extremely negative for independent walks",
eg.adf_statistic
);
}
#[test]
fn test_johansen_cointegrated() {
let data = generate_cointegrated_pair(300, 1.5, 99);
let result = johansen_test(&data.view(), 2, TrendSpec::Constant);
assert!(result.is_ok(), "Johansen test should succeed");
let joh = result.expect("should not fail");
assert_eq!(joh.trace_statistics.len(), 2);
assert_eq!(joh.max_eigenvalue_statistics.len(), 2);
assert_eq!(joh.eigenvalues.len(), 2);
assert_eq!(joh.cointegrating_vectors.dim(), (2, 2));
assert!(
joh.eigenvalues[0] > 0.0,
"First eigenvalue {} should be positive",
joh.eigenvalues[0]
);
}
#[test]
fn test_johansen_independent() {
let data = generate_independent_walks(300, 456);
let result = johansen_test(&data.view(), 2, TrendSpec::Constant);
assert!(result.is_ok());
let joh = result.expect("should not fail");
assert!(
joh.eigenvalues[0] < 0.95,
"First eigenvalue {} should not be too close to 1 for independent walks",
joh.eigenvalues[0]
);
}
#[test]
fn test_johansen_multiple_relationships() {
let data = generate_cointegrated_system(300, 3, 1, 77);
let result = johansen_test(&data.view(), 2, TrendSpec::Constant);
assert!(result.is_ok());
let joh = result.expect("should not fail");
assert_eq!(joh.eigenvalues.len(), 3);
assert_eq!(joh.trace_statistics.len(), 3);
assert_eq!(joh.trace_critical_values.dim(), (3, 3));
}
#[test]
fn test_phillips_ouliaris_cointegrated() {
let data = generate_cointegrated_pair(300, 2.0, 55);
let result = phillips_ouliaris_test(&data.view(), TrendSpec::Constant, None);
assert!(result.is_ok());
let po = result.expect("should not fail");
assert!(po.z_tau.is_finite(), "Z_tau should be finite");
assert!(po.z_alpha.is_finite(), "Z_alpha should be finite");
assert_eq!(po.residuals.len(), 300);
}
#[test]
fn test_phillips_ouliaris_independent() {
let data = generate_independent_walks(300, 789);
let result = phillips_ouliaris_test(&data.view(), TrendSpec::Constant, None);
assert!(result.is_ok());
let po = result.expect("should not fail");
assert!(po.z_tau.is_finite());
assert!(po.z_alpha.is_finite());
}
#[test]
fn test_vecm_estimation() {
let data = generate_cointegrated_pair(300, 2.0, 101);
let result = estimate_vecm(&data.view(), 2, 1, None);
assert!(
result.is_ok(),
"VECM estimation should succeed: {:?}",
result.err()
);
let vecm = result.expect("should not fail");
assert_eq!(vecm.alpha.dim(), (2, 1));
assert_eq!(vecm.beta.dim(), (2, 1));
assert_eq!(vecm.rank, 1);
assert!(vecm.log_likelihood.is_finite());
}
#[test]
fn test_eg_johansen_consistency() {
let data = generate_cointegrated_pair(500, 1.0, 200);
let eg =
engle_granger_test(&data.view(), 2, TrendSpec::Constant).expect("EG should succeed");
let joh =
johansen_test(&data.view(), 2, TrendSpec::Constant).expect("Johansen should succeed");
if eg.adf_statistic < eg.critical_values[1] {
assert!(
joh.eigenvalues[0] > 0.01,
"Johansen should also find evidence if EG does",
);
}
}
#[test]
fn test_identical_series() {
let mut data = Array2::zeros((100, 2));
let mut rng = StdRng::seed_from_u64(999);
let mut val = 0.0;
for i in 0..100 {
val += rng.gen_range(-0.5..0.5);
data[[i, 0]] = val;
data[[i, 1]] = val;
}
let result = engle_granger_test(&data.view(), 1, TrendSpec::Constant);
match result {
Ok(eg) => {
let max_resid = eg.residuals.iter().map(|r| r.abs()).fold(0.0_f64, f64::max);
assert!(
max_resid < 1e-6,
"Residuals for identical series should be near zero, got {}",
max_resid
);
}
Err(_) => {
}
}
}
#[test]
fn test_short_series() {
let data = Array2::zeros((5, 2));
let eg_result = engle_granger_test(&data.view(), 1, TrendSpec::Constant);
assert!(eg_result.is_err(), "Short series should fail for EG test");
let joh_result = johansen_test(&data.view(), 1, TrendSpec::Constant);
assert!(
joh_result.is_err(),
"Short series should fail for Johansen test"
);
}
#[test]
fn test_reduced_rank_regression() {
let n = 100;
let mut rng = StdRng::seed_from_u64(42);
let mut y_mat = Array2::zeros((n, 2));
let mut z_mat = Array2::zeros((n, 2));
for i in 0..n {
let z1: f64 = rng.gen_range(-1.0..1.0);
let z2: f64 = rng.gen_range(-1.0..1.0);
z_mat[[i, 0]] = z1;
z_mat[[i, 1]] = z2;
let noise1: f64 = rng.gen_range(-0.1..0.1);
let noise2: f64 = rng.gen_range(-0.1..0.1);
y_mat[[i, 0]] = 2.0 * z1 + 1.0 * z2 + noise1;
y_mat[[i, 1]] = 4.0 * z1 + 2.0 * z2 + noise2;
}
let result = reduced_rank_regression(&y_mat.view(), &z_mat.view(), None, 1);
assert!(result.is_ok(), "RRR should succeed");
let (alpha, beta) = result.expect("should not fail");
assert_eq!(alpha.dim().1, 1);
assert_eq!(beta.dim().1, 1);
}
#[test]
fn test_vecm_speed_of_adjustment() {
let data = generate_cointegrated_pair(500, 2.0, 303);
let result = estimate_vecm(&data.view(), 2, 1, None);
if let Ok(vecm) = result {
let alpha_norm: f64 = vecm.alpha.iter().map(|&a| a * a).sum::<f64>();
assert!(alpha_norm > 1e-15, "Speed of adjustment should be non-zero");
let (n_resid, k_resid) = vecm.residuals.dim();
assert!(n_resid > 0);
assert_eq!(k_resid, 2);
}
}
#[test]
fn test_engle_granger_trend_specs() {
let data = generate_cointegrated_pair(200, 2.0, 500);
for trend in &[
TrendSpec::None,
TrendSpec::Constant,
TrendSpec::ConstantTrend,
] {
let result = engle_granger_test(&data.view(), 1, *trend);
assert!(
result.is_ok(),
"EG test should succeed with trend {:?}",
trend
);
let eg = result.expect("should not fail");
assert!(eg.adf_statistic.is_finite());
}
}
#[test]
fn test_johansen_trace_max_eig_consistency() {
let data = generate_cointegrated_pair(300, 1.5, 707);
let result = johansen_test(&data.view(), 2, TrendSpec::Constant);
assert!(result.is_ok());
let joh = result.expect("should not fail");
assert!(
joh.trace_statistics[0] >= joh.max_eigenvalue_statistics[0] - 1e-10,
"Trace stat ({}) should be >= max eig stat ({})",
joh.trace_statistics[0],
joh.max_eigenvalue_statistics[0]
);
}
#[test]
fn test_ols_regression_basic() {
let n = 50;
let mut x_mat = Array2::zeros((n, 2));
let mut y_vec = Array1::zeros(n);
for i in 0..n {
let x_val = i as f64 / 10.0;
x_mat[[i, 0]] = 1.0;
x_mat[[i, 1]] = x_val;
y_vec[i] = 2.0 + 3.0 * x_val;
}
let (beta, residuals, _se) =
ols_regression(&y_vec.view(), &x_mat.view()).expect("OLS should succeed");
assert!(
(beta[0] - 2.0).abs() < 1e-10,
"Intercept should be 2.0, got {}",
beta[0]
);
assert!(
(beta[1] - 3.0).abs() < 1e-10,
"Slope should be 3.0, got {}",
beta[1]
);
let max_resid = residuals.iter().map(|r| r.abs()).fold(0.0_f64, f64::max);
assert!(
max_resid < 1e-10,
"Residuals should be near zero for perfect fit"
);
}
}