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/dcc.py
ADDED
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
"""Two-step CCC / DCC / aDCC estimation (Engle 2002; Cappiello, Engle &
|
|
2
|
+
Sheppard 2006), optionally as a Gaussian or Student-t copula (Patton 2006),
|
|
3
|
+
with composite likelihood for large cross-sections (Engle, Shephard &
|
|
4
|
+
Sheppard 2008) and two-step-corrected standard errors (Engle & Sheppard
|
|
5
|
+
2001)."""
|
|
6
|
+
|
|
7
|
+
import copy
|
|
8
|
+
import itertools
|
|
9
|
+
import math
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import pandas as pd
|
|
14
|
+
from scipy import stats
|
|
15
|
+
from scipy.optimize import minimize
|
|
16
|
+
|
|
17
|
+
from . import _kernels as K
|
|
18
|
+
from ._numdiff import gradient, hessian, jacobian
|
|
19
|
+
from .univariate import (
|
|
20
|
+
UnivariateSpec,
|
|
21
|
+
filter_series,
|
|
22
|
+
fit_univariate,
|
|
23
|
+
loglik_obs,
|
|
24
|
+
rescale,
|
|
25
|
+
robust_cov,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
_PENALTY = 1e10
|
|
29
|
+
_EPS_U = 1e-12
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _targets(x):
|
|
33
|
+
T = x.shape[0]
|
|
34
|
+
Qbar = x.T @ x / T
|
|
35
|
+
n = np.minimum(x, 0.0)
|
|
36
|
+
Nbar = n.T @ n / T
|
|
37
|
+
return Qbar, Nbar
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _normalise(Q):
|
|
41
|
+
d = 1 / np.sqrt(np.diag(Q))
|
|
42
|
+
return Q * np.outer(d, d)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _asym_bound(Qbar, Nbar):
|
|
46
|
+
"""delta = max eigenvalue of Qbar^{-1/2} Nbar Qbar^{-1/2}; stationarity
|
|
47
|
+
of aDCC needs a + b + delta * g < 1."""
|
|
48
|
+
Li = np.linalg.inv(np.linalg.cholesky(Qbar))
|
|
49
|
+
return float(np.linalg.eigvalsh(Li @ Nbar @ Li.T).max())
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _const_corr_ll_obs(x, R, nu):
|
|
53
|
+
L = np.linalg.cholesky(R)
|
|
54
|
+
y = np.linalg.solve(L, x.T)
|
|
55
|
+
quad = np.sum(y * y, axis=0)
|
|
56
|
+
logdet = 2 * np.log(np.diag(L)).sum()
|
|
57
|
+
n = x.shape[1]
|
|
58
|
+
if nu <= 0:
|
|
59
|
+
return -0.5 * (n * np.log(2 * np.pi) + logdet + quad)
|
|
60
|
+
c = math.lgamma(0.5 * (nu + n)) - math.lgamma(0.5 * nu) - 0.5 * n * math.log(math.pi * (nu - 2))
|
|
61
|
+
return c - 0.5 * logdet - 0.5 * (nu + n) * np.log1p(quad / (nu - 2))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _std_t_logpdf(x, nu):
|
|
65
|
+
"""Unit-variance Student-t log density, elementwise."""
|
|
66
|
+
c = math.lgamma(0.5 * (nu + 1)) - math.lgamma(0.5 * nu) - 0.5 * math.log(math.pi * (nu - 2))
|
|
67
|
+
return c - 0.5 * (nu + 1) * np.log1p(x * x / (nu - 2))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _std_t_ppf(u, nu):
|
|
71
|
+
return stats.t.ppf(u, nu) * math.sqrt((nu - 2) / nu)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _std_t_cdf(x, nu):
|
|
75
|
+
return stats.t.cdf(x * math.sqrt(nu / (nu - 2)), nu)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class DCC:
|
|
79
|
+
"""Two-step multivariate GARCH with dynamic conditional correlation.
|
|
80
|
+
|
|
81
|
+
Parameters
|
|
82
|
+
----------
|
|
83
|
+
returns : (T, N) array or DataFrame, in any units (percent or
|
|
84
|
+
fractions; estimation is scale-free).
|
|
85
|
+
model : "dcc", "adcc" or "ccc".
|
|
86
|
+
vol : univariate variance model, "garch" or "gjr" (both (1,1)).
|
|
87
|
+
mean : "constant" or "zero".
|
|
88
|
+
dist : margin distribution, "normal" or "t" (unit-variance Student-t).
|
|
89
|
+
corr_dist : distribution of the correlation stage without a copula,
|
|
90
|
+
"normal" or "t" (multivariate t with its own shape `nu`). Defaults
|
|
91
|
+
to `dist`; e.g. dist="normal", corr_dist="t" is tsmarch's and
|
|
92
|
+
rmgarch's "mvt" with Gaussian QML margins.
|
|
93
|
+
copula : None (standardised residuals enter the correlation stage
|
|
94
|
+
directly), "normal" or "t". With a copula the margins are mapped
|
|
95
|
+
to uniforms through their fitted CDFs and the correlation dynamics
|
|
96
|
+
drive a Gaussian or Student-t copula, as in rmgarch's cgarch.
|
|
97
|
+
estimator : "full" (exact likelihood) or "composite" (sum of pairwise
|
|
98
|
+
bivariate likelihoods; use for large N).
|
|
99
|
+
pairs : for the composite estimator, "all" or "adjacent" (N-1 pairs).
|
|
100
|
+
convention : start-up conventions. "arch" (default): exponential
|
|
101
|
+
backcast for the variances (as the `arch` package), Qbar = Z'Z/T and
|
|
102
|
+
Q_1 = Qbar. "rmgarch": reproduce rmgarch's likelihood exactly
|
|
103
|
+
(variances start at the sample mean of squared residuals,
|
|
104
|
+
Qbar = cov(Z), rmgarch's pre-sample Q step and copula clipping).
|
|
105
|
+
"tsmarch": match the tsmarch/tsgarch packages (sigma2_1 = omega +
|
|
106
|
+
persistence * mean squared residual, Qbar = cov(Z), Q_1 = Qbar).
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(self, returns, model="dcc", vol="garch", mean="constant",
|
|
110
|
+
dist="normal", corr_dist=None, copula=None, estimator="full",
|
|
111
|
+
pairs="all", convention="arch"):
|
|
112
|
+
if model not in ("dcc", "adcc", "ccc"):
|
|
113
|
+
raise ValueError("model must be 'dcc', 'adcc' or 'ccc'")
|
|
114
|
+
if estimator not in ("full", "composite"):
|
|
115
|
+
raise ValueError("estimator must be 'full' or 'composite'")
|
|
116
|
+
if copula not in (None, "normal", "t"):
|
|
117
|
+
raise ValueError("copula must be None, 'normal' or 't'")
|
|
118
|
+
if isinstance(returns, pd.DataFrame):
|
|
119
|
+
self.index = returns.index
|
|
120
|
+
self.columns = [str(c) for c in returns.columns]
|
|
121
|
+
y = returns.to_numpy(dtype=float)
|
|
122
|
+
else:
|
|
123
|
+
y = np.asarray(returns, dtype=float)
|
|
124
|
+
self.index = pd.RangeIndex(y.shape[0])
|
|
125
|
+
self.columns = [f"x{i}" for i in range(y.shape[1])]
|
|
126
|
+
if y.ndim != 2 or y.shape[1] < 2:
|
|
127
|
+
raise ValueError("returns must be (T, N) with N >= 2")
|
|
128
|
+
if not np.isfinite(y).all():
|
|
129
|
+
raise ValueError("returns contain NaN or inf")
|
|
130
|
+
self.y = y
|
|
131
|
+
self.model = model
|
|
132
|
+
self.dist = dist
|
|
133
|
+
self.copula = copula
|
|
134
|
+
self.estimator = estimator
|
|
135
|
+
if convention not in ("arch", "rmgarch", "tsmarch"):
|
|
136
|
+
raise ValueError("convention must be 'arch', 'rmgarch' or 'tsmarch'")
|
|
137
|
+
self.convention = convention
|
|
138
|
+
self.uspec = UnivariateSpec(mean=mean, vol=vol, dist=dist,
|
|
139
|
+
init={"arch": "arch", "rmgarch": "rugarch",
|
|
140
|
+
"tsmarch": "tsgarch"}[convention])
|
|
141
|
+
if corr_dist not in (None, "normal", "t"):
|
|
142
|
+
raise ValueError("corr_dist must be None, 'normal' or 't'")
|
|
143
|
+
if corr_dist is not None and copula is not None:
|
|
144
|
+
raise ValueError("corr_dist is set by `copula` when a copula is used")
|
|
145
|
+
self.corr_dist = corr_dist or dist
|
|
146
|
+
self._mvt = copula == "t" or (copula is None and self.corr_dist == "t")
|
|
147
|
+
n = y.shape[1]
|
|
148
|
+
if pairs == "all":
|
|
149
|
+
self.pairs = np.array(list(itertools.combinations(range(n), 2)))
|
|
150
|
+
elif pairs == "adjacent":
|
|
151
|
+
self.pairs = np.array([(i, i + 1) for i in range(n - 1)])
|
|
152
|
+
else:
|
|
153
|
+
raise ValueError("pairs must be 'all' or 'adjacent'")
|
|
154
|
+
self._pairs_arg = pairs
|
|
155
|
+
|
|
156
|
+
def _clone(self, y, index=None):
|
|
157
|
+
m = copy.copy(self)
|
|
158
|
+
m.y = np.asarray(y, float)
|
|
159
|
+
m.index = pd.RangeIndex(m.y.shape[0]) if index is None else index
|
|
160
|
+
return m
|
|
161
|
+
|
|
162
|
+
def _targets(self, x):
|
|
163
|
+
if self.convention in ("rmgarch", "tsmarch"):
|
|
164
|
+
n = np.minimum(x, 0.0)
|
|
165
|
+
return np.cov(x, rowvar=False), np.cov(n, rowvar=False)
|
|
166
|
+
return _targets(x)
|
|
167
|
+
|
|
168
|
+
def _q0(self, Qbar, Nbar, a, b, g):
|
|
169
|
+
"""Q for the first observation. rmgarch's C code feeds a pre-sample
|
|
170
|
+
shock of ones into DCC and of zeros into copula-DCC."""
|
|
171
|
+
if self.convention == "rmgarch":
|
|
172
|
+
shock = 0.0 if self.copula else 1.0
|
|
173
|
+
return (1 - a - b) * Qbar - g * Nbar + (a + g) * shock * np.ones_like(Qbar) + b * Qbar
|
|
174
|
+
return Qbar
|
|
175
|
+
|
|
176
|
+
# ------------------------------------------------------------ names
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def corr_names(self):
|
|
180
|
+
names = {"dcc": ["a", "b"], "adcc": ["a", "b", "g"], "ccc": []}[self.model]
|
|
181
|
+
return names + (["nu"] if self._mvt else [])
|
|
182
|
+
|
|
183
|
+
def _unpack_corr(self, phi):
|
|
184
|
+
a = b = g = nu = 0.0
|
|
185
|
+
k = 0
|
|
186
|
+
if self.model in ("dcc", "adcc"):
|
|
187
|
+
a, b = phi[0], phi[1]
|
|
188
|
+
k = 2
|
|
189
|
+
if self.model == "adcc":
|
|
190
|
+
g = phi[2]
|
|
191
|
+
k = 3
|
|
192
|
+
if self._mvt:
|
|
193
|
+
nu = phi[k]
|
|
194
|
+
return a, b, g, nu
|
|
195
|
+
|
|
196
|
+
# ------------------------------------------------------------ stage-2 data
|
|
197
|
+
|
|
198
|
+
def _std_resid(self, uparams, sigma2_0=None):
|
|
199
|
+
cols = []
|
|
200
|
+
for i, p in enumerate(uparams):
|
|
201
|
+
s0 = None if sigma2_0 is None else sigma2_0[i]
|
|
202
|
+
cols.append(filter_series(self.uspec, p, self.y[:, i], s0)[2])
|
|
203
|
+
return np.column_stack(cols)
|
|
204
|
+
|
|
205
|
+
def _stage2_input(self, uparams, z=None, sigma2_0=None):
|
|
206
|
+
"""Standardised residuals, or their PIT uniforms under a copula."""
|
|
207
|
+
if z is None:
|
|
208
|
+
z = self._std_resid(uparams, sigma2_0)
|
|
209
|
+
if self.copula is None:
|
|
210
|
+
return z
|
|
211
|
+
u = np.empty_like(z)
|
|
212
|
+
for i, p in enumerate(uparams):
|
|
213
|
+
nu_i = self.uspec.unpack(p)[5]
|
|
214
|
+
u[:, i] = _std_t_cdf(z[:, i], nu_i) if nu_i > 0 else stats.norm.cdf(z[:, i])
|
|
215
|
+
if self.convention == "rmgarch":
|
|
216
|
+
return np.clip(u, 2.220446049250313e-16, 0.99999)
|
|
217
|
+
return np.clip(u, _EPS_U, 1 - _EPS_U)
|
|
218
|
+
|
|
219
|
+
def _transform(self, e, nu):
|
|
220
|
+
"""-> (x, adj): variables driving the correlation recursion and the
|
|
221
|
+
per-observation log-density correction (minus the copula's
|
|
222
|
+
marginal densities; zero without a copula)."""
|
|
223
|
+
if self.copula is None:
|
|
224
|
+
return e, None
|
|
225
|
+
if self.copula == "normal":
|
|
226
|
+
x = stats.norm.ppf(e)
|
|
227
|
+
return x, -stats.norm.logpdf(x)
|
|
228
|
+
x = _std_t_ppf(e, nu)
|
|
229
|
+
return x, -_std_t_logpdf(x, nu)
|
|
230
|
+
|
|
231
|
+
def _ll_obs(self, phi, e, targets=None):
|
|
232
|
+
"""Per-observation stage-2 log density (full likelihood).
|
|
233
|
+
`targets` = (Qbar, Nbar) to hold the intercept fixed (filtering new
|
|
234
|
+
data); by default they are re-estimated from the data."""
|
|
235
|
+
a, b, g, nu = self._unpack_corr(phi)
|
|
236
|
+
x, adj = self._transform(e, nu)
|
|
237
|
+
Qbar, Nbar = self._targets(x) if targets is None else targets
|
|
238
|
+
if self.model == "ccc":
|
|
239
|
+
ll = _const_corr_ll_obs(x, _normalise(Qbar), nu)
|
|
240
|
+
else:
|
|
241
|
+
ll = K.dcc_loglik_obs(x, Qbar, Nbar, a, b, g, nu, self._q0(Qbar, Nbar, a, b, g))
|
|
242
|
+
return ll if adj is None else ll + adj.sum(axis=1)
|
|
243
|
+
|
|
244
|
+
def _objective(self, phi, e):
|
|
245
|
+
try:
|
|
246
|
+
if self.estimator == "composite" and self.model != "ccc":
|
|
247
|
+
a, b, g, nu = self._unpack_corr(phi)
|
|
248
|
+
x, adj = self._transform(e, nu)
|
|
249
|
+
Qbar, Nbar = self._targets(x)
|
|
250
|
+
ll = K.dcc_composite_loglik(x, Qbar, Nbar, self.pairs, a, b, g, nu,
|
|
251
|
+
self._q0(Qbar, Nbar, a, b, g))
|
|
252
|
+
if adj is not None:
|
|
253
|
+
counts = np.bincount(self.pairs.ravel(), minlength=x.shape[1])
|
|
254
|
+
ll += float(adj.sum(axis=0) @ counts)
|
|
255
|
+
else:
|
|
256
|
+
ll = self._ll_obs(phi, e).sum()
|
|
257
|
+
except np.linalg.LinAlgError:
|
|
258
|
+
return _PENALTY
|
|
259
|
+
return -ll if np.isfinite(ll) else _PENALTY
|
|
260
|
+
|
|
261
|
+
def _fit_corr(self, e):
|
|
262
|
+
names = self.corr_names
|
|
263
|
+
if not names:
|
|
264
|
+
return np.array([]), True
|
|
265
|
+
if self.model == "adcc":
|
|
266
|
+
x, _ = self._transform(e, 8.0)
|
|
267
|
+
delta = _asym_bound(*self._targets(x))
|
|
268
|
+
else:
|
|
269
|
+
delta = 0.0
|
|
270
|
+
bounds = {"a": (0.0, 0.5), "b": (0.0, 0.9999), "g": (0.0, 0.5), "nu": (2.05, 200.0)}
|
|
271
|
+
bnds = [bounds[n] for n in names]
|
|
272
|
+
cons = []
|
|
273
|
+
if self.model != "ccc":
|
|
274
|
+
def gap(phi):
|
|
275
|
+
a, b, g, _ = self._unpack_corr(phi)
|
|
276
|
+
return 0.9999 - a - b - delta * g
|
|
277
|
+
cons.append({"type": "ineq", "fun": gap})
|
|
278
|
+
|
|
279
|
+
obj = lambda phi: self._objective(phi, e)
|
|
280
|
+
if self.model == "ccc":
|
|
281
|
+
starts = [[nu0] for nu0 in (5.0, 8.0, 15.0)]
|
|
282
|
+
else:
|
|
283
|
+
starts = []
|
|
284
|
+
for a, b in itertools.product((0.01, 0.03, 0.06), (0.85, 0.93, 0.97)):
|
|
285
|
+
if a + b >= 0.999:
|
|
286
|
+
continue
|
|
287
|
+
s = [a, b] + ([0.01] if self.model == "adcc" else [])
|
|
288
|
+
starts.append(s + ([8.0] if self._mvt else []))
|
|
289
|
+
x0 = min(starts, key=lambda s: obj(np.array(s)))
|
|
290
|
+
res = minimize(obj, np.array(x0, float), method="SLSQP", bounds=bnds,
|
|
291
|
+
constraints=cons, options={"maxiter": 1000, "ftol": 1e-11})
|
|
292
|
+
return res.x, bool(res.success)
|
|
293
|
+
|
|
294
|
+
# ------------------------------------------------------------ fit
|
|
295
|
+
|
|
296
|
+
def fit(self, se="two-step"):
|
|
297
|
+
"""Estimate the model.
|
|
298
|
+
|
|
299
|
+
se : "two-step" (Engle-Sheppard covariance that accounts for the
|
|
300
|
+
first-stage estimation error; full estimator only), "naive"
|
|
301
|
+
(stage-wise robust covariances, ignoring the first stage),
|
|
302
|
+
or None to skip standard errors (fastest).
|
|
303
|
+
"""
|
|
304
|
+
if se not in ("two-step", "naive", None):
|
|
305
|
+
raise ValueError("se must be 'two-step', 'naive' or None")
|
|
306
|
+
if se == "two-step" and self.estimator == "composite":
|
|
307
|
+
se = "naive" # the corrected form is for the full likelihood
|
|
308
|
+
# estimate on unit-variance data for a well-conditioned problem,
|
|
309
|
+
# then map the margin parameters (and their covariance) back
|
|
310
|
+
scale = self.y.std(axis=0)
|
|
311
|
+
inner = self._clone(self.y / scale, self.index)
|
|
312
|
+
uparams, phi, cov, ok = inner._estimate(se)
|
|
313
|
+
Js = []
|
|
314
|
+
for i, p in enumerate(uparams):
|
|
315
|
+
uparams[i], J = rescale(self.uspec, p, scale[i])
|
|
316
|
+
Js.append(J)
|
|
317
|
+
J = np.concatenate(Js + [np.ones(phi.size)])
|
|
318
|
+
return DCCResult(self, uparams, phi, cov * np.outer(J, J), converged=ok)
|
|
319
|
+
|
|
320
|
+
def _estimate(self, se):
|
|
321
|
+
ufits = [
|
|
322
|
+
fit_univariate(self.y[:, i], self.uspec, compute_cov=se is not None)
|
|
323
|
+
for i in range(self.y.shape[1])
|
|
324
|
+
]
|
|
325
|
+
uparams = [f.params for f in ufits]
|
|
326
|
+
e = self._stage2_input(uparams)
|
|
327
|
+
phi, ok = self._fit_corr(e)
|
|
328
|
+
|
|
329
|
+
k1 = len(self.uspec.names)
|
|
330
|
+
n = self.y.shape[1]
|
|
331
|
+
npar = k1 * n + phi.size
|
|
332
|
+
cov = np.full((npar, npar), np.nan)
|
|
333
|
+
if se == "naive":
|
|
334
|
+
for i, f in enumerate(ufits):
|
|
335
|
+
cov[i * k1:(i + 1) * k1, i * k1:(i + 1) * k1] = f.cov
|
|
336
|
+
if phi.size:
|
|
337
|
+
cov[k1 * n:, k1 * n:] = self._naive_corr_cov(phi, e)
|
|
338
|
+
elif se == "two-step":
|
|
339
|
+
cov = self._two_step_cov(uparams, phi)
|
|
340
|
+
return uparams, phi, cov, ok and all(f.converged for f in ufits)
|
|
341
|
+
|
|
342
|
+
def fix(self, params):
|
|
343
|
+
"""Build a result from given parameters without estimation.
|
|
344
|
+
`params` is a vector in the order of `DCCResult.params`."""
|
|
345
|
+
params = np.asarray(params, float)
|
|
346
|
+
k1 = len(self.uspec.names)
|
|
347
|
+
n = self.y.shape[1]
|
|
348
|
+
uparams = [params[i * k1:(i + 1) * k1] for i in range(n)]
|
|
349
|
+
phi = params[k1 * n:]
|
|
350
|
+
return DCCResult(self, uparams, phi, np.full((params.size,) * 2, np.nan))
|
|
351
|
+
|
|
352
|
+
# ------------------------------------------------------------ inference
|
|
353
|
+
|
|
354
|
+
def _naive_corr_cov(self, phi, e):
|
|
355
|
+
if self.estimator == "composite":
|
|
356
|
+
# Hessian-only for the composite likelihood (no per-obs scores)
|
|
357
|
+
H = hessian(lambda q: -self._objective(q, e), phi)
|
|
358
|
+
return np.linalg.inv(-H)
|
|
359
|
+
return robust_cov(lambda q: self._ll_obs(q, e), phi)
|
|
360
|
+
|
|
361
|
+
def _two_step_cov(self, uparams, phi):
|
|
362
|
+
"""Engle & Sheppard (2001) / Newey-McFadden two-step GMM covariance.
|
|
363
|
+
|
|
364
|
+
Stacked moments g_t = [s_1t(theta_1); ...; s_Nt(theta_N); s_t(phi | theta)].
|
|
365
|
+
A is block lower-triangular: univariate Hessians on the diagonal,
|
|
366
|
+
the correlation Hessian bottom-right and the cross derivative
|
|
367
|
+
d s(phi) / d theta in the last block row. Cov = A^{-1} B A^{-T} / T.
|
|
368
|
+
The correlation targets are re-estimated inside every evaluation,
|
|
369
|
+
so their dependence on theta is part of the cross derivative.
|
|
370
|
+
"""
|
|
371
|
+
n = self.y.shape[1]
|
|
372
|
+
k1 = len(self.uspec.names)
|
|
373
|
+
T = self.y.shape[0]
|
|
374
|
+
theta = np.concatenate(uparams)
|
|
375
|
+
kc = phi.size
|
|
376
|
+
p = theta.size + kc
|
|
377
|
+
|
|
378
|
+
def corr_ll_obs(theta_vec, phi_vec):
|
|
379
|
+
return self._ll_obs(phi_vec, self._stage2_input(theta_vec.reshape(n, k1)))
|
|
380
|
+
|
|
381
|
+
scores = np.empty((T, p))
|
|
382
|
+
A = np.zeros((p, p))
|
|
383
|
+
for i in range(n):
|
|
384
|
+
sl = slice(i * k1, (i + 1) * k1)
|
|
385
|
+
f = lambda q, i=i: loglik_obs(self.uspec, q, self.y[:, i])
|
|
386
|
+
scores[:, sl] = jacobian(f, uparams[i])
|
|
387
|
+
A[sl, sl] = hessian(lambda q: f(q).sum(), uparams[i]) / T
|
|
388
|
+
if kc:
|
|
389
|
+
scores[:, theta.size:] = jacobian(lambda q: corr_ll_obs(theta, q), phi)
|
|
390
|
+
A[theta.size:, theta.size:] = hessian(
|
|
391
|
+
lambda q: corr_ll_obs(theta, q).sum(), phi) / T
|
|
392
|
+
g2 = lambda t: gradient(lambda q: corr_ll_obs(t, q).sum(), phi)
|
|
393
|
+
A[theta.size:, :theta.size] = jacobian(g2, theta) / T
|
|
394
|
+
B = scores.T @ scores / T
|
|
395
|
+
Ainv = np.linalg.inv(A)
|
|
396
|
+
return Ainv @ B @ Ainv.T / T
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
@dataclass
|
|
400
|
+
class DCCResult:
|
|
401
|
+
"""Fitted (or fixed-parameter) DCC model. Series arrays are aligned with
|
|
402
|
+
the input: row t of `conditional_covariance` is the forecast of the
|
|
403
|
+
covariance of returns at t made with information up to t-1."""
|
|
404
|
+
|
|
405
|
+
model: DCC
|
|
406
|
+
uparams: list
|
|
407
|
+
corr_params: np.ndarray
|
|
408
|
+
cov: np.ndarray = field(repr=False)
|
|
409
|
+
converged: bool = True
|
|
410
|
+
targets: tuple = field(default=None, repr=False)
|
|
411
|
+
sigma2_0: np.ndarray = field(default=None, repr=False)
|
|
412
|
+
|
|
413
|
+
def __post_init__(self):
|
|
414
|
+
m = self.model
|
|
415
|
+
self.uparams = [np.asarray(p, float) for p in self.uparams]
|
|
416
|
+
self.corr_params = np.asarray(self.corr_params, float)
|
|
417
|
+
a, b, g, nu = m._unpack_corr(self.corr_params)
|
|
418
|
+
fs = []
|
|
419
|
+
for i, p in enumerate(self.uparams):
|
|
420
|
+
s0 = None if self.sigma2_0 is None else self.sigma2_0[i]
|
|
421
|
+
fs.append(filter_series(m.uspec, p, m.y[:, i], s0))
|
|
422
|
+
self.resid = np.column_stack([f[0] for f in fs])
|
|
423
|
+
self.sigma2 = np.column_stack([f[1] for f in fs])
|
|
424
|
+
self.std_resid_arr = np.column_stack([f[2] for f in fs])
|
|
425
|
+
self._e = m._stage2_input(self.uparams, self.std_resid_arr)
|
|
426
|
+
self._x, self._adj = m._transform(self._e, nu)
|
|
427
|
+
if self.targets is None:
|
|
428
|
+
self.targets = m._targets(self._x)
|
|
429
|
+
self.Qbar, self.Nbar = self.targets
|
|
430
|
+
self.Rbar = _normalise(self.Qbar)
|
|
431
|
+
if m.model == "ccc":
|
|
432
|
+
T = m.y.shape[0]
|
|
433
|
+
self._R = np.broadcast_to(self.Rbar, (T + 1,) + self.Rbar.shape)
|
|
434
|
+
self._Q = self._R
|
|
435
|
+
else:
|
|
436
|
+
self._Q, self._R = K.dcc_filter(self._x, self.Qbar, self.Nbar, a, b, g,
|
|
437
|
+
m._q0(self.Qbar, self.Nbar, a, b, g))
|
|
438
|
+
|
|
439
|
+
# ------------------------------------------------------------ tables
|
|
440
|
+
|
|
441
|
+
@property
|
|
442
|
+
def params(self):
|
|
443
|
+
m = self.model
|
|
444
|
+
idx, vals = [], []
|
|
445
|
+
for c, p in zip(m.columns, self.uparams):
|
|
446
|
+
idx += [(c, nm) for nm in m.uspec.names]
|
|
447
|
+
vals += list(p)
|
|
448
|
+
idx += [("corr", nm) for nm in m.corr_names]
|
|
449
|
+
vals += list(self.corr_params)
|
|
450
|
+
return pd.Series(vals, index=pd.MultiIndex.from_tuples(idx), name="params")
|
|
451
|
+
|
|
452
|
+
@property
|
|
453
|
+
def std_err(self):
|
|
454
|
+
return pd.Series(np.sqrt(np.diag(self.cov)), index=self.params.index, name="std_err")
|
|
455
|
+
|
|
456
|
+
@property
|
|
457
|
+
def pvalues(self):
|
|
458
|
+
t = self.params / self.std_err
|
|
459
|
+
return pd.Series(2 * stats.norm.sf(np.abs(t)), index=t.index, name="pvalue")
|
|
460
|
+
|
|
461
|
+
@property
|
|
462
|
+
def nu(self):
|
|
463
|
+
"""Shape of the multivariate t / t copula (None if Gaussian)."""
|
|
464
|
+
return self.model._unpack_corr(self.corr_params)[3] or None
|
|
465
|
+
|
|
466
|
+
@property
|
|
467
|
+
def loglik_obs(self):
|
|
468
|
+
"""Per-observation joint log-likelihood of the returns."""
|
|
469
|
+
m = self.model
|
|
470
|
+
a, b, g, nu = m._unpack_corr(self.corr_params)
|
|
471
|
+
if m.model == "ccc":
|
|
472
|
+
ll = _const_corr_ll_obs(self._x, self.Rbar, nu)
|
|
473
|
+
else:
|
|
474
|
+
ll = K.dcc_loglik_obs(self._x, self.Qbar, self.Nbar, a, b, g, nu,
|
|
475
|
+
m._q0(self.Qbar, self.Nbar, a, b, g))
|
|
476
|
+
if self._adj is not None:
|
|
477
|
+
# copula: add back the fitted margins' own log densities
|
|
478
|
+
return ll + self._adj.sum(axis=1) + self._margin_ll().sum(axis=1)
|
|
479
|
+
return ll - 0.5 * np.log(self.sigma2).sum(axis=1)
|
|
480
|
+
|
|
481
|
+
def _margin_ll(self):
|
|
482
|
+
m = self.model
|
|
483
|
+
out = []
|
|
484
|
+
for i, p in enumerate(self.uparams):
|
|
485
|
+
nu_i = m.uspec.unpack(p)[5]
|
|
486
|
+
z, s2 = self.std_resid_arr[:, i], self.sigma2[:, i]
|
|
487
|
+
if nu_i > 0:
|
|
488
|
+
out.append(_std_t_logpdf(z, nu_i) - 0.5 * np.log(s2))
|
|
489
|
+
else:
|
|
490
|
+
out.append(stats.norm.logpdf(z) - 0.5 * np.log(s2))
|
|
491
|
+
return np.column_stack(out)
|
|
492
|
+
|
|
493
|
+
@property
|
|
494
|
+
def loglik(self):
|
|
495
|
+
return float(self.loglik_obs.sum())
|
|
496
|
+
|
|
497
|
+
@property
|
|
498
|
+
def nobs(self):
|
|
499
|
+
return self.model.y.shape[0]
|
|
500
|
+
|
|
501
|
+
@property
|
|
502
|
+
def aic(self):
|
|
503
|
+
return -2 * self.loglik + 2 * self.params.size
|
|
504
|
+
|
|
505
|
+
@property
|
|
506
|
+
def bic(self):
|
|
507
|
+
return -2 * self.loglik + np.log(self.nobs) * self.params.size
|
|
508
|
+
|
|
509
|
+
def summary(self):
|
|
510
|
+
p, se = self.params, self.std_err
|
|
511
|
+
df = pd.DataFrame({"coef": p, "std_err": se, "t": p / se, "p": self.pvalues})
|
|
512
|
+
m = self.model
|
|
513
|
+
cop = f" copula={m.copula}" if m.copula else ""
|
|
514
|
+
head = (f"{m.model.upper()}-{m.uspec.vol.upper()} dist={m.dist}{cop} "
|
|
515
|
+
f"estimator={m.estimator} T={self.nobs} N={len(m.columns)}\n"
|
|
516
|
+
f"loglik={self.loglik:.3f} AIC={self.aic:.3f} BIC={self.bic:.3f} "
|
|
517
|
+
f"converged={self.converged}\n")
|
|
518
|
+
return head + df.to_string(float_format=lambda x: f"{x: .5g}")
|
|
519
|
+
|
|
520
|
+
def __repr__(self):
|
|
521
|
+
return self.summary()
|
|
522
|
+
|
|
523
|
+
# ------------------------------------------------------------ series
|
|
524
|
+
|
|
525
|
+
@property
|
|
526
|
+
def std_resid(self):
|
|
527
|
+
return pd.DataFrame(self.std_resid_arr, index=self.model.index, columns=self.model.columns)
|
|
528
|
+
|
|
529
|
+
@property
|
|
530
|
+
def conditional_volatility(self):
|
|
531
|
+
return pd.DataFrame(np.sqrt(self.sigma2), index=self.model.index,
|
|
532
|
+
columns=self.model.columns)
|
|
533
|
+
|
|
534
|
+
@property
|
|
535
|
+
def conditional_correlation(self):
|
|
536
|
+
"""(T, N, N) array of R_t (the copula correlation under a copula)."""
|
|
537
|
+
return np.asarray(self._R[:-1])
|
|
538
|
+
|
|
539
|
+
@property
|
|
540
|
+
def conditional_covariance(self):
|
|
541
|
+
"""(T, N, N) array of H_t = D_t R_t D_t."""
|
|
542
|
+
s = np.sqrt(self.sigma2)
|
|
543
|
+
return self.conditional_correlation * s[:, :, None] * s[:, None, :]
|
|
544
|
+
|
|
545
|
+
@property
|
|
546
|
+
def conditional_mean(self):
|
|
547
|
+
mu = np.array([self.model.uspec.unpack(p)[0] for p in self.uparams])
|
|
548
|
+
return np.broadcast_to(mu, self.sigma2.shape)
|
|
549
|
+
|
|
550
|
+
def correlation(self, i, j):
|
|
551
|
+
"""Conditional correlation path between two assets as a Series."""
|
|
552
|
+
m = self.model
|
|
553
|
+
i = m.columns.index(i) if isinstance(i, str) else i
|
|
554
|
+
j = m.columns.index(j) if isinstance(j, str) else j
|
|
555
|
+
return pd.Series(self.conditional_correlation[:, i, j], index=m.index,
|
|
556
|
+
name=f"rho({m.columns[i]},{m.columns[j]})")
|
|
557
|
+
|
|
558
|
+
def portfolio_variance(self, weights):
|
|
559
|
+
"""In-sample one-step-ahead portfolio variance w' H_t w."""
|
|
560
|
+
w = np.asarray(weights, float)
|
|
561
|
+
v = np.einsum("i,tij,j->t", w, self.conditional_covariance, w)
|
|
562
|
+
return pd.Series(v, index=self.model.index, name="portfolio_variance")
|
|
563
|
+
|
|
564
|
+
def value_at_risk_path(self, weights, level=0.01, value=1.0):
|
|
565
|
+
"""In-sample one-step-ahead VaR and ES of a portfolio (positive
|
|
566
|
+
numbers are losses), for backtesting. Analytic, so only for models
|
|
567
|
+
without a copula. Returns a DataFrame with columns VaR, ES."""
|
|
568
|
+
if self.model.copula is not None:
|
|
569
|
+
raise ValueError("analytic VaR is not available under a copula")
|
|
570
|
+
w = np.asarray(weights, float)
|
|
571
|
+
mean = self.conditional_mean @ w
|
|
572
|
+
sd = np.sqrt(self.portfolio_variance(w).to_numpy())
|
|
573
|
+
var, es = _analytic_var_es(mean, sd, level, self.nu)
|
|
574
|
+
return pd.DataFrame({"VaR": value * var, "ES": value * es}, index=self.model.index)
|
|
575
|
+
|
|
576
|
+
# ------------------------------------------------------------ forecast
|
|
577
|
+
|
|
578
|
+
def _state_forecast_variance(self, horizon):
|
|
579
|
+
m = self.model
|
|
580
|
+
n = len(m.columns)
|
|
581
|
+
var = np.empty((horizon, n))
|
|
582
|
+
for i, p in enumerate(self.uparams):
|
|
583
|
+
mu, omega, alpha, gamma, beta, _ = m.uspec.unpack(p)
|
|
584
|
+
e = self.resid[-1, i]
|
|
585
|
+
s1 = omega + alpha * e * e + beta * self.sigma2[-1, i] + (gamma * e * e if e < 0 else 0.0)
|
|
586
|
+
pers = alpha + 0.5 * gamma + beta
|
|
587
|
+
var[0, i] = s1
|
|
588
|
+
for h in range(1, horizon):
|
|
589
|
+
var[h, i] = omega + pers * var[h - 1, i]
|
|
590
|
+
return var
|
|
591
|
+
|
|
592
|
+
def forecast(self, horizon=1):
|
|
593
|
+
"""Multi-step forecasts from the end of the sample.
|
|
594
|
+
|
|
595
|
+
Variances use the closed-form GJR-GARCH recursion. Correlations use
|
|
596
|
+
the Engle & Sheppard (2001) approximation
|
|
597
|
+
R_{T+h} = (1 - (a+b)^{h-1}) Rbar + (a+b)^{h-1} R_{T+1}, as in rmgarch.
|
|
598
|
+
Returns dict with "mean" (h, N), "variance" (h, N),
|
|
599
|
+
"correlation" (h, N, N) and "covariance" (h, N, N).
|
|
600
|
+
"""
|
|
601
|
+
var = self._state_forecast_variance(horizon)
|
|
602
|
+
mean = np.tile(self.conditional_mean[0], (horizon, 1))
|
|
603
|
+
a, b, _, _ = self.model._unpack_corr(self.corr_params)
|
|
604
|
+
R1 = np.asarray(self._R[-1])
|
|
605
|
+
w = (a + b) ** np.arange(horizon)
|
|
606
|
+
corr = (1 - w)[:, None, None] * self.Rbar + w[:, None, None] * R1
|
|
607
|
+
s = np.sqrt(var)
|
|
608
|
+
cov = corr * s[:, :, None] * s[:, None, :]
|
|
609
|
+
return {"mean": mean, "variance": var, "correlation": corr, "covariance": cov}
|
|
610
|
+
|
|
611
|
+
def simulate(self, nobs, n_paths=1, start="unconditional", burn=500, seed=None):
|
|
612
|
+
"""Simulate return paths from the fitted model.
|
|
613
|
+
|
|
614
|
+
start : "unconditional" (stationary start with `burn` discarded
|
|
615
|
+
draws) or "end" (continue from the last observation, so the
|
|
616
|
+
first simulated period is T+1).
|
|
617
|
+
Returns dict with "returns" and "sigma2", each (n_paths, nobs, N),
|
|
618
|
+
and "correlation" (n_paths, nobs, N, N).
|
|
619
|
+
"""
|
|
620
|
+
rng = np.random.default_rng(seed)
|
|
621
|
+
m = self.model
|
|
622
|
+
n = len(m.columns)
|
|
623
|
+
ups = np.array([m.uspec.unpack(p) for p in self.uparams])
|
|
624
|
+
mu, omega, alpha, gamma, beta, nus = ups.T
|
|
625
|
+
a, b, g, nu = m._unpack_corr(self.corr_params)
|
|
626
|
+
if start == "end":
|
|
627
|
+
Q = np.broadcast_to(np.asarray(self._Q[-1]), (n_paths, n, n)).copy()
|
|
628
|
+
s2 = np.tile(self._state_forecast_variance(1)[0], (n_paths, 1))
|
|
629
|
+
burn = 0
|
|
630
|
+
elif start == "unconditional":
|
|
631
|
+
Q = np.broadcast_to(self.Qbar, (n_paths, n, n)).copy()
|
|
632
|
+
s2 = np.tile(omega / (1 - alpha - 0.5 * gamma - beta), (n_paths, 1))
|
|
633
|
+
else:
|
|
634
|
+
raise ValueError("start must be 'unconditional' or 'end'")
|
|
635
|
+
C = (1 - a - b) * self.Qbar - g * self.Nbar
|
|
636
|
+
out_r = np.empty((n_paths, nobs, n))
|
|
637
|
+
out_s = np.empty((n_paths, nobs, n))
|
|
638
|
+
out_R = np.empty((n_paths, nobs, n, n))
|
|
639
|
+
for t in range(burn + nobs):
|
|
640
|
+
d = 1 / np.sqrt(np.diagonal(Q, axis1=1, axis2=2))
|
|
641
|
+
R = Q * d[:, :, None] * d[:, None, :]
|
|
642
|
+
L = np.linalg.cholesky(R)
|
|
643
|
+
x = np.einsum("pij,pj->pi", L, _draw(rng, n_paths, n, nu))
|
|
644
|
+
z = m._copula_to_margins(x, nu, nus) if m.copula else x
|
|
645
|
+
eps = np.sqrt(s2) * z
|
|
646
|
+
if t >= burn:
|
|
647
|
+
k = t - burn
|
|
648
|
+
out_r[:, k] = mu + eps
|
|
649
|
+
out_s[:, k] = s2
|
|
650
|
+
out_R[:, k] = R
|
|
651
|
+
nx = np.minimum(x, 0.0)
|
|
652
|
+
Q = (C + a * x[:, :, None] * x[:, None, :] + g * nx[:, :, None] * nx[:, None, :]
|
|
653
|
+
+ b * Q)
|
|
654
|
+
s2 = omega + (alpha + gamma * (eps < 0)) * eps ** 2 + beta * s2
|
|
655
|
+
return {"returns": out_r, "sigma2": out_s, "correlation": out_R}
|
|
656
|
+
|
|
657
|
+
def value_at_risk(self, weights, level=0.01, horizon=1, method=None,
|
|
658
|
+
n_sims=100_000, value=1.0, seed=None):
|
|
659
|
+
"""VaR and ES of a portfolio over the next `horizon` periods
|
|
660
|
+
(cumulative return), as positive losses scaled by `value`.
|
|
661
|
+
|
|
662
|
+
method : "analytic" (one period, no copula), "simulation", or None
|
|
663
|
+
to choose analytic when possible.
|
|
664
|
+
"""
|
|
665
|
+
w = np.asarray(weights, float)
|
|
666
|
+
if method is None:
|
|
667
|
+
method = "analytic" if horizon == 1 and self.model.copula is None else "simulation"
|
|
668
|
+
if method == "analytic":
|
|
669
|
+
if horizon != 1 or self.model.copula is not None:
|
|
670
|
+
raise ValueError("analytic VaR needs horizon=1 and no copula")
|
|
671
|
+
f = self.forecast(1)
|
|
672
|
+
mean = f["mean"][0] @ w
|
|
673
|
+
sd = math.sqrt(w @ f["covariance"][0] @ w)
|
|
674
|
+
var, es = _analytic_var_es(mean, sd, level, self.nu)
|
|
675
|
+
else:
|
|
676
|
+
sims = self.simulate(horizon, n_paths=n_sims, start="end", seed=seed)
|
|
677
|
+
pr = (sims["returns"] @ w).sum(axis=1)
|
|
678
|
+
q = np.quantile(pr, level)
|
|
679
|
+
var, es = -q, -pr[pr <= q].mean()
|
|
680
|
+
return {"VaR": float(value * var), "ES": float(value * es)}
|
|
681
|
+
|
|
682
|
+
# ------------------------------------------------------------ new data
|
|
683
|
+
|
|
684
|
+
def apply(self, returns, keep_targets=True, sigma2_0=None):
|
|
685
|
+
"""Filter new (or extended) data with the fitted parameters.
|
|
686
|
+
|
|
687
|
+
keep_targets : keep the estimated Qbar/Nbar (default) instead of
|
|
688
|
+
re-computing them from the new data.
|
|
689
|
+
sigma2_0 : optional first-period variances; default re-initialises
|
|
690
|
+
from the new data with the model's `init` rule.
|
|
691
|
+
"""
|
|
692
|
+
if isinstance(returns, pd.DataFrame):
|
|
693
|
+
m = self.model._clone(returns.to_numpy(float), returns.index)
|
|
694
|
+
else:
|
|
695
|
+
m = self.model._clone(returns)
|
|
696
|
+
return DCCResult(m, self.uparams, self.corr_params, self.cov,
|
|
697
|
+
self.converged, self.targets if keep_targets else None,
|
|
698
|
+
sigma2_0)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _copula_to_margins(self, x, nu, nus):
|
|
702
|
+
"""Map copula draws x to standardised margin shocks z (per column)."""
|
|
703
|
+
u = _std_t_cdf(x, nu) if self.copula == "t" else stats.norm.cdf(x)
|
|
704
|
+
u = np.clip(u, _EPS_U, 1 - _EPS_U)
|
|
705
|
+
z = np.empty_like(x)
|
|
706
|
+
for i, nu_i in enumerate(nus):
|
|
707
|
+
z[:, i] = _std_t_ppf(u[:, i], nu_i) if nu_i > 0 else stats.norm.ppf(u[:, i])
|
|
708
|
+
return z
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
DCC._copula_to_margins = _copula_to_margins
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _draw(rng, n_paths, n, nu):
|
|
715
|
+
"""Uncorrelated unit-variance shocks; multivariate t if nu > 0."""
|
|
716
|
+
x = rng.standard_normal((n_paths, n))
|
|
717
|
+
if nu and nu > 0:
|
|
718
|
+
w = rng.chisquare(nu, size=n_paths) / nu
|
|
719
|
+
x = x / np.sqrt(w)[:, None] * math.sqrt((nu - 2) / nu)
|
|
720
|
+
return x
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _analytic_var_es(mean, sd, level, nu=None):
|
|
724
|
+
"""VaR/ES (positive losses) of mean + sd * Z, Z normal or unit-variance t."""
|
|
725
|
+
if not nu:
|
|
726
|
+
q = stats.norm.ppf(level)
|
|
727
|
+
return -(mean + sd * q), -(mean - sd * stats.norm.pdf(q) / level)
|
|
728
|
+
k = math.sqrt((nu - 2) / nu)
|
|
729
|
+
q = stats.t.ppf(level, nu)
|
|
730
|
+
es_std = (nu + q * q) / (nu - 1) * stats.t.pdf(q, nu) / level
|
|
731
|
+
return -(mean + sd * k * q), -(mean - sd * k * es_std)
|