From c320e62269cb00ce2ac1ad4ed1c075a487f836cd Mon Sep 17 00:00:00 2001 From: Raashish Aggarwal <94279692+raashish1601@users.noreply.github.com> Date: Thu, 8 Oct 2026 05:38:17 +0530 Subject: [PATCH 1/2] BUG: Use the expected variance path in RiskMetrics2006 forecasts The analytic forecast repeated the one-step forecast for every horizon, which is only exact for a single EWMA. Iterate the expected values of the EWMA components instead, so multi-step analytic forecasts agree with the simulation forecasts. --- .../univariate/test_variance_forecasting.py | 14 +++++++++- arch/univariate/volatility.py | 26 ++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/arch/tests/univariate/test_variance_forecasting.py b/arch/tests/univariate/test_variance_forecasting.py index 8a04263d6a..800bbe380e 100644 --- a/arch/tests/univariate/test_variance_forecasting.py +++ b/arch/tests/univariate/test_variance_forecasting.py @@ -1808,8 +1808,20 @@ def test_rm2006_forecast(self): assert forecasts.forecast_paths is None assert forecasts.shocks is None one_step = _sigma2[1:] + assert_allclose(forecasts.forecasts[:, 0], one_step, rtol=1e-4) + # The components are EWMAs of squared residuals, and the expected + # squared residual is the combined variance + w = vol._ewma_combination_weights() + mus = vol._ewma_smoothing_parameters() + resids2 = np.asarray(resids) ** 2 + components = np.empty((resids2.shape[0] + 1, mus.shape[0])) + components[0] = backcast + for t in range(resids2.shape[0]): + components[t + 1] = mus * components[t] + (1 - mus) * resids2[t] + expected = components[1:] for i in range(10): - assert_allclose(forecasts.forecasts[:, i], one_step, rtol=1e-4) + assert_allclose(forecasts.forecasts[:, i], expected @ w, rtol=1e-4) + expected = mus * expected + (1 - mus) * (expected @ w)[:, None] alt_forecasts = vol.forecast( params, resids, backcast, var_bounds, horizon=10, start=500 diff --git a/arch/univariate/volatility.py b/arch/univariate/volatility.py index 9b7c5007bd..b46cf230e3 100644 --- a/arch/univariate/volatility.py +++ b/arch/univariate/volatility.py @@ -2472,8 +2472,32 @@ def _analytic_forecast( horizon, start_index=start, ) + if horizon == 1: + return VarianceForecast(forecasts) + + kmax = self.kmax + w = self._ewma_combination_weights() + mus = self._ewma_smoothing_parameters() + backcast = cast("Float64Array1D", to_array_1d(np.asarray(backcast))) + t = resids.shape[0] + components = np.empty((kmax, t + 1)) + _resids = np.ascontiguousarray(resids) + for k in range(kmax): + ewma_recursion( + mus[k], + to_array_1d(_resids), + to_array_1d(components[k, :]), + t + 1, + backcast[k], + ) + # Each EWMA component is updated with the squared residual, whose + # expected value is the combined variance, so the expected values of + # the components follow a linear recursion. + components = components.T[start + 1 :] for i in range(1, horizon): - forecasts[:, i] = forecasts[:, 0] + sigma2 = components @ w + components = mus * components + (1 - mus) * sigma2[:, None] + forecasts[:, i] = components @ w return VarianceForecast(forecasts) def _simulation_forecast( From cfc8de5324a55ea2dde59d814ef833f74cc37a74 Mon Sep 17 00:00:00 2001 From: Raashish Aggarwal <94279692+raashish1601@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:26:11 +0530 Subject: [PATCH 2/2] REF: Write RiskMetrics2006 forecasts as reweighted components The expected components follow a linear recursion, so iterating them is the same as applying horizon-dependent weights to the final component values, which is the form used in the RiskMetrics 2006 papers. --- arch/univariate/volatility.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/arch/univariate/volatility.py b/arch/univariate/volatility.py index b46cf230e3..27bb3b41ff 100644 --- a/arch/univariate/volatility.py +++ b/arch/univariate/volatility.py @@ -2492,12 +2492,14 @@ def _analytic_forecast( ) # Each EWMA component is updated with the squared residual, whose # expected value is the combined variance, so the expected values of - # the components follow a linear recursion. + # the components follow a linear recursion. The forecasts are then + # reweighted combinations of the final component values, with weights + # w(h + 1) = mus * w(h) + w * sum((1 - mus) * w(h)) and w(1) = w. components = components.T[start + 1 :] + weights = w for i in range(1, horizon): - sigma2 = components @ w - components = mus * components + (1 - mus) * sigma2[:, None] - forecasts[:, i] = components @ w + weights = mus * weights + w * ((1 - mus) @ weights) + forecasts[:, i] = components @ weights return VarianceForecast(forecasts) def _simulation_forecast(