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/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)