multiarch 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,115 @@
1
+ """Specification tests: constant correlation (Engle & Sheppard 2001),
2
+ multivariate portmanteau (Hosking 1980) and VaR backtests (Kupiec 1995,
3
+ Christoffersen 1998)."""
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+ from scipy import stats
8
+
9
+ from .univariate import UnivariateSpec, filter_series, fit_univariate
10
+
11
+
12
+ def _sqrtm_inv(R):
13
+ w, V = np.linalg.eigh(R)
14
+ return V @ np.diag(w ** -0.5) @ V.T
15
+
16
+
17
+ def constant_correlation_test(returns=None, lags=5, vol="garch", mean="constant",
18
+ std_resid=None, whiten=True):
19
+ """Engle & Sheppard (2001) test of H0: constant conditional correlation.
20
+
21
+ Fits univariate GARCH margins, whitens the standardised residuals with
22
+ the CCC correlation matrix, and regresses the stacked off-diagonal
23
+ outer products on a constant and `lags` own lags. Under H0 all
24
+ coefficients are zero; the statistic is chi2(lags + 1).
25
+ Pass `std_resid` to skip the GARCH step (and `whiten=False` if they
26
+ are already whitened). Returns dict(statistic, pvalue, df).
27
+ """
28
+ if std_resid is None:
29
+ y = returns.to_numpy(float) if isinstance(returns, pd.DataFrame) else np.asarray(returns, float)
30
+ spec = UnivariateSpec(mean=mean, vol=vol)
31
+ z = np.column_stack([
32
+ filter_series(spec, fit_univariate(y[:, i], spec, compute_cov=False).params, y[:, i])[2]
33
+ for i in range(y.shape[1])
34
+ ])
35
+ else:
36
+ z = np.asarray(std_resid, float)
37
+ w = z @ _sqrtm_inv(np.corrcoef(z, rowvar=False)) if whiten else z
38
+ n = w.shape[1]
39
+ Y, X = [], []
40
+ for i in range(n - 1):
41
+ for j in range(i + 1, n):
42
+ p = w[:, i] * w[:, j]
43
+ Y.append(p[lags:])
44
+ X.append(np.column_stack([np.ones(len(p) - lags)]
45
+ + [p[lags - k:len(p) - k] for k in range(1, lags + 1)]))
46
+ Y = np.concatenate(Y)
47
+ X = np.vstack(X)
48
+ beta, *_ = np.linalg.lstsq(X, Y, rcond=None)
49
+ resid = Y - X @ beta
50
+ s2 = resid @ resid / len(Y)
51
+ stat = float(beta @ X.T @ X @ beta / s2)
52
+ df = lags + 1
53
+ return {"statistic": stat, "pvalue": float(stats.chi2.sf(stat, df)), "df": df}
54
+
55
+
56
+ def hosking_test(x, lags=10, fitted_params=0):
57
+ """Multivariate portmanteau test (Hosking 1980) for serial correlation
58
+ in a (T, N) series, e.g. standardised residuals or their squares.
59
+ Returns dict(statistic, pvalue, df)."""
60
+ x = np.asarray(x, float)
61
+ x = x - x.mean(0)
62
+ T, n = x.shape
63
+ C0 = x.T @ x / T
64
+ C0i = np.linalg.inv(C0)
65
+ stat = 0.0
66
+ for k in range(1, lags + 1):
67
+ Ck = x[k:].T @ x[:-k] / T
68
+ stat += np.trace(Ck.T @ C0i @ Ck @ C0i) / (T - k)
69
+ stat *= T * T
70
+ df = n * n * lags - fitted_params
71
+ return {"statistic": float(stat), "pvalue": float(stats.chi2.sf(stat, df)), "df": df}
72
+
73
+
74
+ def var_backtest(returns, var, level=0.01):
75
+ """Backtest a VaR forecast path.
76
+
77
+ returns : realised portfolio returns; var : VaR as positive losses,
78
+ aligned with `returns`. Reports the violation ratio (observed / expected
79
+ violations), Kupiec's unconditional coverage test, Christoffersen's
80
+ independence test and their sum (conditional coverage).
81
+ """
82
+ r = np.asarray(returns, float)
83
+ v = np.asarray(var, float)
84
+ hit = (r < -v).astype(int)
85
+ T = hit.size
86
+ x = int(hit.sum())
87
+ p = level
88
+ pi = x / T
89
+
90
+ def _xlogy(a, b):
91
+ return a * np.log(b) if a > 0 else 0.0
92
+
93
+ lr_uc = -2 * (_xlogy(T - x, 1 - p) + _xlogy(x, p) - _xlogy(T - x, 1 - pi) - _xlogy(x, pi))
94
+ h0, h1 = hit[:-1], hit[1:]
95
+ n00 = int(((h0 == 0) & (h1 == 0)).sum())
96
+ n01 = int(((h0 == 0) & (h1 == 1)).sum())
97
+ n10 = int(((h0 == 1) & (h1 == 0)).sum())
98
+ n11 = int(((h0 == 1) & (h1 == 1)).sum())
99
+ p01 = n01 / max(n00 + n01, 1)
100
+ p11 = n11 / max(n10 + n11, 1)
101
+ p1 = (n01 + n11) / max(n00 + n01 + n10 + n11, 1)
102
+ lr_ind = -2 * (_xlogy(n00 + n10, 1 - p1) + _xlogy(n01 + n11, p1)
103
+ - _xlogy(n00, 1 - p01) - _xlogy(n01, p01)
104
+ - _xlogy(n10, 1 - p11) - _xlogy(n11, p11))
105
+ return {
106
+ "violations": x,
107
+ "expected": p * T,
108
+ "violation_ratio": x / (p * T),
109
+ "kupiec_stat": float(lr_uc),
110
+ "kupiec_pvalue": float(stats.chi2.sf(lr_uc, 1)),
111
+ "independence_stat": float(lr_ind),
112
+ "independence_pvalue": float(stats.chi2.sf(lr_ind, 1)),
113
+ "cc_stat": float(lr_uc + lr_ind),
114
+ "cc_pvalue": float(stats.chi2.sf(lr_uc + lr_ind, 2)),
115
+ }
multiarch/ewma.py ADDED
@@ -0,0 +1,26 @@
1
+ import numpy as np
2
+ from numba import njit
3
+
4
+
5
+ @njit(cache=True)
6
+ def _ewma(x, lam, S0):
7
+ T, n = x.shape
8
+ out = np.empty((T + 1, n, n))
9
+ out[0] = S0
10
+ for t in range(T):
11
+ out[t + 1] = lam * out[t] + (1 - lam) * np.outer(x[t], x[t])
12
+ return out
13
+
14
+
15
+ def ewma_covariance(returns, lam=0.94, demean=False, init=None):
16
+ """RiskMetrics EWMA covariance: S_{t+1} = lam S_t + (1-lam) x_t x_t'.
17
+
18
+ Returns a (T+1, N, N) array; element t is the forecast for period t
19
+ made with data up to t-1, so the last element is the one-step-ahead
20
+ forecast. `init` defaults to the sample covariance.
21
+ """
22
+ x = np.asarray(returns, float)
23
+ if demean:
24
+ x = x - x.mean(0)
25
+ S0 = np.cov(x, rowvar=False, bias=True) if init is None else np.asarray(init, float)
26
+ return _ewma(x, float(lam), S0)
multiarch/gogarch.py ADDED
@@ -0,0 +1,220 @@
1
+ """GO-GARCH (van der Weide 2002) with the ICA rotation of Broda & Paolella
2
+ (2009) / rmgarch: returns are linear combinations of independent factors,
3
+ each following its own univariate GARCH."""
4
+
5
+ import math
6
+ from dataclasses import dataclass, field
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+ from scipy import stats
11
+
12
+ from .dcc import _analytic_var_es, _normalise, _std_t_ppf
13
+ from .univariate import UnivariateSpec, filter_series, fit_univariate
14
+
15
+
16
+ def _sym_decorrelate(W):
17
+ s, u = np.linalg.eigh(W @ W.T)
18
+ return u @ np.diag(1 / np.sqrt(s)) @ u.T @ W
19
+
20
+
21
+ def fastica(X, seed=0, max_iter=2000, tol=1e-10):
22
+ """Symmetric FastICA with the log-cosh contrast on whitened data X
23
+ (N, T). Returns the orthogonal unmixing matrix W (factors = W @ X)."""
24
+ n, T = X.shape
25
+ rng = np.random.default_rng(seed)
26
+ W = _sym_decorrelate(rng.standard_normal((n, n)))
27
+ for _ in range(max_iter):
28
+ G = np.tanh(W @ X)
29
+ W1 = _sym_decorrelate(G @ X.T / T - np.diag((1 - G * G).mean(axis=1)) @ W)
30
+ lim = np.max(np.abs(np.abs(np.einsum("ij,ij->i", W1, W)) - 1))
31
+ W = W1
32
+ if lim < tol:
33
+ break
34
+ return W
35
+
36
+
37
+ class GOGARCH:
38
+ """Generalized orthogonal GARCH.
39
+
40
+ eps_t = A f_t, with f_it independent GARCH processes of unit
41
+ unconditional variance and A = P Lambda^{1/2} U' from the PCA of the
42
+ sample covariance and an ICA rotation U.
43
+
44
+ Parameters
45
+ ----------
46
+ returns : (T, N) array or DataFrame.
47
+ vol : factor variance model, "garch" or "gjr".
48
+ dist : factor distribution, "normal" or "t".
49
+ mean : "constant" (sample mean, as rmgarch) or "zero".
50
+ seed : seed for the ICA start (ICA is identified only up to factor
51
+ order and sign).
52
+ convention : factor variance start-up, "arch" or "rmgarch" (see DCC).
53
+ """
54
+
55
+ def __init__(self, returns, vol="garch", dist="normal", mean="constant", seed=0,
56
+ convention="arch"):
57
+ if isinstance(returns, pd.DataFrame):
58
+ self.index = returns.index
59
+ self.columns = [str(c) for c in returns.columns]
60
+ y = returns.to_numpy(float)
61
+ else:
62
+ y = np.asarray(returns, float)
63
+ self.index = pd.RangeIndex(y.shape[0])
64
+ self.columns = [f"x{i}" for i in range(y.shape[1])]
65
+ if y.ndim != 2 or y.shape[1] < 2:
66
+ raise ValueError("returns must be (T, N) with N >= 2")
67
+ self.y = y
68
+ self.mean = mean
69
+ self.seed = seed
70
+ init = {"arch": "arch", "rmgarch": "rugarch", "tsmarch": "tsgarch"}[convention]
71
+ self.fspec = UnivariateSpec(mean="zero", vol=vol, dist=dist, init=init)
72
+
73
+ def fit(self, compute_cov=True, unmixing=None):
74
+ """Estimate the rotation by ICA and the factor GARCH models.
75
+ `unmixing` (N, N) fixes the map from demeaned returns to factors
76
+ (f_t = unmixing @ eps_t) instead of estimating it."""
77
+ y = self.y
78
+ mu = y.mean(axis=0) if self.mean == "constant" else np.zeros(y.shape[1])
79
+ eps = y - mu
80
+ if unmixing is None:
81
+ Sigma = eps.T @ eps / len(eps)
82
+ lam, P = np.linalg.eigh(Sigma)
83
+ K = np.diag(lam ** -0.5) @ P.T # whitening
84
+ W = fastica(K @ eps.T, seed=self.seed)
85
+ A = P @ np.diag(lam ** 0.5) @ W.T # mixing: eps = A f
86
+ else:
87
+ A = np.linalg.inv(np.asarray(unmixing, float))
88
+ f = eps @ np.linalg.inv(A).T
89
+ fits = [fit_univariate(f[:, i], self.fspec, compute_cov=compute_cov)
90
+ for i in range(f.shape[1])]
91
+ return GOGARCHResult(self, mu, A, f, fits)
92
+
93
+
94
+ @dataclass
95
+ class GOGARCHResult:
96
+ model: GOGARCH
97
+ mu: np.ndarray
98
+ A: np.ndarray
99
+ factors: np.ndarray = field(repr=False)
100
+ fits: list = field(repr=False)
101
+
102
+ def __post_init__(self):
103
+ fs = [filter_series(self.model.fspec, ft.params, self.factors[:, i])
104
+ for i, ft in enumerate(self.fits)]
105
+ self.factor_sigma2 = np.column_stack([s[1] for s in fs])
106
+
107
+ @property
108
+ def params(self):
109
+ idx, vals = [], []
110
+ for i, ft in enumerate(self.fits):
111
+ idx += [(f"f{i}", nm) for nm in self.model.fspec.names]
112
+ vals += list(ft.params)
113
+ return pd.Series(vals, index=pd.MultiIndex.from_tuples(idx), name="params")
114
+
115
+ @property
116
+ def std_err(self):
117
+ return pd.Series(np.concatenate([ft.std_err for ft in self.fits]),
118
+ index=self.params.index, name="std_err")
119
+
120
+ @property
121
+ def loglik(self):
122
+ """sum of factor log-likelihoods minus T log|det A|."""
123
+ T = self.factors.shape[0]
124
+ return float(sum(ft.loglik for ft in self.fits)
125
+ - T * np.log(abs(np.linalg.det(self.A))))
126
+
127
+ @property
128
+ def nobs(self):
129
+ return self.factors.shape[0]
130
+
131
+ @property
132
+ def aic(self):
133
+ k = self.params.size + self.A.size
134
+ return -2 * self.loglik + 2 * k
135
+
136
+ def summary(self):
137
+ p, se = self.params, self.std_err
138
+ df = pd.DataFrame({"coef": p, "std_err": se, "t": p / se})
139
+ head = (f"GO-GARCH ({self.model.fspec.vol}, {self.model.fspec.dist}) "
140
+ f"T={self.nobs} N={len(self.model.columns)} loglik={self.loglik:.3f}\n")
141
+ return head + df.to_string(float_format=lambda x: f"{x: .5g}")
142
+
143
+ def __repr__(self):
144
+ return self.summary()
145
+
146
+ @property
147
+ def conditional_covariance(self):
148
+ """(T, N, N) array of H_t = A diag(h_t) A'."""
149
+ return np.einsum("ik,tk,jk->tij", self.A, self.factor_sigma2, self.A)
150
+
151
+ @property
152
+ def conditional_correlation(self):
153
+ H = self.conditional_covariance
154
+ d = 1 / np.sqrt(np.diagonal(H, axis1=1, axis2=2))
155
+ return H * d[:, :, None] * d[:, None, :]
156
+
157
+ @property
158
+ def conditional_volatility(self):
159
+ H = self.conditional_covariance
160
+ return pd.DataFrame(np.sqrt(np.diagonal(H, axis1=1, axis2=2)),
161
+ index=self.model.index, columns=self.model.columns)
162
+
163
+ def _factor_forecast(self, horizon):
164
+ out = np.empty((horizon, len(self.fits)))
165
+ for i, ft in enumerate(self.fits):
166
+ _, omega, alpha, gamma, beta, _ = self.model.fspec.unpack(ft.params)
167
+ e = self.factors[-1, i]
168
+ s = omega + alpha * e * e + beta * self.factor_sigma2[-1, i] + (gamma * e * e if e < 0 else 0)
169
+ pers = alpha + 0.5 * gamma + beta
170
+ for h in range(horizon):
171
+ out[h, i] = s
172
+ s = omega + pers * s
173
+ return out
174
+
175
+ def forecast(self, horizon=1):
176
+ h = self._factor_forecast(horizon)
177
+ cov = np.einsum("ik,tk,jk->tij", self.A, h, self.A)
178
+ return {"mean": np.tile(self.mu, (horizon, 1)), "covariance": cov,
179
+ "correlation": np.array([_normalise(c) for c in cov]),
180
+ "variance": np.diagonal(cov, axis1=1, axis2=2).copy()}
181
+
182
+ def simulate(self, nobs, n_paths=1, start="end", burn=500, seed=None):
183
+ """Simulate return paths; start "end" continues from the sample."""
184
+ rng = np.random.default_rng(seed)
185
+ ups = np.array([self.model.fspec.unpack(ft.params) for ft in self.fits])
186
+ _, omega, alpha, gamma, beta, nus = ups.T
187
+ n = len(self.fits)
188
+ if start == "end":
189
+ s2 = np.tile(self._factor_forecast(1)[0], (n_paths, 1))
190
+ burn = 0
191
+ else:
192
+ s2 = np.tile(omega / (1 - alpha - 0.5 * gamma - beta), (n_paths, 1))
193
+ out = np.empty((n_paths, nobs, len(self.mu)))
194
+ for t in range(burn + nobs):
195
+ z = rng.standard_normal((n_paths, n))
196
+ for i, nu in enumerate(nus):
197
+ if nu > 0:
198
+ z[:, i] = _std_t_ppf(rng.uniform(size=n_paths), nu)
199
+ fct = np.sqrt(s2) * z
200
+ if t >= burn:
201
+ out[:, t - burn] = self.mu + fct @ self.A.T
202
+ s2 = omega + (alpha + gamma * (fct < 0)) * fct ** 2 + beta * s2
203
+ return {"returns": out}
204
+
205
+ def value_at_risk(self, weights, level=0.01, horizon=1, method=None,
206
+ n_sims=100_000, value=1.0, seed=None):
207
+ """Portfolio VaR/ES (positive losses). Analytic for one period with
208
+ normal factors, otherwise by simulation."""
209
+ w = np.asarray(weights, float)
210
+ if method is None:
211
+ method = ("analytic" if horizon == 1 and self.model.fspec.dist == "normal"
212
+ else "simulation")
213
+ if method == "analytic":
214
+ f = self.forecast(1)
215
+ var, es = _analytic_var_es(self.mu @ w, math.sqrt(w @ f["covariance"][0] @ w), level)
216
+ else:
217
+ pr = (self.simulate(horizon, n_paths=n_sims, seed=seed)["returns"] @ w).sum(axis=1)
218
+ q = np.quantile(pr, level)
219
+ var, es = -q, -pr[pr <= q].mean()
220
+ return {"VaR": float(value * var), "ES": float(value * es)}
multiarch/rolling.py ADDED
@@ -0,0 +1,69 @@
1
+ """Rolling-window estimation and one-step-ahead forecasts, for backtesting."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+
8
+ from .dcc import DCC, _analytic_var_es
9
+
10
+
11
+ @dataclass
12
+ class RollingForecast:
13
+ """One-step-ahead forecasts for periods window..T-1.
14
+
15
+ mean (n, N), covariance (n, N, N), and nu (n,) for multivariate-t models
16
+ (NaN otherwise). Row k is the forecast for `index[k]`, made with data up
17
+ to the previous period only.
18
+ """
19
+
20
+ index: pd.Index
21
+ columns: list
22
+ mean: np.ndarray
23
+ covariance: np.ndarray
24
+ nu: np.ndarray
25
+
26
+ def portfolio_variance(self, weights):
27
+ w = np.asarray(weights, float)
28
+ return pd.Series(np.einsum("i,tij,j->t", w, self.covariance, w), index=self.index)
29
+
30
+ def value_at_risk(self, weights, level=0.01, value=1.0):
31
+ """Analytic one-step VaR/ES path (positive numbers are losses)."""
32
+ w = np.asarray(weights, float)
33
+ mean = self.mean @ w
34
+ sd = np.sqrt(self.portfolio_variance(w).to_numpy())
35
+ var = np.empty(len(mean))
36
+ es = np.empty(len(mean))
37
+ for k in range(len(mean)):
38
+ nu = None if np.isnan(self.nu[k]) else self.nu[k]
39
+ var[k], es[k] = _analytic_var_es(mean[k], sd[k], level, nu)
40
+ return pd.DataFrame({"VaR": value * var, "ES": value * es}, index=self.index)
41
+
42
+
43
+ def rolling_forecast(returns, window, refit_every=20, expanding=False, **model_kwargs):
44
+ """Re-estimate a DCC model every `refit_every` periods on a rolling (or
45
+ expanding) window and filter forward between refits with the parameters
46
+ held fixed. Uses no information from the forecast period.
47
+
48
+ `model_kwargs` are passed to `DCC` (model, vol, dist, ...). Copula models
49
+ are supported for the covariance path, but their VaR needs simulation.
50
+ """
51
+ df = returns if isinstance(returns, pd.DataFrame) else pd.DataFrame(np.asarray(returns, float))
52
+ y = df.to_numpy(float)
53
+ T, n = y.shape
54
+ if window >= T:
55
+ raise ValueError("window must be shorter than the sample")
56
+ means, covs, nus = [], [], []
57
+ for k in range(window, T, refit_every):
58
+ lo = 0 if expanding else k - window
59
+ res = DCC(y[lo:k], **model_kwargs).fit(se=None)
60
+ hi = min(k + refit_every, T)
61
+ # filter the estimation window plus the next block; row t of the
62
+ # filtered covariance only uses data up to t-1
63
+ ap = res.apply(y[lo:hi], sigma2_0=res.sigma2[0])
64
+ sl = slice(k - lo, hi - lo)
65
+ covs.append(ap.conditional_covariance[sl])
66
+ means.append(ap.conditional_mean[sl])
67
+ nus.append(np.full(hi - k, res.nu if res.nu else np.nan))
68
+ return RollingForecast(df.index[window:], [str(c) for c in df.columns],
69
+ np.concatenate(means), np.concatenate(covs), np.concatenate(nus))
multiarch/simulate.py ADDED
@@ -0,0 +1,43 @@
1
+ import numpy as np
2
+
3
+ from . import _kernels as K
4
+
5
+
6
+ def simulate_dcc(nobs, mu, omega, alpha, beta, Qbar, a, b, gamma=None, g=0.0,
7
+ Nbar=None, nu=None, burn=500, seed=None, Q0=None, sigma2_0=None,
8
+ shocks=None):
9
+ """Simulate returns from GJR-GARCH(1,1) margins + (A)DCC correlation.
10
+
11
+ nu : degrees of freedom for unit-variance multivariate-t shocks
12
+ (None for Gaussian).
13
+ Q0, sigma2_0 : starting state (default: unconditional, Qbar and
14
+ omega / (1 - persistence)).
15
+ shocks : optional (nobs + burn, N) iid unit-variance, uncorrelated
16
+ innovations, e.g. copula draws; overrides `nu`.
17
+ Returns (returns, sigma2, std_resid), each (nobs, N).
18
+ """
19
+ rng = np.random.default_rng(seed)
20
+ omega = np.asarray(omega, float)
21
+ n = omega.size
22
+ mu = np.broadcast_to(np.asarray(mu, float), (n,)).copy()
23
+ alpha = np.asarray(alpha, float)
24
+ beta = np.asarray(beta, float)
25
+ gamma = np.zeros(n) if gamma is None else np.asarray(gamma, float)
26
+ Qbar = np.asarray(Qbar, float)
27
+ Nbar = np.zeros_like(Qbar) if Nbar is None else np.asarray(Nbar, float)
28
+ T = nobs + burn
29
+ Q0 = Qbar if Q0 is None else np.asarray(Q0, float)
30
+ if sigma2_0 is None:
31
+ sigma2_0 = omega / (1 - alpha - 0.5 * gamma - beta)
32
+ sigma2_0 = np.asarray(sigma2_0, float)
33
+ if shocks is not None:
34
+ return K.simulate_path(mu, omega, alpha, gamma, beta, Qbar, Nbar,
35
+ float(a), float(b), float(g), np.asarray(shocks, float),
36
+ burn, Q0, sigma2_0)
37
+ x = rng.standard_normal((T, n))
38
+ if nu is not None:
39
+ # multivariate t: common chi-square mixing, scaled to unit variance
40
+ w = rng.chisquare(nu, size=T) / nu
41
+ x = x / np.sqrt(w)[:, None] * np.sqrt((nu - 2) / nu)
42
+ return K.simulate_path(mu, omega, alpha, gamma, beta, Qbar, Nbar,
43
+ float(a), float(b), float(g), x, burn, Q0, sigma2_0)
@@ -0,0 +1,213 @@
1
+ """Univariate GARCH(1,1) / GJR-GARCH(1,1) margins, the first stage of DCC.
2
+
3
+ Conventions (start-up backcast, unit-variance Student-t, parameter order)
4
+ follow the `arch` package so results are directly comparable.
5
+ """
6
+
7
+ from dataclasses import dataclass, field
8
+
9
+ import numpy as np
10
+ from scipy.optimize import minimize
11
+
12
+ from . import _kernels as K
13
+ from ._numdiff import hessian, jacobian
14
+
15
+
16
+ def backcast(resid):
17
+ """arch's exponential backcast for the pre-sample variance."""
18
+ tau = min(75, resid.shape[0])
19
+ w = 0.94 ** np.arange(tau)
20
+ w /= w.sum()
21
+ return float(np.sum(resid[:tau] ** 2 * w))
22
+
23
+
24
+ @dataclass
25
+ class UnivariateSpec:
26
+ mean: str = "constant" # "constant" or "zero"
27
+ vol: str = "garch" # "garch" or "gjr"
28
+ dist: str = "normal" # "normal" or "t"
29
+ # start-up of the variance recursion: "arch" (exponential backcast,
30
+ # sigma2_0 = omega + persistence * backcast), "rugarch"
31
+ # (sigma2_0 = full-sample mean of squared residuals) or "tsgarch"
32
+ # (sigma2_0 = omega + persistence * mean of squared residuals)
33
+ init: str = "arch"
34
+
35
+ def __post_init__(self):
36
+ if self.mean not in ("constant", "zero"):
37
+ raise ValueError("mean must be 'constant' or 'zero'")
38
+ if self.vol not in ("garch", "gjr"):
39
+ raise ValueError("vol must be 'garch' or 'gjr'")
40
+ if self.dist not in ("normal", "t"):
41
+ raise ValueError("dist must be 'normal' or 't'")
42
+ if self.init not in ("arch", "rugarch", "tsgarch"):
43
+ raise ValueError("init must be 'arch', 'rugarch' or 'tsgarch'")
44
+
45
+ @property
46
+ def names(self):
47
+ n = ["mu"] if self.mean == "constant" else []
48
+ n += ["omega", "alpha"]
49
+ if self.vol == "gjr":
50
+ n.append("gamma")
51
+ n.append("beta")
52
+ if self.dist == "t":
53
+ n.append("nu")
54
+ return n
55
+
56
+ def unpack(self, p):
57
+ """-> mu, omega, alpha, gamma, beta, nu (nu=0 means normal)."""
58
+ k = 0
59
+ mu = 0.0
60
+ if self.mean == "constant":
61
+ mu = p[0]
62
+ k = 1
63
+ omega, alpha = p[k], p[k + 1]
64
+ k += 2
65
+ gamma = 0.0
66
+ if self.vol == "gjr":
67
+ gamma = p[k]
68
+ k += 1
69
+ beta = p[k]
70
+ nu = p[k + 1] if self.dist == "t" else 0.0
71
+ return mu, omega, alpha, gamma, beta, nu
72
+
73
+
74
+ def _sigma2_0(spec, omega, alpha, gamma, beta, resid):
75
+ if spec.init == "rugarch":
76
+ return float(np.mean(resid * resid))
77
+ if spec.init == "tsgarch":
78
+ return omega + (alpha + 0.5 * gamma + beta) * float(np.mean(resid * resid))
79
+ return omega + (alpha + 0.5 * gamma + beta) * backcast(resid)
80
+
81
+
82
+ def loglik_obs(spec, p, y):
83
+ mu, omega, alpha, gamma, beta, nu = spec.unpack(p)
84
+ resid = y - mu
85
+ s0 = _sigma2_0(spec, omega, alpha, gamma, beta, resid)
86
+ sigma2 = K.garch_filter(omega, alpha, gamma, beta, resid, s0)
87
+ if nu > 0:
88
+ return K.std_t_loglik_obs(resid, sigma2, nu)
89
+ return K.normal_loglik_obs(resid, sigma2)
90
+
91
+
92
+ def filter_series(spec, p, y, sigma2_0=None):
93
+ """-> (residuals, conditional variance, standardised residuals).
94
+ `sigma2_0` overrides the start-up variance."""
95
+ mu, omega, alpha, gamma, beta, _ = spec.unpack(p)
96
+ resid = y - mu
97
+ s0 = _sigma2_0(spec, omega, alpha, gamma, beta, resid) if sigma2_0 is None else sigma2_0
98
+ sigma2 = K.garch_filter(omega, alpha, gamma, beta, resid, s0)
99
+ return resid, sigma2, resid / np.sqrt(sigma2)
100
+
101
+
102
+ @dataclass
103
+ class UnivariateFit:
104
+ spec: UnivariateSpec
105
+ params: np.ndarray
106
+ loglik: float
107
+ cov: np.ndarray = field(repr=False)
108
+ converged: bool = True
109
+
110
+ @property
111
+ def std_err(self):
112
+ return np.sqrt(np.diag(self.cov))
113
+
114
+
115
+ def _start_values(spec, y):
116
+ var = float(np.var(y))
117
+ best, best_ll = None, -np.inf
118
+ for alpha in (0.03, 0.08, 0.15):
119
+ for beta in (0.75, 0.85, 0.92):
120
+ gamma = 0.05 if spec.vol == "gjr" else 0.0
121
+ if alpha + beta + gamma / 2 >= 0.995:
122
+ continue
123
+ p = []
124
+ if spec.mean == "constant":
125
+ p.append(float(np.mean(y)))
126
+ p += [var * (1 - alpha - beta - gamma / 2), alpha]
127
+ if spec.vol == "gjr":
128
+ p.append(gamma)
129
+ p.append(beta)
130
+ if spec.dist == "t":
131
+ p.append(8.0)
132
+ p = np.array(p)
133
+ ll = loglik_obs(spec, p, y).sum()
134
+ if ll > best_ll:
135
+ best, best_ll = p, ll
136
+ return best
137
+
138
+
139
+ def rescale(spec, p, s):
140
+ """Map parameters estimated on y/s back to the scale of y.
141
+ Returns (params, jacobian diagonal)."""
142
+ J = np.array([{"mu": s, "omega": s * s}.get(nm, 1.0) for nm in spec.names])
143
+ return p * J, J
144
+
145
+
146
+ def fit_univariate(y, spec=None, compute_cov=True):
147
+ """QML / ML fit of a single GARCH margin. Covariance is the robust
148
+ (Bollerslev-Wooldridge) sandwich, matching arch's default.
149
+
150
+ The fit runs on y / sd(y) and is mapped back, so results do not depend
151
+ on whether returns are in percent or fractions."""
152
+ spec = spec or UnivariateSpec()
153
+ y = np.asarray(y, dtype=float)
154
+ s = float(np.std(y))
155
+ f = _fit_unit(y / s, spec, compute_cov)
156
+ p, J = rescale(spec, f.params, s)
157
+ cov = f.cov * np.outer(J, J)
158
+ return UnivariateFit(spec, p, f.loglik - y.shape[0] * np.log(s), cov, f.converged)
159
+
160
+
161
+ def _fit_unit(y, spec, compute_cov):
162
+ names = spec.names
163
+ var = float(np.var(y))
164
+ scale = max(abs(float(np.mean(y))), 1e-8)
165
+
166
+ bounds = []
167
+ for nm in names:
168
+ bounds.append(
169
+ {
170
+ "mu": (-10 * scale - 10 * np.sqrt(var), 10 * scale + 10 * np.sqrt(var)),
171
+ "omega": (1e-8 * var, 2.0 * var),
172
+ "alpha": (0.0, 1.0),
173
+ "gamma": (-1.0, 2.0),
174
+ "beta": (0.0, 1.0),
175
+ "nu": (2.05, 500.0),
176
+ }[nm]
177
+ )
178
+ ia, ib = names.index("alpha"), names.index("beta")
179
+ ig = names.index("gamma") if "gamma" in names else None
180
+
181
+ def persistence_gap(p):
182
+ g = p[ig] / 2 if ig is not None else 0.0
183
+ return 1.0 - 1e-6 - p[ia] - p[ib] - g
184
+
185
+ cons = [{"type": "ineq", "fun": persistence_gap}]
186
+ if ig is not None: # alpha + gamma >= 0 keeps the variance positive
187
+ cons.append({"type": "ineq", "fun": lambda p: p[ia] + p[ig]})
188
+
189
+ def obj(p):
190
+ ll = loglik_obs(spec, p, y).sum()
191
+ return -ll if np.isfinite(ll) else 1e10
192
+
193
+ x0 = _start_values(spec, y)
194
+ res = minimize(
195
+ obj, x0, method="SLSQP", bounds=bounds, constraints=cons,
196
+ options={"maxiter": 1000, "ftol": 1e-10},
197
+ )
198
+ p = res.x
199
+ ll = -res.fun
200
+ cov = np.full((len(p), len(p)), np.nan)
201
+ if compute_cov:
202
+ cov = robust_cov(lambda q: loglik_obs(spec, q, y), p)
203
+ return UnivariateFit(spec, p, ll, cov, bool(res.success))
204
+
205
+
206
+ def robust_cov(ll_obs_fn, p):
207
+ """Sandwich covariance A^{-1} B A^{-1} / T from per-observation log-likelihoods."""
208
+ scores = jacobian(ll_obs_fn, p) # T x k
209
+ T = scores.shape[0]
210
+ H = hessian(lambda q: ll_obs_fn(q).sum(), p) / T
211
+ B = scores.T @ scores / T
212
+ Ainv = np.linalg.inv(H)
213
+ return Ainv @ B @ Ainv / T