use ndarray::Array1;
use ndarray::Array2;
#[derive(Debug, Clone)]
pub struct DrrGarch {
pub alpha_ar: f64,
pub beta: Array1<f64>,
pub garch_params: (f64, f64, f64),
pub intercept: f64,
pub lambda1: f64,
pub lambda2: f64,
}
#[derive(Debug, Clone)]
pub struct DrrGarchFit {
pub model: DrrGarch,
pub residuals: Array1<f64>,
pub conditional_variance: Array1<f64>,
pub cv_scores: Vec<(f64, f64)>,
}
fn cholesky(a: &Array2<f64>) -> Array2<f64> {
let n = a.nrows();
let mut l = Array2::<f64>::zeros((n, n));
for j in 0..n {
let mut sum = 0.0;
for k in 0..j {
sum += l[[j, k]] * l[[j, k]];
}
let diag = a[[j, j]] - sum;
assert!(
diag > 0.0,
"Cholesky: matrix is not positive definite (diag={diag} at j={j})"
);
l[[j, j]] = diag.sqrt();
for i in (j + 1)..n {
let mut sum = 0.0;
for k in 0..j {
sum += l[[i, k]] * l[[j, k]];
}
l[[i, j]] = (a[[i, j]] - sum) / l[[j, j]];
}
}
l
}
fn cholesky_solve(a: &Array2<f64>, b: &Array1<f64>) -> Array1<f64> {
let l = cholesky(a);
let n = b.len();
let mut y = Array1::<f64>::zeros(n);
for i in 0..n {
let mut sum = 0.0;
for j in 0..i {
sum += l[[i, j]] * y[j];
}
y[i] = (b[i] - sum) / l[[i, i]];
}
let mut x = Array1::<f64>::zeros(n);
for i in (0..n).rev() {
let mut sum = 0.0;
for j in (i + 1)..n {
sum += l[[j, i]] * x[j];
}
x[i] = (y[i] - sum) / l[[i, i]];
}
x
}
pub fn ridge_solve(x: &Array2<f64>, y: &Array1<f64>, lambda: f64) -> Array1<f64> {
let n = x.nrows() as f64;
let p = x.ncols();
let xt = x.t();
let mut a = xt.dot(x);
a /= n;
for i in 0..p {
a[[i, i]] += lambda;
}
let b = xt.dot(y) / n;
cholesky_solve(&a, &b)
}
pub fn cross_validate_lambda(
x: &Array2<f64>,
y: &Array1<f64>,
lambdas: &[f64],
k_folds: usize,
) -> (f64, Vec<(f64, f64)>) {
assert!(k_folds >= 2, "CV requires at least 2 folds");
let n = x.nrows();
let p = x.ncols();
let fold_ids: Vec<usize> = (0..n).map(|i| i % k_folds).collect();
let mut scores: Vec<(f64, f64)> = Vec::with_capacity(lambdas.len());
let mut best_lambda = lambdas[0];
let mut best_mse = f64::INFINITY;
for &lam in lambdas {
let mut total_mse = 0.0;
let mut total_test = 0usize;
for fold in 0..k_folds {
let n_test = fold_ids.iter().filter(|&&f| f == fold).count();
let n_train = n - n_test;
if n_train == 0 || n_test == 0 {
continue;
}
let mut x_train = Array2::<f64>::zeros((n_train, p));
let mut y_train = Array1::<f64>::zeros(n_train);
let mut x_test = Array2::<f64>::zeros((n_test, p));
let mut y_test = Array1::<f64>::zeros(n_test);
let mut tr_idx = 0;
let mut te_idx = 0;
for i in 0..n {
if fold_ids[i] == fold {
x_test.row_mut(te_idx).assign(&x.row(i));
y_test[te_idx] = y[i];
te_idx += 1;
} else {
x_train.row_mut(tr_idx).assign(&x.row(i));
y_train[tr_idx] = y[i];
tr_idx += 1;
}
}
let beta_hat = ridge_solve(&x_train, &y_train, lam);
let y_pred = x_test.dot(&beta_hat);
let diff = &y_test - &y_pred;
let mse: f64 = diff.iter().map(|d| d * d).sum::<f64>() / n_test as f64;
total_mse += mse * n_test as f64;
total_test += n_test;
}
let avg_mse = if total_test > 0 {
total_mse / total_test as f64
} else {
f64::INFINITY
};
scores.push((lam, avg_mse));
if avg_mse < best_mse {
best_mse = avg_mse;
best_lambda = lam;
}
}
let corrected = best_lambda * (k_folds - 1) as f64 / k_folds as f64;
(corrected, scores)
}
pub fn fit_garch11(residuals: &Array1<f64>) -> (f64, f64, f64) {
let n = residuals.len();
assert!(n >= 4, "Need at least 4 residuals to fit Garch(1,1)");
let mean_eps: f64 = residuals.iter().sum::<f64>() / n as f64;
let eps_centered: Array1<f64> = residuals.mapv(|e| e - mean_eps);
let gamma0: f64 = eps_centered.iter().map(|e| e * e).sum::<f64>() / n as f64;
if gamma0 < 1e-15 {
return (1e-10, 0.01, 0.01);
}
let eps2: Array1<f64> = eps_centered.mapv(|e| e * e);
let mean_eps2 = gamma0;
let mut cov1 = 0.0;
let mut var_eps2 = 0.0;
for t in 1..n {
let d0 = eps2[t] - mean_eps2;
let d1 = eps2[t - 1] - mean_eps2;
cov1 += d0 * d1;
var_eps2 += d0 * d0;
}
let d0_first = eps2[0] - mean_eps2;
var_eps2 += d0_first * d0_first;
var_eps2 /= n as f64;
cov1 /= (n - 1) as f64;
let rho1 = if var_eps2 > 1e-15 {
(cov1 / var_eps2).clamp(-0.99, 0.99)
} else {
0.0
};
let mut cov2 = 0.0;
for t in 2..n {
let d0 = eps2[t] - mean_eps2;
let d2 = eps2[t - 2] - mean_eps2;
cov2 += d0 * d2;
}
if n > 2 {
cov2 /= (n - 2) as f64;
}
let rho2 = if var_eps2 > 1e-15 {
(cov2 / var_eps2).clamp(-0.99, 0.99)
} else {
0.0
};
let persistence = rho1.abs().clamp(0.0, 0.98);
let ratio = if rho1.abs() > 1e-10 {
(rho2 / rho1).clamp(0.0, 0.99)
} else {
0.9
};
let beta1 = (persistence * ratio).clamp(0.01, 0.97);
let alpha1 = (persistence - beta1).clamp(0.01, 0.97 - beta1);
let sum_ab = alpha1 + beta1;
let (alpha1, beta1) = if sum_ab >= 0.999 {
let scale = 0.98 / sum_ab;
(alpha1 * scale, beta1 * scale)
} else {
(alpha1, beta1)
};
let omega = gamma0 * (1.0 - alpha1 - beta1);
let omega = omega.max(1e-10);
(omega, alpha1, beta1)
}
pub fn fit_drr_garch(
y: &Array1<f64>,
x: &Array2<f64>,
lambda1: Option<f64>,
k_folds: Option<usize>,
) -> DrrGarchFit {
let n = y.len();
let p = x.ncols();
assert!(n >= 4, "Need at least 4 observations");
assert_eq!(x.nrows(), n, "x must have same number of rows as y");
let k = k_folds.unwrap_or(5);
let lam1 = lambda1.unwrap_or(p as f64 / n as f64);
let n_eff = n - 1;
let mut z = Array2::<f64>::zeros((n_eff, 1 + p));
let mut y_next = Array1::<f64>::zeros(n_eff);
for t in 0..n_eff {
z[[t, 0]] = y[t]; for j in 0..p {
z[[t, 1 + j]] = x[[t, j]];
}
y_next[t] = y[t + 1];
}
let theta_hat = ridge_solve(&z, &y_next, lam1);
let alpha_ar = theta_hat[0];
let mut residuals_step1 = Array1::<f64>::zeros(n_eff);
for t in 0..n_eff {
residuals_step1[t] = y_next[t] - alpha_ar * y[t];
}
let x_lag = x.slice(ndarray::s![..n_eff, ..]).to_owned();
let lambdas: Vec<f64> = (0..20)
.map(|i| 10.0_f64.powf(-4.0 + i as f64 * 5.0 / 19.0))
.collect();
let (lam2, cv_scores) = cross_validate_lambda(&x_lag, &residuals_step1, &lambdas, k);
let beta_hat = ridge_solve(&x_lag, &residuals_step1, lam2);
let predicted = x_lag.dot(&beta_hat);
let final_residuals = &residuals_step1 - &predicted;
let garch_params = fit_garch11(&final_residuals);
let (omega, garch_a, garch_b) = garch_params;
let mut cond_var = Array1::<f64>::zeros(n_eff);
let uncond_var = if (1.0 - garch_a - garch_b) > 1e-10 {
omega / (1.0 - garch_a - garch_b)
} else {
omega
};
cond_var[0] = uncond_var;
for t in 1..n_eff {
cond_var[t] = omega + garch_a * final_residuals[t - 1].powi(2) + garch_b * cond_var[t - 1];
}
DrrGarchFit {
model: DrrGarch {
alpha_ar,
beta: beta_hat,
garch_params,
intercept: 0.0,
lambda1: lam1,
lambda2: lam2,
},
residuals: final_residuals,
conditional_variance: cond_var,
cv_scores,
}
}
impl DrrGarch {
pub fn predict(&self, y_last: f64, x_new: &Array1<f64>) -> (f64, f64) {
let y_hat = self.intercept + self.alpha_ar * y_last + self.beta.dot(x_new);
let (omega, a, b) = self.garch_params;
let sigma2 = if (1.0 - a - b) > 1e-10 {
omega / (1.0 - a - b)
} else {
omega
};
(y_hat, sigma2)
}
pub fn forecast(&self, y: &[f64], x: &Array2<f64>, h: usize) -> (Array1<f64>, Array1<f64>) {
assert!(!y.is_empty(), "Need at least one historical observation");
assert_eq!(x.nrows(), h, "x must have h rows for h-step forecast");
assert_eq!(
x.ncols(),
self.beta.len(),
"x must have same number of columns as beta"
);
let (omega, a, b) = self.garch_params;
let mut forecasts = Array1::<f64>::zeros(h);
let mut variances = Array1::<f64>::zeros(h);
let uncond_var = if (1.0 - a - b) > 1e-10 {
omega / (1.0 - a - b)
} else {
omega
};
let mut y_prev = *y.last().unwrap();
let mut sigma2_prev = uncond_var;
for t in 0..h {
let x_row = x.row(t).to_owned();
let y_hat = self.intercept + self.alpha_ar * y_prev + self.beta.dot(&x_row);
forecasts[t] = y_hat;
sigma2_prev = omega + (a + b) * sigma2_prev;
variances[t] = sigma2_prev;
y_prev = y_hat;
}
(forecasts, variances)
}
}
#[cfg(test)]
mod tests {
use ndarray::Array2;
use super::*;
#[test]
fn ridge_solve_recovers_simple_coefficients() {
let n = 200;
let x = Array2::from_shape_fn((n, 2), |(i, j)| {
if j == 0 {
(i as f64) / n as f64
} else {
((i * 7 + 3) % n) as f64 / n as f64
}
});
let y = Array1::from_shape_fn(n, |i| {
2.0 * x[[i, 0]] + 3.0 * x[[i, 1]] + 0.01 * ((i * 13) % 17) as f64 / 17.0
});
let beta = ridge_solve(&x, &y, 0.001);
assert!((beta[0] - 2.0).abs() < 0.5, "beta[0]={}", beta[0]);
assert!((beta[1] - 3.0).abs() < 0.5, "beta[1]={}", beta[1]);
}
#[test]
fn cross_validation_selects_reasonable_lambda() {
let n = 100;
let p = 5;
let x = Array2::from_shape_fn((n, p), |(i, j)| {
((i * (j + 1) * 7 + 11) % 100) as f64 / 100.0
});
let y = Array1::from_shape_fn(n, |i| {
x[[i, 0]] * 1.5 + x[[i, 1]] * 0.5 + 0.1 * ((i * 3) % 10) as f64 / 10.0
});
let lambdas: Vec<f64> = (0..20)
.map(|i| 10.0_f64.powf(-4.0 + i as f64 * 0.5))
.collect();
let (best_lambda, scores) = cross_validate_lambda(&x, &y, &lambdas, 5);
assert!(best_lambda > 0.0, "lambda should be positive");
assert!(!scores.is_empty(), "should have scores");
}
#[test]
fn fit_drr_garch_produces_valid_model() {
let n = 500;
let p = 10;
let mut y = Array1::<f64>::zeros(n);
y[0] = 0.5;
for t in 1..n {
y[t] = 0.5 * y[t - 1] + 0.1 * ((t * 7 + 3) % 20) as f64 / 20.0 - 0.05;
}
let x = Array2::from_shape_fn((n, p), |(i, j)| {
((i * (j + 1) * 13 + 7) % 100) as f64 / 100.0 - 0.5
});
let fit = fit_drr_garch(&y, &x, None, None);
assert!(
fit.model.alpha_ar.abs() < 1.0,
"AR coeff should be < 1 in absolute value"
);
assert!(fit.model.garch_params.0 > 0.0, "omega should be > 0");
assert!(!fit.residuals.is_empty(), "should have residuals");
}
#[test]
fn garch_fit_recovers_reasonable_params() {
let n = 1000;
let omega = 0.05;
let alpha = 0.08;
let beta = 0.9;
let mut eps = Array1::<f64>::zeros(n);
let mut sig2 = Array1::<f64>::zeros(n);
sig2[0] = omega / (1.0 - alpha - beta);
eps[0] = sig2[0].sqrt() * 0.5; for t in 1..n {
sig2[t] = omega + alpha * eps[t - 1].powi(2) + beta * sig2[t - 1];
eps[t] = sig2[t].sqrt() * (((t * 17 + 3) % 100) as f64 / 50.0 - 1.0);
}
let (w, a, b) = fit_garch11(&eps);
assert!(w > 0.0, "omega should be > 0, got {w}");
assert!(
a + b < 1.0,
"stationarity: a+b should be < 1, got {}",
a + b
);
}
}