Skip to content

Commit 6e50240

Browse files
committed
Implement MS GARCH
1 parent 6cacadd commit 6e50240

File tree

5 files changed

+380
-18
lines changed

5 files changed

+380
-18
lines changed

arch/tests/univariate/test_volatility.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
)
1313
import pytest
1414
from scipy.special import gamma, gammaln
15-
15+
from arch import arch_model
1616
from arch.univariate import ZeroMean
1717
from arch.univariate.distribution import Normal, SkewStudent, StudentsT
1818
import arch.univariate.recursions_python as recpy
@@ -28,6 +28,7 @@
2828
FixedVariance,
2929
MIDASHyperbolic,
3030
RiskMetrics2006,
31+
MSGARCH,
3132
)
3233
from arch.utility.exceptions import InitialValueWarning
3334

@@ -1804,3 +1805,28 @@ def test_figarch_weights():
18041805
lam_cy = rec.figarch_weights(params, 1, 1, 1000)
18051806
assert_allclose(lam_py, lam_nb)
18061807
assert_allclose(lam_py, lam_cy)
1808+
1809+
1810+
1811+
def test_msgarch():
1812+
data = np.random.randn(100) # arbitary data series
1813+
model = arch_model(data, vol="MSGARCH",p=1,q=1,mean="zero") # 2 regimes
1814+
result = model.fit(disp="off")
1815+
forecast = result.forecast(horizon=1)
1816+
cond_var = result.conditional_volatility
1817+
probs = result.model.volatility.filtered_probs
1818+
ll = result.loglikelihood
1819+
1820+
assert hasattr(result, "params")
1821+
assert result is not None
1822+
assert result.params.shape[0] == 8 # 2*3 GARCH params + 2 transition matrix probabilities (this is valid whilst k=2)
1823+
assert np.all(np.isfinite(result.params))
1824+
assert result.model.volatility.k == 2
1825+
assert np.all(forecast.variance.values > 0)
1826+
assert forecast.variance.shape[1] == 1
1827+
assert np.allclose(probs[:,1:].sum(axis=0), 1.0, atol=1e-6) # excludes t=0 prob's as these have not been filtered
1828+
assert np.all((probs >= 0) & (probs <= 1))
1829+
assert cond_var.shape[0] == len(data)
1830+
assert np.all(cond_var > 0)
1831+
assert np.isfinite(ll)
1832+

arch/univariate/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
FixedVariance,
3030
MIDASHyperbolic,
3131
RiskMetrics2006,
32+
MSGARCH,
3233
)
3334

3435
recursions: types.ModuleType
@@ -55,6 +56,7 @@
5556
"HARX",
5657
"LS",
5758
"MIDASHyperbolic",
59+
"MSGARCH",
5860
"Normal",
5961
"RiskMetrics2006",
6062
"SkewStudent",

