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.
- multiarch/__init__.py +18 -0
- multiarch/_kernels.py +235 -0
- multiarch/_numdiff.py +43 -0
- multiarch/dcc.py +731 -0
- multiarch/diagnostics.py +115 -0
- multiarch/ewma.py +26 -0
- multiarch/gogarch.py +220 -0
- multiarch/rolling.py +69 -0
- multiarch/simulate.py +43 -0
- multiarch/univariate.py +213 -0
- multiarch-0.2.0.dist-info/METADATA +204 -0
- multiarch-0.2.0.dist-info/RECORD +15 -0
- multiarch-0.2.0.dist-info/WHEEL +5 -0
- multiarch-0.2.0.dist-info/licenses/LICENSE +21 -0
- multiarch-0.2.0.dist-info/top_level.txt +1 -0
multiarch/diagnostics.py
ADDED
|
@@ -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)
|
multiarch/univariate.py
ADDED
|
@@ -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
|