Skip to content

Commit 3da6c09

Browse files
committed
Add tests for AR mean forecast simulation paths.
Verify simulated mean and variance paths for AR forecasts using simulation and bootstrap methods.
1 parent 543f44c commit 3da6c09

2 files changed

Lines changed: 183 additions & 1 deletion

File tree

arch/tests/univariate/test_forecast.py

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,59 @@
3131

3232
SP500 = 100 * sp500.load()["Adj Close"].pct_change().dropna()
3333

34+
35+
def _recompute_ar_simulation_paths(
36+
y: np.ndarray,
37+
start_index: int,
38+
horizon: int,
39+
constant: float,
40+
dynp: np.ndarray,
41+
shocks: np.ndarray,
42+
variance_paths: np.ndarray,
43+
expected_x: np.ndarray,
44+
exog_p: np.ndarray,
45+
max_lags: int,
46+
) -> tuple[np.ndarray, np.ndarray]:
47+
"""Recompute AR mean simulation paths (mirrors ``HARX.forecast``)."""
48+
impulse = _ar_to_impulse(horizon, dynp)
49+
long_run_variance_paths = variance_paths.copy()
50+
for i in range(horizon):
51+
impulses = impulse[i::-1][:, None]
52+
lrvp = variance_paths[:, :, : (i + 1)].dot(impulses**2)
53+
lrvp = lrvp[:, :, 0]
54+
long_run_variance_paths[:, :, i] = lrvp
55+
56+
t = y.shape[0]
57+
m = max_lags
58+
mean_paths = np.empty(shocks.shape[:2] + (m + horizon,))
59+
dynp_rev = dynp[::-1]
60+
for i in range(start_index, t):
61+
path_loc = i - start_index
62+
mean_paths[path_loc, :, :m] = y[i - m + 1 : i + 1]
63+
for j in range(horizon):
64+
mean_paths[path_loc, :, m + j] = (
65+
constant
66+
+ mean_paths[path_loc, :, j : m + j].dot(dynp_rev)
67+
+ shocks[path_loc, :, j]
68+
)
69+
if expected_x.shape[0] > 0:
70+
mean_paths[path_loc, :, m + j] += expected_x[:, path_loc, j].T @ exog_p
71+
mean_paths = mean_paths[:, :, m:]
72+
return mean_paths, long_run_variance_paths
73+
74+
75+
def _ar_mean_simulation_coefficients(
76+
model, params
77+
) -> tuple[float, np.ndarray, np.ndarray]:
78+
mp, _, _ = model._parse_parameters(np.asarray(params))
79+
arp = model._har_to_ar(mp)
80+
nexog = 0 if model._x is None else model._x.shape[1]
81+
exog_p = np.empty(0) if model._x is None else mp[-nexog:]
82+
constant = arp[0] if model.constant else 0.0
83+
dynp = arp[int(model.constant) :]
84+
return constant, dynp, exog_p
85+
86+
3487
MEAN_MODELS = [
3588
HARX(SP500, lags=[1, 5]),
3689
ARX(SP500, lags=2),
@@ -539,6 +592,136 @@ def test_ar1_forecast_bootstrap(self):
539592
assert_frame_equal(forecast.mean, repeat.mean)
540593
assert_frame_equal(forecast.variance, repeat.variance)
541594

595+
@pytest.mark.parametrize(
596+
("method", "start", "simulations"),
597+
[
598+
("simulation", 50, 25),
599+
("bootstrap", 200, 25),
600+
],
601+
)
602+
def test_ar1_garch_mean_forecast_simulation_paths(
603+
self, method: str, start: int, simulations: int
604+
):
605+
am = arch_model(self.ar1, mean="AR", vol="GARCH", lags=[1])
606+
res = am.fit(disp="off")
607+
horizon = 5
608+
forecast_kwargs: dict = {
609+
"horizon": horizon,
610+
"start": start,
611+
"method": method,
612+
"simulations": simulations,
613+
}
614+
if method == "bootstrap":
615+
forecast_kwargs["random_state"] = RandomState(424242)
616+
forecast = res.forecast(**forecast_kwargs)
617+
sim = forecast.simulations
618+
model = res.model
619+
y = np.asarray(model._y)
620+
constant, dynp, exog_p = _ar_mean_simulation_coefficients(model, res.params)
621+
expected_mean, expected_lrv = _recompute_ar_simulation_paths(
622+
y,
623+
start,
624+
horizon,
625+
constant,
626+
dynp,
627+
sim.residuals,
628+
sim.residual_variances,
629+
np.empty(0),
630+
exog_p,
631+
model._max_lags,
632+
)
633+
nobs = y.shape[0] - start
634+
assert sim.values.shape == (nobs, simulations, horizon)
635+
assert sim.residuals.shape == (nobs, simulations, horizon)
636+
assert sim.residual_variances.shape == (nobs, simulations, horizon)
637+
assert sim.variances.shape == (nobs, simulations, horizon)
638+
assert_allclose(sim.values, expected_mean)
639+
assert_allclose(sim.variances, expected_lrv)
640+
641+
@pytest.mark.parametrize("method", ["simulation", "bootstrap"])
642+
def test_ar2_garch_mean_forecast_simulation_paths(self, method: str):
643+
am = arch_model(self.ar2, mean="AR", vol="GARCH", lags=[1, 2], p=1, q=1)
644+
res = am.fit(disp="off")
645+
horizon = 4
646+
start = 200 if method == "bootstrap" else 30
647+
simulations = 20
648+
forecast_kwargs: dict = {
649+
"horizon": horizon,
650+
"start": start,
651+
"method": method,
652+
"simulations": simulations,
653+
}
654+
if method == "bootstrap":
655+
forecast_kwargs["random_state"] = RandomState(535353)
656+
forecast = res.forecast(**forecast_kwargs)
657+
sim = forecast.simulations
658+
model = res.model
659+
y = np.asarray(model._y)
660+
constant, dynp, exog_p = _ar_mean_simulation_coefficients(model, res.params)
661+
expected_mean, expected_lrv = _recompute_ar_simulation_paths(
662+
y,
663+
start,
664+
horizon,
665+
constant,
666+
dynp,
667+
sim.residuals,
668+
sim.residual_variances,
669+
np.empty(0),
670+
exog_p,
671+
model._max_lags,
672+
)
673+
assert_allclose(sim.values, expected_mean)
674+
assert_allclose(sim.variances, expected_lrv)
675+
676+
def test_arx_garch_mean_forecast_simulation_paths(self):
677+
rng = RandomState(12345)
678+
am = arch_model(None, mean="ARX", lags=2)
679+
data = am.simulate([0.1, 1.2, -0.6, 0.1, 0.1, 0.8], nobs=1000)
680+
cols = ["x1", "x2"]
681+
x = pd.DataFrame(
682+
rng.standard_normal((data.data.shape[0], 2)),
683+
columns=cols,
684+
index=data.data.index,
685+
)
686+
y = data.data + x @ np.array([0.25, 0.5])
687+
y.name = "y"
688+
am = arch_model(y, x, mean="ARX", lags=2, p=1, q=1)
689+
res = am.fit(disp="off")
690+
horizon = 4
691+
simulations = 20
692+
start = y.shape[0] - 1
693+
x_fcast = np.zeros((x.shape[1], 1, horizon))
694+
for i in range(x_fcast.shape[0]):
695+
x_fcast[i] = np.arange(100 * i, 100 * i + horizon)
696+
697+
forecast = res.forecast(
698+
horizon=horizon,
699+
start=start,
700+
method="simulation",
701+
simulations=simulations,
702+
x=x_fcast,
703+
)
704+
sim = forecast.simulations
705+
model = res.model
706+
y_array = np.asarray(model._y)
707+
constant, dynp, exog_p = _ar_mean_simulation_coefficients(model, res.params)
708+
expected_x = model._reformat_forecast_x(x_fcast, horizon, start)
709+
expected_mean, expected_lrv = _recompute_ar_simulation_paths(
710+
y_array,
711+
start,
712+
horizon,
713+
constant,
714+
dynp,
715+
sim.residuals,
716+
sim.residual_variances,
717+
expected_x,
718+
exog_p,
719+
model._max_lags,
720+
)
721+
assert sim.values.shape == (1, simulations, horizon)
722+
assert_allclose(sim.values, expected_mean)
723+
assert_allclose(sim.variances, expected_lrv)
724+
542725
def test_ar2_garch11(self):
543726
pass
544727

arch/univariate/mean.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1038,7 +1038,6 @@ def forecast(
10381038
shocks: Float64Array | None = None
10391039
long_run_variance_paths: Float64Array | None = None
10401040
if method.lower() in ("simulation", "bootstrap"):
1041-
# TODO: This is not tested, but probably right
10421041
assert isinstance(vfcast.forecast_paths, np.ndarray)
10431042
variance_paths = vfcast.forecast_paths
10441043
assert isinstance(vfcast.shocks, np.ndarray)

0 commit comments

Comments
 (0)