Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions arch/tests/univariate/test_mean.py
Original file line number Diff line number Diff line change
Expand Up @@ -1249,6 +1249,76 @@ def test_arch_lm(simulated_data):
assert "H0: Standardized" in wald.__repr__()


def test_li_mak(simulated_data):
# --- default call ---
zm = ZeroMean(simulated_data, volatility=GARCH())
res = zm.fit(disp=DISPLAY)
wald = res.li_mak_test()

nobs = simulated_data.shape[0]
default_lags = int(np.ceil(12.0 * np.power(nobs / 100.0, 1 / 4.0)))
# df = lags - fitdf where fitdf = GARCH(1,1).num_params - 1 = 2
assert wald.df == default_lags - 2
assert "uncorrelated" in wald.null
assert "Li-Mak" in wald.__repr__()
assert wald.stat >= 0.0
assert 0.0 <= wald.pval <= 1.0
assert_almost_equal(wald.pval, 1 - stats.chi2(wald.df).cdf(wald.stat))

# --- explicit lags ---
wald5 = res.li_mak_test(lags=5)
assert wald5.df == 5 - 2 # fitdf=2 for GARCH(1,1)
assert wald5.stat >= 0.0

# --- fitdf=0 gives uncorrected Ljung-Box on squared standardized residuals ---
wald_lb = res.li_mak_test(lags=5, fitdf=0)
assert wald_lb.df == 5

# manually compute Ljung-Box on squared standardized residuals
e = res.resid / res.conditional_volatility
a = e**2
a = a[~np.isnan(a)]
n = a.shape[0]
a_dm = a - a.mean()
a_var = (a_dm**2).sum()
stat_manual = 0.0
for k in range(1, 6):
rk = (a_dm[k:] * a_dm[:-k]).sum() / a_var
stat_manual += rk**2 / (n - k)
stat_manual *= n * (n + 2)
assert_almost_equal(wald_lb.stat, stat_manual, decimal=8)

# --- li_mak != arch_lm on standardized residuals (different formula + df) ---
wald_archlm = res.arch_lm_test(lags=5, standardized=True)
# ARCH-LM uses nobs*R^2 (OLS); Li-Mak uses Ljung-Box — different statistics
assert wald_archlm.stat != pytest.approx(wald5.stat)

# --- fitdf parameter override ---
wald_custom = res.li_mak_test(lags=10, fitdf=5)
assert wald_custom.df == 5


def test_li_mak_constant_variance(simulated_data):
"""With constant variance, fitdf defaults to 0 (no df correction needed)."""
from arch.univariate.volatility import ConstantVariance

zm = ZeroMean(simulated_data, volatility=ConstantVariance())
res = zm.fit(disp=DISPLAY)
wald = res.li_mak_test(lags=5)
assert wald.df == 5 # fitdf = ConstantVariance.num_params - 1 = 0


def test_li_mak_pval_structure(simulated_data):
"""p-value, critical values and repr are consistent."""
zm = ZeroMean(simulated_data, volatility=GARCH())
res = zm.fit(disp=DISPLAY)
wald = res.li_mak_test(lags=10)
assert len(wald.critical_values) == 3
assert "10%" in wald.critical_values
assert "Li-Mak" in repr(wald)
assert_almost_equal(wald.pval, 1 - stats.chi2(wald.df).cdf(wald.stat))


def test_autoscale():
rs = np.random.RandomState(34254321)
dist = Normal(seed=rs)
Expand Down
89 changes: 89 additions & 0 deletions arch/univariate/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1748,6 +1748,95 @@ def arch_lm_test(
stat, df=lags, null=null, alternative=alt, name="ARCH-LM Test"
)

def li_mak_test(
self, lags: int | None = None, fitdf: int | None = None
) -> WaldTestStatistic:
r"""
Li-Mak portmanteau test for remaining ARCH effects in standardized residuals

Applies the Ljung-Box statistic to the *squared* standardized residuals
:math:`\hat{e}_t^2 = (r_t / \hat{\sigma}_t)^2` and corrects the degrees
of freedom for the number of estimated GARCH variance parameters, as
derived in Li and Mak (1994). Under a correctly specified GARCH model,
the statistic is asymptotically :math:`\chi^2(m - \text{fitdf})`, where
:math:`m` is the number of lags and *fitdf* is the number of estimated
volatility lag parameters.

Unlike the ARCH-LM test, which only tests raw residuals for heteroskedasticity,
the Li-Mak test directly checks whether the fitted volatility model has
removed *all* serial dependence in the conditional variance.

Parameters
----------
lags : int, optional
Number of autocorrelation lags to include. Defaults to
:math:`\lceil 1.2\,T^{1/4} \rceil`.
fitdf : int, optional
Number of estimated volatility lag parameters to subtract from the
degrees of freedom. Defaults to ``volatility.num_params - 1``
(all variance parameters excluding the constant :math:`\omega`).
Pass ``fitdf=0`` to obtain the uncorrected Ljung-Box statistic.

Returns
-------
result : WaldTestStatistic
Test result with statistic, p-value, and null/alternative descriptions.

References
----------
.. [1] Li, W. K. and Mak, T. K. (1994). On the Squared Residual
Autocorrelations in Non-Linear Time Series with Conditional
Heteroskedasticity. *Journal of Time Series Analysis*,
15(6), 627--636.

Examples
--------
>>> from arch import arch_model
>>> import numpy as np
>>> rng = np.random.default_rng(0)
>>> returns = rng.standard_normal(500)
>>> res = arch_model(returns, vol="Garch", p=1, q=1).fit(disp="off")
>>> li_mak = res.li_mak_test(lags=10)
>>> li_mak.stat # doctest: +SKIP
"""
resids = self.resid / np.asarray(self.conditional_volatility)
resids = resids[~np.isnan(resids)]
nobs = resids.shape[0]
if nobs < 3:
raise ValueError("Test requires at least 3 non-nan observations")

a = resids**2
lags = (
int(np.ceil(12.0 * np.power(nobs / 100.0, 1 / 4.0)))
if lags is None
else lags
)
lags = max(min(nobs // 2 - 1, lags), 1)
assert isinstance(lags, int)

if fitdf is None:
fitdf = max(self.model.volatility.num_params - 1, 0)

# Ljung-Box Q statistic on squared standardized residuals
a_mean = a.mean()
a_dm = a - a_mean
a_var = (a_dm**2).sum()

stat = 0.0
for k in range(1, lags + 1):
rk = (a_dm[k:] * a_dm[:-k]).sum() / a_var
stat += rk**2 / (nobs - k)
stat *= nobs * (nobs + 2)

df = max(lags - fitdf, 1)
return WaldTestStatistic(
stat,
df=df,
null="Squared standardized residuals are uncorrelated.",
alternative="Squared standardized residuals are autocorrelated (ARCH effects remain).",
name="Li-Mak Test",
)


class ARCHModelResult(ARCHModelFixedResult):
"""
Expand Down
Loading