arch/univariate/base.py

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
Literal,
3535
)
3636
from arch.univariate.distribution import Distribution, Normal
37-
from arch.univariate.volatility import ConstantVariance, VolatilityProcess
37+
from arch.univariate.volatility import ConstantVariance, VolatilityProcess, MSGARCH
3838
from arch.utility.array import ensure1d, to_array_1d
3939
from arch.utility.exceptions import (
4040
ConvergenceWarning,
@@ -714,15 +714,21 @@ def fit(
714714
if total_params == 0:
715715
return self._fit_parameterless_model(cov_type=cov_type, backcast=backcast)
716716

717-
sigma2 = np.zeros(resids.shape[0], dtype=float)
718-
self._backcast = backcast
719-
sv_volatility = v.starting_values(resids)
717+
n_regimes = v.k if isinstance(v, MSGARCH) else 1
718+
sigma2 = np.zeros((resids.shape[0], n_regimes)) if n_regimes > 1 else np.zeros(resids.shape[0])
719+
self._backcast = backcast
720+
sv_volatility = v.starting_values(resids) # initial guess for GARCH recursion
720721
self._var_bounds = var_bounds = v.variance_bounds(resids)
721-
v.compute_variance(sv_volatility, resids, sigma2, backcast, var_bounds)
722-
std_resids = resids / np.sqrt(sigma2)
722+
v.compute_variance(sv_volatility, resids, sigma2, backcast, var_bounds) # fills sigma 2 recursively using chosen vol model
723+
if n_regimes == 1:
724+
std_resids = resids / np.sqrt(sigma2)
725+
else:
726+
pi = self.volatility.pi # shape (k,)
727+
pi_weighted_sigma2 = sigma2 @ pi # (t,k) @ (k,) = (t,)
728+
std_resids = resids / np.sqrt(pi_weighted_sigma2) ## Using stationary distribution to weight regime variances. This is only an approximation (true weights should come from filtered probabilties), but we don't have these available at this stage
723729

724730
# 2. Construct constraint matrices from all models and distribution
725-
constraints = (
731+
constraints = (
726732
self.constraints(),
727733
self.volatility.constraints(),
728734
self.distribution.constraints(),
@@ -790,12 +796,16 @@ def fit(
790796
_callback_info["display"] = update_freq
791797
disp_flag = True if disp == "final" else False
792798

793-
func = self._loglikelihood
794-
args = (sigma2, backcast, var_bounds)
795-
ineq_constraints = constraint(a, b)
799+
if isinstance(self.volatility, MSGARCH):
800+
func = self.volatility.msgarch_loglikelihood # MS GARCH
801+
args = (resids, sigma2, backcast, var_bounds)
796802

797-
from scipy.optimize import minimize
803+
else:
804+
func = self._loglikelihood # standard GARCH
805+
args = (sigma2, backcast, var_bounds)
798806

807+
ineq_constraints = constraint(a, b)
808+
from scipy.optimize import minimize
799809
options = {} if options is None else options
800810
options.setdefault("disp", disp_flag)
801811
with warnings.catch_warnings():
@@ -835,7 +845,10 @@ def fit(
835845
mp, vp, dp = self._parse_parameters(params)
836846

837847
resids = self.resids(mp)
838-
vol = np.zeros(resids.shape[0], dtype=float)
848+
if isinstance(self.volatility, MSGARCH):
849+
vol = np.zeros((resids.shape[0], n_regimes), dtype=float) # MS GARCH
850+
else:
851+
vol = np.zeros(resids.shape[0], dtype=float) # standard GARCH
839852
self.volatility.compute_variance(vp, resids, vol, backcast, var_bounds)
840853
vol = cast(Float64Array1D, np.sqrt(vol))
841854

@@ -849,8 +862,21 @@ def fit(
849862
first_obs, last_obs = self._fit_indices
850863
resids_final = np.full(self._y.shape, np.nan, dtype=float)
851864
resids_final[first_obs:last_obs] = resids
852-
vol_final = np.full(self._y.shape, np.nan, dtype=float)
853-
vol_final[first_obs:last_obs] = vol
865+
866+
if isinstance(self.volatility, MSGARCH):
867+
filtered_probs = self.volatility.filtered_probs # n_regimes x n_obs
868+
else:
869+
filtered_probs = None
870+
871+
872+
if isinstance(self.volatility, MSGARCH):
873+
vol_final = np.full(self._y.shape, np.nan, dtype=float)
874+
weighted_sigma2 = (vol**2 * filtered_probs.T).sum(axis=1)
875+
vol_final[first_obs:last_obs] = np.sqrt(weighted_sigma2)
876+
877+
else:
878+
vol_final = np.full(self._y.shape, np.nan, dtype=float)
879+
vol_final[first_obs:last_obs] = vol
854880

855881
fit_start, fit_stop = self._fit_indices
856882
model_copy = deepcopy(self)

arch/univariate/mean.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
HARCH,
6868
ConstantVariance,
6969
VolatilityProcess,
70+
MSGARCH
7071
)
7172
from arch.utility.array import (
7273
AbstractDocStringInheritor,
@@ -1891,7 +1892,7 @@ def arch_model(
18911892
] = "Constant",
18921893
lags: int | list[int] | Int32Array | Int64Array | None = 0,
18931894
vol: Literal[
1894-
"GARCH", "ARCH", "EGARCH", "FIGARCH", "APARCH", "HARCH", "FIGARCH"
1895+
"GARCH", "ARCH", "EGARCH", "FIGARCH", "APARCH", "HARCH", "FIGARCH", "MSGARCH"
18951896
] = "GARCH",
18961897
p: int | list[int] = 1,
18971898
o: int = 0,
@@ -2000,6 +2001,7 @@ def arch_model(
20002001
"harch",
20012002
"constant",
20022003
"egarch",
2004+
"msgarch",
20032005
)
20042006
known_dist = (
20052007
"normal",
@@ -2060,8 +2062,10 @@ def arch_model(
20602062
elif vol_model == "aparch":
20612063
assert isinstance(p, int)
20622064
v = APARCH(p=p, o=o, q=q)
2063-
else: # vol == 'harch'
2065+
elif vol_model == 'harch':
20642066
v = HARCH(lags=p)
2067+
else: # vol_model == "msgarch":
2068+
v = MSGARCH()
20652069

20662070
if dist_name in ("skewstudent", "skewt"):
20672071
d: Distribution = SkewStudent()

0 commit comments

Comments
 (0)