use crate::TimeSeries;
use scirs2_core::ndarray::Array2;
use torsh_core::error::Result;
#[derive(Debug, Clone)]
pub struct EngleGrangerResult {
pub test_statistic: f64,
pub critical_values: CriticalValues,
pub p_value: f64,
pub is_cointegrated: bool,
pub cointegrating_vector: Vec<f64>,
pub residuals: Vec<f64>,
}
#[derive(Debug, Clone)]
pub struct CriticalValues {
pub cv_1pct: f64,
pub cv_5pct: f64,
pub cv_10pct: f64,
}
pub fn engle_granger_test(
y: &TimeSeries,
x: &TimeSeries,
trend: &str,
) -> Result<EngleGrangerResult> {
let y_data = y.values.to_vec()?;
let x_data = x.values.to_vec()?;
if y_data.len() != x_data.len() {
return Err(torsh_core::error::TorshError::InvalidArgument(
"Series must have equal length".to_string(),
));
}
let n = y_data.len();
if n < 10 {
return Err(torsh_core::error::TorshError::InvalidArgument(
"Need at least 10 observations for cointegration test".to_string(),
));
}
let (alpha, beta, residuals) = match trend {
"c" => {
ols_regression(&y_data, &x_data, true, false)
}
"ct" => {
ols_regression_with_trend(&y_data, &x_data)
}
_ => {
ols_regression(&y_data, &x_data, false, false)
}
};
let adf_statistic = adf_test_on_residuals(&residuals);
let critical_values = get_engle_granger_critical_values(n, trend);
let is_cointegrated = adf_statistic < critical_values.cv_5pct;
let p_value = approximate_engle_granger_pvalue(adf_statistic, n);
Ok(EngleGrangerResult {
test_statistic: adf_statistic,
critical_values,
p_value,
is_cointegrated,
cointegrating_vector: vec![alpha, beta],
residuals,
})
}
fn ols_regression(
y: &[f32],
x: &[f32],
include_constant: bool,
_include_trend: bool,
) -> (f64, f64, Vec<f64>) {
let n = y.len();
let x_f64: Vec<f64> = x.iter().map(|&v| v as f64).collect();
let y_f64: Vec<f64> = y.iter().map(|&v| v as f64).collect();
if include_constant {
let mean_x = x_f64.iter().sum::<f64>() / n as f64;
let mean_y = y_f64.iter().sum::<f64>() / n as f64;
let mut cov_xy = 0.0;
let mut var_x = 0.0;
for i in 0..n {
let dx = x_f64[i] - mean_x;
let dy = y_f64[i] - mean_y;
cov_xy += dx * dy;
var_x += dx * dx;
}
let beta = if var_x > 1e-10 { cov_xy / var_x } else { 0.0 };
let alpha = mean_y - beta * mean_x;
let residuals: Vec<f64> = y_f64
.iter()
.zip(x_f64.iter())
.map(|(&yi, &xi)| yi - (alpha + beta * xi))
.collect();
(alpha, beta, residuals)
} else {
let sum_xy: f64 = x_f64
.iter()
.zip(y_f64.iter())
.map(|(&xi, &yi)| xi * yi)
.sum();
let sum_xx: f64 = x_f64.iter().map(|&xi| xi * xi).sum();
let beta = if sum_xx > 1e-10 { sum_xy / sum_xx } else { 0.0 };
let residuals: Vec<f64> = y_f64
.iter()
.zip(x_f64.iter())
.map(|(&yi, &xi)| yi - beta * xi)
.collect();
(0.0, beta, residuals)
}
}
fn ols_regression_with_trend(y: &[f32], x: &[f32]) -> (f64, f64, Vec<f64>) {
let n = y.len();
let x_f64: Vec<f64> = x.iter().map(|&v| v as f64).collect();
let y_f64: Vec<f64> = y.iter().map(|&v| v as f64).collect();
let mut xtx = [[0.0_f64; 3]; 3];
let mut xty = [0.0_f64; 3];
for i in 0..n {
let row = [1.0_f64, x_f64[i], i as f64];
for j in 0..3 {
for k in 0..3 {
xtx[j][k] += row[j] * row[k];
}
xty[j] += row[j] * y_f64[i];
}
}
match solve_3x3(&xtx, &xty) {
Some(beta) => {
let (alpha, b, gamma) = (beta[0], beta[1], beta[2]);
let residuals: Vec<f64> = (0..n)
.map(|i| y_f64[i] - (alpha + b * x_f64[i] + gamma * i as f64))
.collect();
(alpha, b, residuals)
}
None => {
ols_regression(y, x, true, false)
}
}
}
fn adf_test_on_residuals(residuals: &[f64]) -> f64 {
let n = residuals.len();
if n < 3 {
return 0.0;
}
let mut delta_epsilon = Vec::with_capacity(n - 1);
for i in 1..n {
delta_epsilon.push(residuals[i] - residuals[i - 1]);
}
let lagged_epsilon = &residuals[0..n - 1];
let sum_delta_lag: f64 = delta_epsilon
.iter()
.zip(lagged_epsilon.iter())
.map(|(&d, &l)| d * l)
.sum();
let sum_lag_sq: f64 = lagged_epsilon.iter().map(|&l| l * l).sum();
let rho = if sum_lag_sq > 1e-10 {
sum_delta_lag / sum_lag_sq
} else {
0.0
};
let residuals_adf: Vec<f64> = delta_epsilon
.iter()
.zip(lagged_epsilon.iter())
.map(|(&d, &l)| d - rho * l)
.collect();
let sse: f64 = residuals_adf.iter().map(|&r| r * r).sum();
let sigma_squared = sse / (n - 2) as f64;
let se_rho = (sigma_squared / sum_lag_sq).sqrt();
let adf_stat = if se_rho > 1e-10 { rho / se_rho } else { 0.0 };
adf_stat
}
fn get_engle_granger_critical_values(n: usize, trend: &str) -> CriticalValues {
match trend {
"c" => {
if n < 50 {
CriticalValues {
cv_1pct: -3.90,
cv_5pct: -3.34,
cv_10pct: -3.04,
}
} else if n < 100 {
CriticalValues {
cv_1pct: -3.77,
cv_5pct: -3.17,
cv_10pct: -2.91,
}
} else {
CriticalValues {
cv_1pct: -3.73,
cv_5pct: -3.13,
cv_10pct: -2.87,
}
}
}
"ct" => {
if n < 50 {
CriticalValues {
cv_1pct: -4.32,
cv_5pct: -3.78,
cv_10pct: -3.50,
}
} else if n < 100 {
CriticalValues {
cv_1pct: -4.21,
cv_5pct: -3.65,
cv_10pct: -3.36,
}
} else {
CriticalValues {
cv_1pct: -4.16,
cv_5pct: -3.60,
cv_10pct: -3.32,
}
}
}
_ => {
CriticalValues {
cv_1pct: -2.66,
cv_5pct: -1.95,
cv_10pct: -1.60,
}
}
}
}
fn approximate_engle_granger_pvalue(test_stat: f64, _n: usize) -> f64 {
if test_stat < -3.73 {
0.01 } else if test_stat < -3.13 {
0.05 } else if test_stat < -2.87 {
0.10 } else {
0.20 }
}
pub fn johansen_test(series: &[TimeSeries], max_rank: usize) -> Result<JohansenResult> {
if series.is_empty() {
return Err(torsh_core::error::TorshError::InvalidArgument(
"Need at least one series".to_string(),
));
}
let n = series[0].len();
let k = series.len();
for s in series {
if s.len() != n {
return Err(torsh_core::error::TorshError::InvalidArgument(
"All series must have same length".to_string(),
));
}
}
if n < 20 {
return Err(torsh_core::error::TorshError::InvalidArgument(
"Need at least 20 observations for Johansen test".to_string(),
));
}
let trace_stats = vec![0.0; max_rank.min(k)];
let max_eigen_stats = vec![0.0; max_rank.min(k)];
Ok(JohansenResult {
trace_statistics: trace_stats,
max_eigen_statistics: max_eigen_stats,
cointegrating_rank: 0,
critical_values_trace: vec![],
critical_values_max_eigen: vec![],
})
}
#[derive(Debug, Clone)]
pub struct JohansenResult {
pub trace_statistics: Vec<f64>,
pub max_eigen_statistics: Vec<f64>,
pub cointegrating_rank: usize,
pub critical_values_trace: Vec<CriticalValues>,
pub critical_values_max_eigen: Vec<CriticalValues>,
}
#[derive(Debug, Clone)]
pub struct VECM {
pub lags: usize,
pub rank: usize,
pub pi_matrix: Option<Array2<f64>>,
pub gamma_matrices: Vec<Array2<f64>>,
pub beta: Option<Array2<f64>>,
pub alpha: Option<Array2<f64>>,
last_obs: Vec<Vec<f64>>,
last_deltas: Vec<Vec<f64>>,
}
impl VECM {
pub fn new(lags: usize, rank: usize) -> Self {
Self {
lags,
rank,
pi_matrix: None,
gamma_matrices: Vec::new(),
beta: None,
alpha: None,
last_obs: Vec::new(),
last_deltas: Vec::new(),
}
}
pub fn fit(&mut self, series: &[TimeSeries]) -> Result<()> {
let k = series.len();
if k == 0 {
return Err(torsh_core::error::TorshError::InvalidArgument(
"Need at least one series for VECM fit".to_string(),
));
}
if self.rank == 0 || self.rank > k {
return Err(torsh_core::error::TorshError::InvalidArgument(format!(
"Cointegrating rank must be in 1..={k}, got {}",
self.rank
)));
}
let mut data: Vec<Vec<f64>> = Vec::with_capacity(k);
for s in series {
let vals = s.values.to_vec()?;
data.push(vals.iter().map(|&v| v as f64).collect());
}
let n = data[0].len();
for d in &data {
if d.len() != n {
return Err(torsh_core::error::TorshError::InvalidArgument(
"All series must have the same length".to_string(),
));
}
}
let p = self.lags;
if n <= p + 1 {
return Err(torsh_core::error::TorshError::InvalidArgument(
"Not enough observations for the specified lag order".to_string(),
));
}
let t_diff = n - 1; let delta: Vec<Vec<f64>> = (0..k)
.map(|j| (0..t_diff).map(|t| data[j][t + 1] - data[j][t]).collect())
.collect();
let t_eff = t_diff - p; if t_eff < k + 1 {
return Err(torsh_core::error::TorshError::InvalidArgument(
"Effective sample too small after consuming lags".to_string(),
));
}
let n_reg = 1 + k * p; let mut reg_matrix: Vec<Vec<f64>> = Vec::with_capacity(t_eff);
for s in 0..t_eff {
let t = p + s; let mut row = vec![1.0_f64]; for lag in 1..p {
for j in 0..k {
let idx = t.checked_sub(lag).unwrap_or(0);
row.push(if idx < delta[j].len() {
delta[j][idx]
} else {
0.0
});
}
}
for j in 0..k {
row.push(data[j][t]);
}
reg_matrix.push(row);
}
let mut coef_matrix: Vec<Vec<f64>> = Vec::with_capacity(k);
let mut residual_matrix: Vec<Vec<f64>> = (0..k).map(|_| vec![0.0_f64; t_eff]).collect();
for j in 0..k {
let y_dep: Vec<f64> = (0..t_eff).map(|s| delta[j][p + s]).collect();
match ols_multivariate(®_matrix, &y_dep) {
Some(coef) => {
for s in 0..t_eff {
let fitted: f64 = coef
.iter()
.zip(reg_matrix[s].iter())
.map(|(c, x)| c * x)
.sum();
residual_matrix[j][s] = y_dep[s] - fitted;
}
coef_matrix.push(coef);
}
None => {
coef_matrix.push(vec![0.0_f64; n_reg]);
for s in 0..t_eff {
residual_matrix[j][s] = delta[j][p + s];
}
}
}
}
let mut gamma_matrices: Vec<Array2<f64>> = Vec::new();
for lag in 0..(p.saturating_sub(1)) {
let col_start = 1 + lag * k;
let mut gamma = Array2::zeros((k, k));
for j in 0..k {
for m in 0..k {
gamma[[j, m]] = coef_matrix[j][col_start + m];
}
}
gamma_matrices.push(gamma);
}
let level_start = 1 + (p.saturating_sub(1)) * k;
let mut pi_flat: Vec<f64> = vec![0.0_f64; k * k];
for j in 0..k {
for m in 0..k {
pi_flat[j * k + m] = if level_start + m < coef_matrix[j].len() {
coef_matrix[j][level_start + m]
} else {
0.0
};
}
}
let pi_mat: Vec<Vec<f64>> = (0..k)
.map(|j| pi_flat[j * k..(j + 1) * k].to_vec())
.collect();
let (u_mat, sigma, v_mat) = svd_small(&pi_mat, self.rank);
let mut alpha_arr = Array2::zeros((k, self.rank));
let mut beta_arr = Array2::zeros((k, self.rank));
let mut pi_arr = Array2::zeros((k, k));
for j in 0..k {
for r in 0..self.rank {
alpha_arr[[j, r]] = u_mat[j][r] * sigma[r];
beta_arr[[j, r]] = v_mat[j][r];
}
for m in 0..k {
pi_arr[[j, m]] = pi_flat[j * k + m];
}
}
self.alpha = Some(alpha_arr);
self.beta = Some(beta_arr);
self.pi_matrix = Some(pi_arr);
self.gamma_matrices = gamma_matrices;
let last_p = p.max(1);
self.last_obs = (0..last_p)
.map(|lag| {
let idx = n.saturating_sub(1 + lag);
(0..k).map(|j| data[j][idx]).collect()
})
.collect();
self.last_deltas = (0..last_p)
.map(|lag| {
let idx = t_diff.saturating_sub(1 + lag);
(0..k)
.map(|j| {
if idx < delta[j].len() {
delta[j][idx]
} else {
0.0
}
})
.collect()
})
.collect();
Ok(())
}
pub fn forecast(&self, steps: usize) -> Result<Vec<TimeSeries>> {
use torsh_tensor::Tensor;
let pi = self.pi_matrix.as_ref().ok_or_else(|| {
torsh_core::error::TorshError::NotImplemented("VECM not fitted".to_string())
})?;
let k = pi.shape()[0];
let mut y_level = if self.last_obs.is_empty() {
vec![0.0_f64; k]
} else {
self.last_obs[0].clone() };
let p = self.lags;
let mut delta_history: Vec<Vec<f64>> = if self.last_deltas.is_empty() {
vec![vec![0.0_f64; k]; p.max(1)]
} else {
let mut h: Vec<Vec<f64>> = self.last_deltas.iter().cloned().rev().collect();
h.truncate(p.max(1));
while h.len() < p.max(1) {
h.insert(0, vec![0.0_f64; k]);
}
h
};
let mut paths: Vec<Vec<f64>> = (0..k).map(|_| Vec::with_capacity(steps)).collect();
for _step in 0..steps {
let mut delta_new = vec![0.0_f64; k];
for j in 0..k {
for m in 0..k {
delta_new[j] += pi[[j, m]] * y_level[m];
}
}
for (i, gamma) in self.gamma_matrices.iter().enumerate() {
let hist_idx = delta_history.len().saturating_sub(i + 1);
let lag_delta = if hist_idx < delta_history.len() {
&delta_history[hist_idx]
} else {
continue;
};
for j in 0..k {
for m in 0..k {
delta_new[j] += gamma[[j, m]] * lag_delta[m];
}
}
}
let y_new: Vec<f64> = y_level
.iter()
.zip(delta_new.iter())
.map(|(y, d)| y + d)
.collect();
for j in 0..k {
paths[j].push(y_new[j]);
}
delta_history.push(delta_new);
if delta_history.len() > p {
delta_history.remove(0);
}
y_level = y_new;
}
let result: Result<Vec<_>> = paths
.into_iter()
.map(|path| {
let n = path.len();
let f32_path: Vec<f32> = path.iter().map(|&v| v as f32).collect();
Tensor::from_vec(f32_path, &[n])
.map(TimeSeries::new)
.map_err(|e| torsh_core::error::TorshError::InvalidArgument(e.to_string()))
})
.collect();
result
}
pub fn impulse_response(&self, periods: usize) -> Result<Vec<Array2<f64>>> {
let pi = self.pi_matrix.as_ref().ok_or_else(|| {
torsh_core::error::TorshError::NotImplemented("VECM not fitted".to_string())
})?;
let k = pi.shape()[0];
let p = self.lags;
let kp = k * p;
let mut a_comp: Vec<Vec<f64>> = vec![vec![0.0_f64; kp]; kp];
for j in 0..k {
for m in 0..k {
let pi_val = pi[[j, m]];
let i_val = if j == m { 1.0_f64 } else { 0.0_f64 };
let gamma1_val = if !self.gamma_matrices.is_empty() {
self.gamma_matrices[0][[j, m]]
} else {
0.0
};
a_comp[j][m] = i_val + pi_val + gamma1_val;
for lag in 1..(p.saturating_sub(1)) {
let g_curr = if lag < self.gamma_matrices.len() {
self.gamma_matrices[lag][[j, m]]
} else {
0.0
};
let g_prev = self.gamma_matrices[lag - 1][[j, m]];
a_comp[j][m + (lag) * k] = g_curr - g_prev;
}
if p > 1 {
let g_last = if p - 2 < self.gamma_matrices.len() {
self.gamma_matrices[p - 2][[j, m]]
} else {
0.0
};
a_comp[j][m + (p - 1) * k] = -g_last;
}
}
}
for j in k..kp {
if j - k < kp {
a_comp[j][j - k] = 1.0;
}
}
let mut irf: Vec<Array2<f64>> = Vec::with_capacity(periods + 1);
let phi0 = Array2::eye(k);
irf.push(phi0);
let mut state: Vec<Vec<f64>> = vec![vec![0.0_f64; k]; kp];
for j in 0..k {
state[j][j] = 1.0;
}
for _h in 0..periods {
let mut state_new: Vec<Vec<f64>> = vec![vec![0.0_f64; k]; kp];
for row in 0..kp {
for col in 0..k {
let val: f64 = (0..kp).map(|c| a_comp[row][c] * state[c][col]).sum();
state_new[row][col] = val;
}
}
let mut phi_h = Array2::zeros((k, k));
for j in 0..k {
for m in 0..k {
phi_h[[j, m]] = state_new[j][m];
}
}
irf.push(phi_h);
state = state_new;
}
Ok(irf)
}
}
fn solve_3x3(a: &[[f64; 3]; 3], b: &[f64; 3]) -> Option<[f64; 3]> {
let mut aug = [[0.0_f64; 4]; 3];
for i in 0..3 {
aug[i][0] = a[i][0];
aug[i][1] = a[i][1];
aug[i][2] = a[i][2];
aug[i][3] = b[i];
}
for col in 0..3 {
let max_row = (col..3)
.max_by(|&r1, &r2| {
aug[r1][col]
.abs()
.partial_cmp(&aug[r2][col].abs())
.unwrap_or(std::cmp::Ordering::Equal)
})
.unwrap_or(col);
aug.swap(col, max_row);
let pivot = aug[col][col];
if pivot.abs() < 1e-12 {
return None;
}
let inv_pivot = 1.0 / pivot;
for k in col..4 {
aug[col][k] *= inv_pivot;
}
for row in 0..3 {
if row == col {
continue;
}
let factor = aug[row][col];
for k in col..4 {
aug[row][k] -= factor * aug[col][k];
}
}
}
Some([aug[0][3], aug[1][3], aug[2][3]])
}
fn solve_linear_system(a: &[Vec<f64>], b: &[f64]) -> Option<Vec<f64>> {
let n = b.len();
debug_assert_eq!(a.len(), n);
let mut aug: Vec<Vec<f64>> = a
.iter()
.zip(b.iter())
.map(|(row, &rhs)| {
let mut r = row.clone();
r.push(rhs);
r
})
.collect();
for col in 0..n {
let max_row = (col..n)
.max_by(|&r1, &r2| {
aug[r1][col]
.abs()
.partial_cmp(&aug[r2][col].abs())
.unwrap_or(std::cmp::Ordering::Equal)
})
.unwrap_or(col);
aug.swap(col, max_row);
let pivot = aug[col][col];
if pivot.abs() < 1e-12 {
return None;
}
let inv_pivot = 1.0 / pivot;
for k in col..=n {
aug[col][k] *= inv_pivot;
}
for row in 0..n {
if row == col {
continue;
}
let factor = aug[row][col];
for k in col..=n {
aug[row][k] -= factor * aug[col][k];
}
}
}
Some((0..n).map(|i| aug[i][n]).collect())
}
fn ols_multivariate(x_rows: &[Vec<f64>], y: &[f64]) -> Option<Vec<f64>> {
let n_obs = x_rows.len();
debug_assert_eq!(y.len(), n_obs);
if n_obs == 0 {
return None;
}
let n_reg = x_rows[0].len();
let mut xtx: Vec<Vec<f64>> = vec![vec![0.0_f64; n_reg]; n_reg];
let mut xty: Vec<f64> = vec![0.0_f64; n_reg];
for (row, &yi) in x_rows.iter().zip(y.iter()) {
for j in 0..n_reg {
for k in 0..n_reg {
xtx[j][k] += row[j] * row[k];
}
xty[j] += row[j] * yi;
}
}
solve_linear_system(&xtx, &xty)
}
fn jacobi_eig_symmetric(mat: &[Vec<f64>], max_iter: usize) -> (Vec<f64>, Vec<Vec<f64>>) {
let n = mat.len();
let mut a: Vec<Vec<f64>> = mat.to_vec();
let mut v: Vec<Vec<f64>> = (0..n)
.map(|i| (0..n).map(|j| if i == j { 1.0 } else { 0.0 }).collect())
.collect();
for _ in 0..max_iter {
let mut max_val = 0.0_f64;
let mut p = 0;
let mut q = 1;
for i in 0..n {
for j in (i + 1)..n {
if a[i][j].abs() > max_val {
max_val = a[i][j].abs();
p = i;
q = j;
}
}
}
if max_val < 1e-14 {
break;
}
let theta = if (a[p][p] - a[q][q]).abs() < 1e-15 {
std::f64::consts::FRAC_PI_4
} else {
0.5 * ((2.0 * a[p][q]) / (a[p][p] - a[q][q])).atan()
};
let cos_t = theta.cos();
let sin_t = theta.sin();
let a_pp = a[p][p];
let a_qq = a[q][q];
let a_pq = a[p][q];
a[p][p] = cos_t * cos_t * a_pp + 2.0 * cos_t * sin_t * a_pq + sin_t * sin_t * a_qq;
a[q][q] = sin_t * sin_t * a_pp - 2.0 * cos_t * sin_t * a_pq + cos_t * cos_t * a_qq;
a[p][q] = 0.0;
a[q][p] = 0.0;
for r in 0..n {
if r == p || r == q {
continue;
}
let a_rp = a[r][p];
let a_rq = a[r][q];
a[r][p] = cos_t * a_rp + sin_t * a_rq;
a[p][r] = a[r][p];
a[r][q] = -sin_t * a_rp + cos_t * a_rq;
a[q][r] = a[r][q];
}
for r in 0..n {
let v_rp = v[r][p];
let v_rq = v[r][q];
v[r][p] = cos_t * v_rp + sin_t * v_rq;
v[r][q] = -sin_t * v_rp + cos_t * v_rq;
}
}
let mut pairs: Vec<(f64, usize)> = (0..n).map(|i| (a[i][i], i)).collect();
pairs.sort_by(|(a, _), (b, _)| {
b.abs()
.partial_cmp(&a.abs())
.unwrap_or(std::cmp::Ordering::Equal)
});
let eigenvalues: Vec<f64> = pairs.iter().map(|&(ev, _)| ev).collect();
let eigenvectors: Vec<Vec<f64>> = pairs
.iter()
.map(|&(_, idx)| (0..n).map(|r| v[r][idx]).collect())
.collect();
(eigenvalues, eigenvectors)
}
fn svd_small(a: &[Vec<f64>], rank: usize) -> (Vec<Vec<f64>>, Vec<f64>, Vec<Vec<f64>>) {
let k = a.len();
if k == 0 {
return (vec![], vec![], vec![]);
}
let r = rank.min(k);
let mut ata: Vec<Vec<f64>> = vec![vec![0.0_f64; k]; k];
for i in 0..k {
for j in 0..k {
for row in 0..k {
ata[i][j] += a[row][i] * a[row][j];
}
}
}
let max_jacobi = 200 * k;
let (eigenvalues, v_cols) = jacobi_eig_symmetric(&ata, max_jacobi);
let sigma: Vec<f64> = eigenvalues[..r]
.iter()
.map(|&ev| ev.max(0.0).sqrt())
.collect();
let v_mat: Vec<Vec<f64>> = (0..k)
.map(|row| (0..r).map(|col| v_cols[col][row]).collect())
.collect();
let mut u_mat: Vec<Vec<f64>> = vec![vec![0.0_f64; r]; k];
for col in 0..r {
if sigma[col] < 1e-14 {
continue;
}
let inv_s = 1.0 / sigma[col];
for row in 0..k {
let av: f64 = (0..k).map(|m| a[row][m] * v_cols[col][m]).sum();
u_mat[row][col] = av * inv_s;
}
}
(u_mat, sigma, v_mat)
}
#[cfg(test)]
mod tests {
use super::*;
use torsh_tensor::Tensor;
fn create_cointegrated_series() -> (TimeSeries, TimeSeries) {
let n = 100;
let mut x_data = Vec::with_capacity(n);
let mut y_data = Vec::with_capacity(n);
let mut x_val = 10.0;
let mut error = 0.0;
for _i in 0..n {
x_val += 0.1; error += ((_i % 7) as f32 - 3.0) * 0.01;
x_data.push(x_val);
y_data.push(2.0 * x_val + error);
}
let x_tensor = Tensor::from_vec(x_data, &[n]).expect("x tensor");
let y_tensor = Tensor::from_vec(y_data, &[n]).expect("y tensor");
(TimeSeries::new(y_tensor), TimeSeries::new(x_tensor))
}
fn create_non_cointegrated_series() -> (TimeSeries, TimeSeries) {
let n = 100;
let mut x_data = Vec::with_capacity(n);
let mut y_data = Vec::with_capacity(n);
let mut x_val = 0.0;
let mut y_val = 0.0;
for i in 0..n {
x_val += ((i % 5) as f32 - 2.0) * 0.5;
y_val += ((i % 7) as f32 - 3.0) * 0.5;
x_data.push(x_val);
y_data.push(y_val);
}
let x_tensor = Tensor::from_vec(x_data, &[n]).expect("x tensor");
let y_tensor = Tensor::from_vec(y_data, &[n]).expect("y tensor");
(TimeSeries::new(y_tensor), TimeSeries::new(x_tensor))
}
#[test]
fn test_engle_granger_cointegrated() {
let (y, x) = create_cointegrated_series();
let result = engle_granger_test(&y, &x, "c").expect("engle granger test");
assert!(
result.is_cointegrated || result.test_statistic < -2.5,
"Should detect cointegration or have negative test statistic"
);
assert!(result.cointegrating_vector.len() == 2);
assert!(
(result.cointegrating_vector[1] - 2.0).abs() < 0.5,
"Beta should be close to 2.0, got {}",
result.cointegrating_vector[1]
);
}
#[test]
fn test_engle_granger_not_cointegrated() {
let (y, x) = create_non_cointegrated_series();
let result = engle_granger_test(&y, &x, "c").expect("engle granger test");
assert!(result.cointegrating_vector.len() == 2);
assert!(!result.residuals.is_empty());
}
#[test]
fn test_engle_granger_different_trends() {
let (y, x) = create_cointegrated_series();
let result_c = engle_granger_test(&y, &x, "c").expect("constant test");
assert!(result_c.cointegrating_vector.len() == 2);
let result_ct = engle_granger_test(&y, &x, "ct").expect("constant+trend test");
assert!(result_ct.cointegrating_vector.len() == 2);
let result_nc = engle_granger_test(&y, &x, "nc").expect("no-constant test");
assert!(result_nc.cointegrating_vector.len() == 2);
}
#[test]
fn test_johansen_basic() {
let (y, x) = create_cointegrated_series();
let series = vec![y, x];
let result = johansen_test(&series, 1).expect("johansen test");
assert!(!result.trace_statistics.is_empty());
assert!(!result.max_eigen_statistics.is_empty());
}
#[test]
fn test_vecm_creation() {
let vecm = VECM::new(2, 1);
assert_eq!(vecm.lags, 2);
assert_eq!(vecm.rank, 1);
assert!(vecm.pi_matrix.is_none());
assert!(vecm.beta.is_none());
assert!(vecm.alpha.is_none());
}
#[test]
fn test_ols_regression() {
let x = vec![1.0f32, 2.0, 3.0, 4.0, 5.0];
let y = vec![2.0f32, 4.0, 6.0, 8.0, 10.0];
let (alpha, beta, residuals) = ols_regression(&y, &x, true, false);
assert!(
(beta - 2.0).abs() < 0.1,
"Beta should be ~2.0, got {}",
beta
);
assert!(alpha.abs() < 0.1, "Alpha should be ~0.0, got {}", alpha);
let max_residual = residuals.iter().map(|&r| r.abs()).fold(0.0, f64::max);
assert!(max_residual < 0.1, "Max residual should be small");
}
#[test]
fn test_critical_values() {
let cv_50 = get_engle_granger_critical_values(50, "c");
let cv_100 = get_engle_granger_critical_values(100, "c");
let cv_200 = get_engle_granger_critical_values(200, "c");
assert!(cv_50.cv_5pct < cv_100.cv_5pct);
assert!(cv_100.cv_5pct <= cv_200.cv_5pct);
assert!(cv_100.cv_1pct < cv_100.cv_5pct);
assert!(cv_100.cv_5pct < cv_100.cv_10pct);
}
#[test]
fn test_ols_regression_with_trend() {
let n = 30usize;
let x: Vec<f32> = (0..n).map(|i| (i as f32).sin() + 3.0).collect();
let y: Vec<f32> = (0..n)
.map(|i| 1.0 + 2.0 * x[i] + 0.5 * (i as f32))
.collect();
let (alpha, beta, residuals) = ols_regression_with_trend(&y, &x);
assert!(
(alpha - 1.0).abs() < 1.0,
"alpha should be ~1.0, got {alpha}"
);
assert!((beta - 2.0).abs() < 0.5, "beta should be ~2.0, got {beta}");
let max_res = residuals.iter().map(|&r| r.abs()).fold(0.0_f64, f64::max);
assert!(max_res < 1.0, "max residual should be small, got {max_res}");
}
#[test]
fn test_solve_3x3_identity() {
let a = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
let b = [3.0, 5.0, 7.0];
let sol = solve_3x3(&a, &b).expect("identity system should be solvable");
assert!((sol[0] - 3.0).abs() < 1e-10);
assert!((sol[1] - 5.0).abs() < 1e-10);
assert!((sol[2] - 7.0).abs() < 1e-10);
}
#[test]
fn test_solve_3x3_singular_returns_none() {
let a = [[1.0, 2.0, 3.0], [1.0, 2.0, 3.0], [0.0, 0.0, 1.0]];
let b = [1.0, 2.0, 3.0];
assert!(solve_3x3(&a, &b).is_none());
}
#[test]
fn test_jacobi_eig_2x2() {
let mat = vec![vec![2.0, 1.0], vec![1.0, 2.0]];
let (eigenvalues, _) = jacobi_eig_symmetric(&mat, 100);
assert_eq!(eigenvalues.len(), 2);
assert!(
(eigenvalues[0] - 3.0).abs() < 1e-10,
"expected 3.0, got {}",
eigenvalues[0]
);
assert!(
(eigenvalues[1] - 1.0).abs() < 1e-10,
"expected 1.0, got {}",
eigenvalues[1]
);
}
#[test]
fn test_svd_small_rank1() {
let a = vec![vec![1.0, 0.0], vec![0.0, 0.0]];
let (u, sigma, v) = svd_small(&a, 1);
assert_eq!(sigma.len(), 1);
assert!(
(sigma[0] - 1.0).abs() < 1e-6,
"singular value should be ~1.0, got {}",
sigma[0]
);
assert_eq!(u.len(), 2);
assert_eq!(v.len(), 2);
}
fn create_vecm_test_series() -> Vec<TimeSeries> {
let n = 60usize;
let mut y1_data = Vec::with_capacity(n);
let mut y2_data = Vec::with_capacity(n);
let mut level = 10.0_f32;
for i in 0..n {
level += ((i % 3) as f32 - 1.0) * 0.5;
y1_data.push(level);
y2_data.push(2.0 * level + ((i % 5) as f32 - 2.0) * 0.1);
}
let t1 = Tensor::from_vec(y1_data, &[n]).expect("tensor y1");
let t2 = Tensor::from_vec(y2_data, &[n]).expect("tensor y2");
vec![TimeSeries::new(t1), TimeSeries::new(t2)]
}
#[test]
fn test_vecm_fit_basic() {
let series = create_vecm_test_series();
let mut vecm = VECM::new(2, 1);
vecm.fit(&series).expect("fit should succeed");
assert!(
vecm.pi_matrix.is_some(),
"pi_matrix should be set after fit"
);
assert!(vecm.alpha.is_some(), "alpha should be set after fit");
assert!(vecm.beta.is_some(), "beta should be set after fit");
let pi = vecm.pi_matrix.as_ref().expect("pi");
assert_eq!(pi.shape(), [2, 2]);
}
#[test]
fn test_vecm_forecast_shape() {
let series = create_vecm_test_series();
let mut vecm = VECM::new(2, 1);
vecm.fit(&series).expect("fit should succeed");
let steps = 10;
let forecasts = vecm.forecast(steps).expect("forecast should succeed");
assert_eq!(forecasts.len(), 2, "should return one series per variable");
for ts in &forecasts {
assert_eq!(ts.len(), steps, "each series should have {steps} points");
}
}
#[test]
fn test_vecm_impulse_response_shape() {
let series = create_vecm_test_series();
let mut vecm = VECM::new(2, 1);
vecm.fit(&series).expect("fit should succeed");
let periods = 5;
let irf = vecm.impulse_response(periods).expect("irf should succeed");
assert_eq!(
irf.len(),
periods + 1,
"should have periods+1 matrices (including h=0)"
);
for (h, phi) in irf.iter().enumerate() {
assert_eq!(phi.shape(), [2, 2], "matrix at h={h} should be 2x2");
}
}
#[test]
fn test_vecm_fit_invalid_rank() {
let series = create_vecm_test_series();
let mut vecm = VECM::new(2, 5);
assert!(vecm.fit(&series).is_err());
}
}