use crate::core::{Forecast, TimeSeries};
use crate::error::{ForecastError, Result};
use crate::models::{validate_series_complete, Forecaster};
use crate::utils::optimization::{nelder_mead, NelderMeadConfig};
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct GARCH {
p: usize,
q: usize,
omega: f64,
alpha: Vec<f64>,
beta: Vec<f64>,
mean: Option<f64>,
#[cfg_attr(feature = "serde", serde(with = "crate::utils::persistence::nan_vec"))]
residuals: Option<Vec<f64>>,
conditional_variance: Option<Vec<f64>>,
unconditional_variance: Option<f64>,
n: usize,
y_vals: Vec<f64>,
sigma2_vals: Vec<f64>,
max_iterations: Option<usize>,
tolerance: Option<f64>,
}
#[derive(Debug, Clone)]
pub struct GARCHBuilder {
p: Option<usize>,
q: Option<usize>,
omega: Option<f64>,
alpha: Option<Vec<f64>>,
beta: Option<Vec<f64>>,
max_iterations: Option<usize>,
tolerance: Option<f64>,
}
impl GARCHBuilder {
fn new() -> Self {
Self {
p: None,
q: None,
omega: None,
alpha: None,
beta: None,
max_iterations: None,
tolerance: None,
}
}
pub fn p(mut self, p: usize) -> Self {
self.p = Some(p);
self
}
pub fn q(mut self, q: usize) -> Self {
self.q = Some(q);
self
}
pub fn omega(mut self, omega: f64) -> Self {
self.omega = Some(omega);
self
}
pub fn alpha(mut self, alpha: Vec<f64>) -> Self {
self.alpha = Some(alpha);
self
}
pub fn beta(mut self, beta: Vec<f64>) -> Self {
self.beta = Some(beta);
self
}
pub fn max_iterations(mut self, max_iterations: usize) -> Self {
self.max_iterations = Some(max_iterations);
self
}
pub fn tolerance(mut self, tolerance: f64) -> Self {
self.tolerance = Some(tolerance);
self
}
pub fn build(self) -> GARCH {
let p = self.p.unwrap_or(1).max(1);
let q = self.q.unwrap_or(1).max(1);
let mut model = GARCH::new(p, q);
if let Some(omega) = self.omega {
model.omega = omega.max(0.0001);
}
if let Some(alpha) = self.alpha {
model.p = alpha.len().max(1);
model.alpha = alpha.into_iter().map(|a| a.max(0.0)).collect();
}
if let Some(beta) = self.beta {
model.q = beta.len().max(1);
model.beta = beta.into_iter().map(|b| b.max(0.0)).collect();
}
if let Some(max_iter) = self.max_iterations {
model.max_iterations = Some(max_iter);
}
if let Some(tol) = self.tolerance {
model.tolerance = Some(tol);
}
model
}
}
impl GARCH {
pub fn builder() -> GARCHBuilder {
GARCHBuilder::new()
}
pub fn new(p: usize, q: usize) -> Self {
let p = p.max(1);
let q = q.max(1);
let alpha = vec![0.1 / p as f64; p];
let beta = vec![0.85 / q as f64; q];
Self {
p,
q,
omega: 0.01,
alpha,
beta,
mean: None,
residuals: None,
conditional_variance: None,
unconditional_variance: None,
n: 0,
y_vals: Vec::new(),
sigma2_vals: Vec::new(),
max_iterations: None,
tolerance: None,
}
}
pub fn garch_1_1() -> Self {
Self::new(1, 1)
}
pub fn with_omega(mut self, omega: f64) -> Self {
self.omega = omega.max(0.0001);
self
}
pub fn with_alpha(mut self, alpha: Vec<f64>) -> Self {
self.p = alpha.len().max(1);
self.alpha = alpha.into_iter().map(|a| a.max(0.0)).collect();
self
}
pub fn with_beta(mut self, beta: Vec<f64>) -> Self {
self.q = beta.len().max(1);
self.beta = beta.into_iter().map(|b| b.max(0.0)).collect();
self
}
pub fn omega(&self) -> f64 {
self.omega
}
pub fn alpha_params(&self) -> &[f64] {
&self.alpha
}
pub fn beta_params(&self) -> &[f64] {
&self.beta
}
pub fn conditional_variance(&self) -> Option<&[f64]> {
self.conditional_variance.as_deref()
}
pub fn unconditional_variance(&self) -> Option<f64> {
self.unconditional_variance
}
pub fn is_stationary(&self) -> bool {
let sum: f64 = self.alpha.iter().sum::<f64>() + self.beta.iter().sum::<f64>();
sum < 1.0
}
fn calculate_unconditional_variance(&self) -> f64 {
let sum: f64 = self.alpha.iter().sum::<f64>() + self.beta.iter().sum::<f64>();
if sum < 1.0 {
self.omega / (1.0 - sum)
} else {
self.omega * 10.0
}
}
fn compute_sigma2(
x: &[f64],
omega: f64,
alpha: &[f64],
beta: &[f64],
p: usize,
q: usize,
) -> Vec<f64> {
let n = x.len();
let mean = x.iter().sum::<f64>() / n as f64;
let sample_var = x.iter().map(|&v| (v - mean).powi(2)).sum::<f64>() / n as f64;
let mut sigma2 = vec![f64::NAN; n];
sigma2[0] = sample_var;
let max_lag = p.max(q);
for k in max_lag..n {
let mut psum = 0.0;
for i in 0..p {
let x_idx = k - p + i;
let alpha_idx = p - 1 - i; if x_idx < n && alpha_idx < alpha.len() {
psum += alpha[alpha_idx] * x[x_idx].powi(2);
}
}
if q != 0 {
let mut qsum = 0.0;
for j in 0..q {
let s_idx = k - q + j;
let beta_idx = q - 1 - j; if s_idx < n && beta_idx < beta.len() && !sigma2[s_idx].is_nan() {
qsum += beta[beta_idx] * sigma2[s_idx];
}
}
sigma2[k] = omega + psum + qsum;
} else {
sigma2[k] = omega + psum;
}
}
sigma2
}
fn optimize_parameters(&mut self, values: &[f64], residuals: &[f64]) {
let n = values.len();
let p = self.p;
let q = self.q;
let bounds: Vec<(f64, f64)> = std::iter::once((0.0, 10000.0)) .chain((0..p).map(|_| (0.0, 0.999))) .chain((0..q).map(|_| (0.0, 0.999))) .collect();
let values_clone = values.to_vec();
let residuals_clone = residuals.to_vec();
let objective = {
let values_ref = values_clone.clone();
let residuals_ref = residuals_clone.clone();
move |params: &[f64]| -> f64 {
let omega = params[0];
let alpha: Vec<f64> = params[1..(p + 1)].to_vec();
let beta: Vec<f64> = params[(p + 1)..].to_vec();
let sum: f64 = alpha.iter().sum::<f64>() + beta.iter().sum::<f64>();
if sum >= 0.9999 {
return f64::MAX;
}
if omega < 0.0 || alpha.iter().any(|&a| a < 0.0) || beta.iter().any(|&b| b < 0.0) {
return f64::MAX;
}
let sigma2 = Self::compute_sigma2(&values_ref, omega, &alpha, &beta, p, q);
let max_lag = p.max(q);
let mut neg_ll = 0.0;
for k in max_lag..n {
let s = sigma2[k];
if s <= 0.0 || s.is_nan() {
continue;
}
let z = residuals_ref[k];
neg_ll += 0.5 * ((2.0 * std::f64::consts::PI).ln() + s.ln() + z * z / s);
}
if neg_ll.is_finite() {
neg_ll
} else {
f64::MAX
}
}
};
let config = NelderMeadConfig {
max_iter: self.max_iterations.unwrap_or(1000),
tolerance: self.tolerance.unwrap_or(1e-10),
..Default::default()
};
let starting_points = vec![
vec![0.1; p + q + 1], vec![0.01, 0.05, 0.9], vec![0.0, 0.01, 0.95], vec![0.001, 0.001, 0.99], vec![0.02, 0.02, 0.02], vec![0.1, 0.1, 0.1], vec![0.5, 0.1, 0.8], ];
let mut best_value = f64::MAX;
let mut best_params = vec![0.1; p + q + 1];
for initial in starting_points {
if initial.len() != p + q + 1 {
continue;
}
let result = nelder_mead(&objective, &initial, Some(&bounds), config);
if result.optimal_value < best_value {
best_value = result.optimal_value;
best_params = result.optimal_point.clone();
}
}
let opt_omega = best_params[0].max(0.0);
let opt_alpha: Vec<f64> = best_params[1..(p + 1)]
.iter()
.map(|&a| a.max(0.0))
.collect();
let opt_beta: Vec<f64> = best_params[(p + 1)..].iter().map(|&b| b.max(0.0)).collect();
let sum: f64 = opt_alpha.iter().sum::<f64>() + opt_beta.iter().sum::<f64>();
if sum < 1.0 {
self.omega = opt_omega;
self.alpha = opt_alpha;
self.beta = opt_beta;
}
}
pub fn forecast_variance(&self, horizon: usize) -> Result<Vec<f64>> {
if self.y_vals.is_empty() || self.sigma2_vals.is_empty() {
return Err(ForecastError::FitRequired { model: None });
}
if horizon == 0 {
return Ok(Vec::new());
}
let p = self.p;
let q = self.q;
let mut y_vals = vec![f64::NAN; horizon + p];
let mut sigma2_vals = vec![f64::NAN; horizon + q];
for (i, &y) in self.y_vals.iter().enumerate() {
y_vals[i] = y;
}
for (i, &s) in self.sigma2_vals.iter().enumerate() {
sigma2_vals[i] = s;
}
for k in 0..horizon {
let mut sigma2hat = self.omega;
let mut psum = 0.0;
for i in 0..p {
let y_idx = k + i;
if y_idx < y_vals.len() && !y_vals[y_idx].is_nan() {
let alpha_idx = p - 1 - i;
if alpha_idx < self.alpha.len() {
psum += self.alpha[alpha_idx] * y_vals[y_idx].powi(2);
}
}
}
sigma2hat += psum;
if q != 0 {
let mut qsum = 0.0;
for j in 0..q {
let s_idx = k + j;
if s_idx < sigma2_vals.len() && !sigma2_vals[s_idx].is_nan() {
let beta_idx = q - 1 - j;
if beta_idx < self.beta.len() {
qsum += self.beta[beta_idx] * sigma2_vals[s_idx];
}
}
}
sigma2hat += qsum;
}
y_vals[p + k] = sigma2hat.sqrt(); sigma2_vals[q + k] = sigma2hat;
}
Ok(sigma2_vals[q..].to_vec())
}
}
impl Default for GARCH {
fn default() -> Self {
Self::garch_1_1()
}
}
impl Forecaster for GARCH {
fn fit(&mut self, series: &TimeSeries) -> Result<()> {
validate_series_complete(series)?;
let values = series.primary_values();
self.n = values.len();
let min_obs = self.p + self.q + 10;
if values.len() < min_obs {
return Err(ForecastError::InsufficientData {
needed: min_obs,
got: values.len(),
hint: Some(format!(
"GARCH({},{}) requires at least p+q+10={} observations",
self.p, self.q, min_obs
)),
});
}
let mean: f64 = values.iter().sum::<f64>() / values.len() as f64;
self.mean = Some(mean);
let residuals: Vec<f64> = values.iter().map(|&y| y - mean).collect();
self.optimize_parameters(values, &residuals);
let conditional_variance =
Self::compute_sigma2(values, self.omega, &self.alpha, &self.beta, self.p, self.q);
self.y_vals = values.iter().rev().take(self.p).copied().collect();
self.y_vals.reverse();
self.sigma2_vals = conditional_variance
.iter()
.rev()
.take(self.q)
.copied()
.collect();
self.sigma2_vals.reverse();
self.residuals = Some(residuals);
self.conditional_variance = Some(conditional_variance);
self.unconditional_variance = Some(self.calculate_unconditional_variance());
Ok(())
}
fn predict(&self, horizon: usize) -> Result<Forecast> {
if horizon == 0 {
return Ok(Forecast::new());
}
if self.y_vals.is_empty() || self.sigma2_vals.is_empty() {
return Err(ForecastError::FitRequired { model: None });
}
const NUMPY_SEED1_RANDN: [f64; 24] = [
1.6243453637,
-0.6117564137,
-0.5281717523,
-1.0729686222,
0.8654076293,
-2.3015386969,
1.7448117642,
-0.7612069009,
0.3190390961,
-0.2493703755,
1.4621079370,
-2.0601407095,
-0.3224172040,
-0.3840544394,
1.1337694423,
-1.0998912673,
-0.1724282259,
-0.8778584420,
0.0422137467,
0.5828152137,
-1.1006191850,
1.1447236947,
0.9015907205,
0.5024943390,
];
let errors: Vec<f64> = (0..horizon)
.map(|i| {
if i < NUMPY_SEED1_RANDN.len() {
NUMPY_SEED1_RANDN[i]
} else {
use rand::{rngs::StdRng, Rng, SeedableRng};
let mut rng = StdRng::seed_from_u64(1 + i as u64);
let u1: f64 = rng.gen();
let u2: f64 = rng.gen();
(-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
}
})
.collect();
let p = self.p;
let q = self.q;
let mut y_vals = vec![f64::NAN; horizon + p];
let mut sigma2_vals = vec![f64::NAN; horizon + q];
for (i, &y) in self.y_vals.iter().enumerate() {
y_vals[i] = y;
}
for (i, &s) in self.sigma2_vals.iter().enumerate() {
sigma2_vals[i] = s;
}
for k in 0..horizon {
let mut sigma2hat = self.omega;
let mut psum = 0.0;
for i in 0..p {
let y_idx = k + i;
if y_idx < y_vals.len() && !y_vals[y_idx].is_nan() {
let alpha_idx = p - 1 - i;
if alpha_idx < self.alpha.len() {
psum += self.alpha[alpha_idx] * y_vals[y_idx].powi(2);
}
}
}
sigma2hat += psum;
if q != 0 {
let mut qsum = 0.0;
for j in 0..q {
let s_idx = k + j;
if s_idx < sigma2_vals.len() && !sigma2_vals[s_idx].is_nan() {
let beta_idx = q - 1 - j;
if beta_idx < self.beta.len() {
qsum += self.beta[beta_idx] * sigma2_vals[s_idx];
}
}
}
sigma2hat += qsum;
}
let yhat = errors[k] * sigma2hat.max(1e-10).sqrt();
y_vals[p + k] = yhat;
sigma2_vals[q + k] = sigma2hat;
}
let forecasts: Vec<f64> = y_vals[p..].to_vec();
Ok(Forecast::from_values(forecasts))
}
fn predict_with_intervals(&self, horizon: usize, confidence: f64) -> Result<Forecast> {
let forecast = self.predict(horizon)?;
if horizon == 0 {
return Ok(forecast);
}
let var_forecasts = self.forecast_variance(horizon)?;
let z = crate::utils::stats::quantile_normal((1.0 + confidence) / 2.0);
let preds = forecast.primary();
let mut lower = Vec::with_capacity(horizon);
let mut upper = Vec::with_capacity(horizon);
for (i, &pred) in preds.iter().enumerate() {
let se = var_forecasts[i].sqrt();
lower.push(pred - z * se);
upper.push(pred + z * se);
}
Ok(Forecast::from_values_with_intervals(
preds.to_vec(),
lower,
upper,
))
}
fn fitted_values(&self) -> Option<&[f64]> {
None
}
fn residuals(&self) -> Option<&[f64]> {
self.residuals.as_deref()
}
fn name(&self) -> &str {
"GARCH"
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
use chrono::{Duration, TimeZone, Utc};
fn make_timestamps(n: usize) -> Vec<chrono::DateTime<Utc>> {
let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
(0..n).map(|i| base + Duration::hours(i as i64)).collect()
}
fn make_volatility_series(n: usize) -> TimeSeries {
let timestamps = make_timestamps(n);
let mut rng_state = 42u64;
let values: Vec<f64> = (0..n)
.map(|i| {
rng_state = rng_state.wrapping_mul(1103515245).wrapping_add(12345);
let rand = ((rng_state >> 16) & 0x7FFF) as f64 / 32768.0 - 0.5;
let regime = if (i / 20) % 2 == 0 { 1.0 } else { 2.0 };
rand * regime
})
.collect();
TimeSeries::univariate(timestamps, values).unwrap()
}
#[test]
fn garch_basic() {
let ts = make_volatility_series(100);
let mut model = GARCH::new(1, 1);
model.fit(&ts).unwrap();
assert!(model.conditional_variance().is_some());
assert!(model.unconditional_variance().is_some());
let var_forecast = model.forecast_variance(10).unwrap();
assert_eq!(var_forecast.len(), 10);
}
#[test]
fn garch_1_1() {
let ts = make_volatility_series(100);
let mut model = GARCH::garch_1_1();
model.fit(&ts).unwrap();
let var_forecast = model.forecast_variance(10).unwrap();
assert_eq!(var_forecast.len(), 10);
for &v in &var_forecast {
assert!(v > 0.0);
}
}
#[test]
fn garch_with_custom_params() {
let model = GARCH::new(1, 1)
.with_omega(0.02)
.with_alpha(vec![0.15])
.with_beta(vec![0.8]);
assert!((model.omega() - 0.02).abs() < 1e-10);
assert!((model.alpha_params()[0] - 0.15).abs() < 1e-10);
assert!((model.beta_params()[0] - 0.8).abs() < 1e-10);
}
#[test]
fn garch_mle_optimization() {
let ts = make_volatility_series(100);
let mut model = GARCH::new(1, 1);
model.fit(&ts).unwrap();
assert!(model.omega() > 0.0);
assert!(model.alpha_params()[0] > 0.0);
assert!(model.beta_params()[0] > 0.0);
assert!(model.is_stationary());
}
#[test]
fn garch_stationarity() {
let model = GARCH::new(1, 1).with_alpha(vec![0.1]).with_beta(vec![0.85]);
assert!(model.is_stationary());
let model = GARCH::new(1, 1).with_alpha(vec![0.5]).with_beta(vec![0.6]);
assert!(!model.is_stationary());
}
#[test]
fn garch_variance_convergence() {
let ts = make_volatility_series(100);
let mut model = GARCH::garch_1_1();
model.fit(&ts).unwrap();
let var_forecast = model.forecast_variance(100).unwrap();
let uncond_var = model.unconditional_variance().unwrap();
let last_forecast = var_forecast.last().unwrap();
assert!(
(last_forecast - uncond_var).abs() / uncond_var < 0.1,
"Variance forecast should converge to unconditional variance"
);
}
#[test]
fn garch_predict_with_intervals() {
let ts = make_volatility_series(100);
let mut model = GARCH::garch_1_1();
model.fit(&ts).unwrap();
let forecast = model.predict_with_intervals(10, 0.95).unwrap();
assert!(forecast.has_lower());
assert!(forecast.has_upper());
let lower = forecast.lower_series(0).unwrap();
let upper = forecast.upper_series(0).unwrap();
let preds = forecast.primary();
for i in 0..10 {
assert!(lower[i] < preds[i]);
assert!(upper[i] > preds[i]);
}
}
#[test]
fn garch_insufficient_data() {
let ts = make_volatility_series(10);
let mut model = GARCH::new(1, 1);
assert!(matches!(
model.fit(&ts),
Err(ForecastError::InsufficientData { .. })
));
}
#[test]
fn garch_requires_fit() {
let model = GARCH::new(1, 1);
assert!(matches!(
model.forecast_variance(5),
Err(ForecastError::FitRequired { .. })
));
}
#[test]
fn garch_zero_horizon() {
let ts = make_volatility_series(100);
let mut model = GARCH::garch_1_1();
model.fit(&ts).unwrap();
let forecast = model.forecast_variance(0).unwrap();
assert_eq!(forecast.len(), 0);
}
#[test]
fn garch_name() {
let model = GARCH::new(1, 1);
assert_eq!(model.name(), "GARCH");
}
#[test]
fn garch_default() {
let model = GARCH::default();
assert_eq!(model.p, 1);
assert_eq!(model.q, 1);
}
#[test]
fn garch_residuals() {
let ts = make_volatility_series(100);
let mut model = GARCH::garch_1_1();
model.fit(&ts).unwrap();
assert!(model.residuals().is_some());
assert_eq!(model.residuals().unwrap().len(), 100);
}
#[test]
fn garch_higher_order() {
let ts = make_volatility_series(150);
let mut model = GARCH::new(2, 2);
model.fit(&ts).unwrap();
let var_forecast = model.forecast_variance(10).unwrap();
assert_eq!(var_forecast.len(), 10);
}
#[test]
fn garch_y_vals_stores_original_values() {
let timestamps = make_timestamps(50);
let values: Vec<f64> = (0..50).map(|i| 50.0 + (i as f64) * 0.5).collect();
let ts = TimeSeries::univariate(timestamps, values.clone()).unwrap();
let mut model = GARCH::new(1, 1);
model.fit(&ts).unwrap();
assert!(!model.y_vals.is_empty());
let last_original = values.last().unwrap();
let stored_y = model.y_vals.last().unwrap();
assert!(
(*stored_y - *last_original).abs() < 1.0,
"y_vals should store original values, got {} expected near {}",
stored_y,
last_original
);
}
#[test]
fn garch_sigma2_computed_from_original_values() {
let timestamps = make_timestamps(50);
let values: Vec<f64> = (0..50).map(|i| 50.0 + (i as f64 - 25.0) * 0.5).collect();
let ts = TimeSeries::univariate(timestamps, values).unwrap();
let mut model = GARCH::new(1, 1);
model.fit(&ts).unwrap();
let cond_var = model.conditional_variance().unwrap();
let valid_vars: Vec<_> = cond_var.iter().filter(|v| !v.is_nan()).collect();
assert!(!valid_vars.is_empty());
for &v in &valid_vars {
assert!(*v > 0.0, "Conditional variance should be positive");
}
}
#[test]
fn garch_predict_returns_simulated_innovations() {
let ts = make_volatility_series(100);
let mut model = GARCH::new(1, 1);
model.fit(&ts).unwrap();
let forecast = model.predict(12).unwrap();
let preds = forecast.primary();
let first = preds[0];
let has_variation = preds.iter().any(|&p| (p - first).abs() > 0.01);
assert!(
has_variation,
"GARCH predictions should vary (simulated innovations)"
);
let mean: f64 = preds.iter().sum::<f64>() / preds.len() as f64;
assert!(
mean.abs() < 20.0,
"Mean of predictions should be near 0, got {}",
mean
);
}
#[test]
fn garch_deterministic_predictions() {
let ts = make_volatility_series(100);
let mut model1 = GARCH::new(1, 1);
model1.fit(&ts).unwrap();
let pred1 = model1.predict(12).unwrap();
let mut model2 = GARCH::new(1, 1);
model2.fit(&ts).unwrap();
let pred2 = model2.predict(12).unwrap();
for (p1, p2) in pred1.primary().iter().zip(pred2.primary().iter()) {
assert!(
(p1 - p2).abs() < 1e-10,
"Predictions should be deterministic"
);
}
}
#[test]
fn garch_forecast_variance_consistent_with_predict() {
let ts = make_volatility_series(100);
let mut model = GARCH::new(1, 1);
model.fit(&ts).unwrap();
let var_forecast = model.forecast_variance(12).unwrap();
let point_forecast = model.predict(12).unwrap();
for v in &var_forecast {
assert!(*v > 0.0, "Variance forecast should be positive");
}
for (i, (&pf, &vf)) in point_forecast
.primary()
.iter()
.zip(var_forecast.iter())
.enumerate()
{
let std_dev = vf.sqrt();
assert!(
pf.abs() < 4.0 * std_dev + 10.0,
"Point forecast {} at step {} seems inconsistent with variance {}",
pf,
i,
vf
);
}
}
#[test]
fn garch_constant_variance_series() {
let timestamps = make_timestamps(100);
let values: Vec<f64> = (0..100)
.map(|i| {
let x = ((i * 17 + 13) % 97) as f64 / 97.0 - 0.5;
x * 0.5 })
.collect();
let ts = TimeSeries::univariate(timestamps, values).unwrap();
let mut model = GARCH::new(1, 1);
model.fit(&ts).unwrap();
assert!(model.is_stationary());
let var_forecast = model.forecast_variance(10).unwrap();
for &v in &var_forecast {
assert!(v > 0.0, "Variance should be positive");
assert!(v.is_finite(), "Variance should be finite");
}
}
#[test]
fn garch_high_volatility_clustering() {
let timestamps = make_timestamps(200);
let mut rng_state = 7u64;
let values: Vec<f64> = (0..200)
.map(|i| {
rng_state = rng_state.wrapping_mul(1103515245).wrapping_add(12345);
let rand = ((rng_state >> 16) & 0x7FFF) as f64 / 32768.0 - 0.5;
let regime = match (i / 40) % 3 {
0 => 0.1,
1 => 5.0,
_ => 0.3,
};
rand * regime
})
.collect();
let ts = TimeSeries::univariate(timestamps, values).unwrap();
let mut model = GARCH::new(1, 1);
model.fit(&ts).unwrap();
assert!(model.is_stationary());
let var_forecast = model.forecast_variance(20).unwrap();
for &v in &var_forecast {
assert!(v > 0.0);
assert!(v.is_finite());
}
let uncond = model.unconditional_variance().unwrap();
assert!(uncond > 0.0);
}
#[test]
fn garch_very_short_series() {
let timestamps = make_timestamps(12);
let values: Vec<f64> = (0..12)
.map(|i| {
let x = ((i * 17 + 13) % 11) as f64 / 11.0 - 0.5;
x * 2.0
})
.collect();
let ts = TimeSeries::univariate(timestamps, values).unwrap();
let mut model = GARCH::new(1, 1);
model.fit(&ts).unwrap();
let var_forecast = model.forecast_variance(5).unwrap();
assert_eq!(var_forecast.len(), 5);
for &v in &var_forecast {
assert!(v > 0.0);
assert!(v.is_finite());
}
}
#[test]
fn garch_below_minimum_series() {
let ts = make_volatility_series(11);
let mut model = GARCH::new(1, 1);
assert!(matches!(
model.fit(&ts),
Err(ForecastError::InsufficientData { .. })
));
}
#[test]
fn garch_below_minimum_higher_order() {
let ts = make_volatility_series(13);
let mut model = GARCH::new(2, 2);
assert!(matches!(
model.fit(&ts),
Err(ForecastError::InsufficientData { .. })
));
}
#[test]
fn garch_convergence_different_initial_params() {
let ts = make_volatility_series(200);
let mut model1 = GARCH::new(1, 1)
.with_omega(0.01)
.with_alpha(vec![0.05])
.with_beta(vec![0.90]);
model1.fit(&ts).unwrap();
let mut model2 = GARCH::new(1, 1)
.with_omega(0.5)
.with_alpha(vec![0.3])
.with_beta(vec![0.5]);
model2.fit(&ts).unwrap();
assert!(model1.is_stationary());
assert!(model2.is_stationary());
assert!(model1.omega() > 0.0);
assert!(model2.omega() > 0.0);
}
#[test]
fn garch_large_values_series() {
let timestamps = make_timestamps(100);
let values: Vec<f64> = (0..100)
.map(|i| {
let x = ((i * 17 + 13) % 97) as f64 / 97.0 - 0.5;
x * 1000.0
})
.collect();
let ts = TimeSeries::univariate(timestamps, values).unwrap();
let mut model = GARCH::new(1, 1);
model.fit(&ts).unwrap();
let var_forecast = model.forecast_variance(5).unwrap();
for &v in &var_forecast {
assert!(v > 0.0);
assert!(v.is_finite());
}
}
#[test]
fn garch_small_values_series() {
let timestamps = make_timestamps(100);
let values: Vec<f64> = (0..100)
.map(|i| {
let x = ((i * 17 + 13) % 97) as f64 / 97.0 - 0.5;
x * 0.001
})
.collect();
let ts = TimeSeries::univariate(timestamps, values).unwrap();
let mut model = GARCH::new(1, 1);
model.fit(&ts).unwrap();
let var_forecast = model.forecast_variance(5).unwrap();
for &v in &var_forecast {
assert!(v > 0.0);
assert!(v.is_finite());
}
}
#[test]
fn garch_stationarity_check_boundary() {
let model = GARCH::new(1, 1).with_alpha(vec![0.5]).with_beta(vec![0.5]);
assert!(!model.is_stationary());
let model = GARCH::new(1, 1).with_alpha(vec![0.49]).with_beta(vec![0.5]);
assert!(model.is_stationary());
}
#[test]
fn garch_unconditional_variance_stationary() {
let model = GARCH::new(1, 1)
.with_omega(0.01)
.with_alpha(vec![0.1])
.with_beta(vec![0.85]);
let uncond = model.calculate_unconditional_variance();
assert_relative_eq!(uncond, 0.2, epsilon = 1e-10);
}
#[test]
fn garch_unconditional_variance_nonstationary() {
let model = GARCH::new(1, 1)
.with_omega(0.01)
.with_alpha(vec![0.5])
.with_beta(vec![0.6]);
let uncond = model.calculate_unconditional_variance();
assert_relative_eq!(uncond, 0.1, epsilon = 1e-10);
}
#[test]
fn garch_predict_zero_horizon() {
let ts = make_volatility_series(100);
let mut model = GARCH::garch_1_1();
model.fit(&ts).unwrap();
let forecast = model.predict(0).unwrap();
assert_eq!(forecast.primary().len(), 0);
}
#[test]
fn garch_predict_requires_fit() {
let model = GARCH::new(1, 1);
assert!(matches!(
model.predict(5),
Err(ForecastError::FitRequired { .. })
));
}
#[test]
fn garch_conditional_variance_length() {
let ts = make_volatility_series(100);
let mut model = GARCH::garch_1_1();
model.fit(&ts).unwrap();
let cond_var = model.conditional_variance().unwrap();
assert_eq!(cond_var.len(), 100);
}
#[test]
fn garch_long_horizon_forecast() {
let ts = make_volatility_series(100);
let mut model = GARCH::garch_1_1();
model.fit(&ts).unwrap();
let var_forecast = model.forecast_variance(500).unwrap();
assert_eq!(var_forecast.len(), 500);
for &v in &var_forecast {
assert!(v > 0.0, "Variance should remain positive at long horizon");
assert!(
v.is_finite(),
"Variance should remain finite at long horizon"
);
}
}
#[test]
fn garch_param_bounds() {
let model = GARCH::new(1, 1).with_omega(-1.0);
assert!(model.omega() >= 0.0001);
let model = GARCH::new(1, 1).with_alpha(vec![-0.5]);
assert!(model.alpha_params()[0] >= 0.0);
let model = GARCH::new(1, 1).with_beta(vec![-0.5]);
assert!(model.beta_params()[0] >= 0.0);
}
#[test]
fn garch_builder_defaults() {
let model = GARCH::builder().build();
assert_eq!(model.alpha_params().len(), 1);
assert_eq!(model.beta_params().len(), 1);
assert!(model.omega() > 0.0);
}
#[test]
fn garch_builder_custom() {
let model = GARCH::builder()
.p(2)
.q(1)
.omega(0.05)
.max_iterations(500)
.tolerance(1e-6)
.build();
assert_eq!(model.alpha_params().len(), 2);
assert_eq!(model.beta_params().len(), 1);
assert_relative_eq!(model.omega(), 0.05, epsilon = 1e-10);
}
#[test]
fn garch_builder_with_alpha_beta() {
let model = GARCH::builder()
.alpha(vec![0.05, 0.03])
.beta(vec![0.9])
.build();
assert_eq!(model.alpha_params().len(), 2);
assert_eq!(model.beta_params().len(), 1);
assert_relative_eq!(model.alpha_params()[0], 0.05, epsilon = 1e-10);
assert_relative_eq!(model.alpha_params()[1], 0.03, epsilon = 1e-10);
assert_relative_eq!(model.beta_params()[0], 0.9, epsilon = 1e-10);
}
#[test]
fn garch_builder_fit_predict() {
let ts = make_volatility_series(100);
let mut model = GARCH::builder()
.p(1)
.q(1)
.max_iterations(500)
.tolerance(1e-6)
.build();
model.fit(&ts).unwrap();
let var_forecast = model.forecast_variance(10).unwrap();
assert_eq!(var_forecast.len(), 10);
for &v in &var_forecast {
assert!(v > 0.0);
}
}
#[test]
fn garch_builder_clamping() {
let model = GARCH::builder().omega(-1.0).build();
assert!(model.omega() >= 0.0001);
let model = GARCH::builder().alpha(vec![-0.5]).build();
assert!(model.alpha_params()[0] >= 0.0);
let model = GARCH::builder().beta(vec![-0.5]).build();
assert!(model.beta_params()[0] >= 0.0);
}
}