CCC-GARCH — the constant-correlation baseline (from scratch)¶
Multivariate volatility, part 1: Bollerslev (1990)¶
This opens the cross-asset branch of the volatility arc. Univariate GARCH/SV, realized volatility, and the persistence pair all modelled one series' variance. Now the object is a vector of returns $r_t\in\mathbb{R}^N$ and its conditional covariance matrix $H_t=\text{Var}(r_t\mid\mathcal{F}_{t-1})$ — an $N\times N$ matrix that must stay positive-definite at every $t$, with $N(N{+}1)/2$ distinct elements. Two problems immediately bite: keeping $H_t$ positive-definite, and the curse of dimensionality.
Bollerslev's (1990) CCC is the elegant first answer. Split the covariance into volatilities and a correlation, $$H_t = D_t\,R\,D_t,\qquad D_t=\text{diag}\!\big(\sqrt{h_{1t}},\dots,\sqrt{h_{Nt}}\big),\qquad R=\text{constant correlation matrix},$$ so each asset carries its own univariate GARCH for $h_{it}$, and a single constant $R$ ties them together. Positive-definiteness is automatic (any $h_{it}>0$ and any valid correlation $R$), and the parameter count collapses. The price is the name: the conditional correlation is assumed constant. This notebook fits CCC from scratch and then asks whether that assumption survives contact with the data — the question the entire rest of the project answers.
Data. Four major exchange rates against the US dollar — the euro (EUR), British pound (GBP), Japanese yen (JPY) and Swiss franc (CHF) — downloaded from Yahoo Finance (tickers EURUSD=X, GBPUSD=X, JPYUSD=X, CHFUSD=X) and stored in fx_returns.csv. Each price is quoted as US dollars per unit of foreign currency, so a positive return is a foreign-currency appreciation against the dollar. We model daily log returns in percent,
$$r_{it} = 100\times\ln\!\big(P_{it}/P_{i,t-1}\big),$$
aligned to common trading days: 5,464 observations, 2 Dec 2003 – 30 Dec 2024. Sample daily volatilities are similar across the four (~0.6–0.7%). The choice deliberately mirrors Bollerslev's (1990) original European-exchange-rate setting — we expect a tightly co-moving European bloc (EUR/GBP/CHF) and the safe-haven JPY standing apart.
The model and the two-step estimator¶
CCC is estimated in two steps (Bollerslev 1990; also stage 1 of Engle's DCC):
- Univariate GARCH per series. For each asset $i$ fit a GARCH(1,1), $$h_{it}=\omega_i+\alpha_i\,\varepsilon_{i,t-1}^2+\beta_i\,h_{i,t-1},\qquad \varepsilon_{it}=r_{it}-\mu_i,$$ by Gaussian QMLE, giving conditional variances $h_{it}$ and standardized residuals $z_{it}=\varepsilon_{it}/\sqrt{h_{it}}$.
- Constant correlation. With the volatility filtered out, $z_t$ is (under CCC) i.i.d. with correlation $R$, so estimate $R$ as the sample correlation of the standardized residuals $z$.
The Gaussian log-likelihood uses $H_t=D_tRD_t$, and simplifies neatly because $\log|H_t|=2\sum_i\log\sqrt{h_{it}}+\log|R|$ and $r_t'H_t^{-1}r_t=z_t'R^{-1}z_t$: $$\ell=-\tfrac12\sum_t\Big[N\log 2\pi+2\textstyle\sum_i\log\sqrt{h_{it}}+\log|R|+z_t'R^{-1}z_t\Big].$$
All of this is in ccc.py (garch11, ccc_fit, ccc_loglik), plus rolling_corr to probe the constancy of $R$.
import numpy as np, pandas as pd, matplotlib.pyplot as plt
import ccc as C
df = pd.read_csv("fx_returns.csv", parse_dates=["date"], index_col="date") # EUR/GBP/JPY/CHF vs USD, 2003-2024
names = list(df.columns); dates = df.index; R = df.values
fit = C.ccc_fit(R)
k = 3 * len(names) + len(names) * (len(names) - 1) // 2 # 3N GARCH params + N(N-1)/2 correlations
aic = -2 * fit["loglik"] + 2 * k
print("Univariate GARCH(1,1) per currency (stage 1):")
print(" %-5s %8s %8s %8s %10s %10s" % ("ccy", "omega", "alpha", "beta", "persist", "vol%"))
for nm, f in zip(names, fit["fits"]):
print(" %-5s %8.4f %8.4f %8.4f %10.3f %10.3f"
% (nm.upper(), f["omega"], f["alpha"], f["beta"], f["persistence"], np.sqrt(f["h"].mean())))
Rbar = fit["Rbar"]
print("\nConstant conditional correlation R (stage 2):")
print(" " + "".join("%7s" % n.upper() for n in names))
for i, n in enumerate(names):
print(" %-5s" % n.upper() + "".join("%7.3f" % Rbar[i, j] for j in range(len(names))))
print("\nCCC Gaussian loglik %.1f AIC %.0f (%d params)" % (fit["loglik"], aic, k))
# --- the refutation: rolling correlation vs the constant CCC value ---
pairs = [("eur", "chf"), ("eur", "jpy"), ("gbp", "chf")]; cols = {"eur":0,"gbp":1,"jpy":2,"chf":3}; win = 126
rc = {}
print("\nRolling %d-day correlation vs the constant CCC value:" % win)
for a, b in pairs:
r = C.rolling_corr(df[a].values, df[b].values, win); rc[(a, b)] = r
print(" %s-%s: constant %.2f but rolling ranges [%.2f, %.2f] (spread %.2f)"
% (a.upper(), b.upper(), Rbar[cols[a], cols[b]], np.nanmin(r), np.nanmax(r), np.nanmax(r) - np.nanmin(r)))
fig, ax = plt.subplots(1, 2, figsize=(14, 4.6), gridspec_kw=dict(width_ratios=[2.1, 1]))
coln = {("eur","chf"):"firebrick", ("eur","jpy"):"steelblue", ("gbp","chf"):"seagreen"}
for (a, b), col in coln.items():
ax[0].plot(dates, rc[(a, b)], color=col, lw=.7, label="%s-%s" % (a.upper(), b.upper()))
ax[0].axhline(Rbar[cols[a], cols[b]], color=col, ls="--", lw=1.2, alpha=.9)
ax[0].axhline(0, color="0.6", lw=.6); ax[0].set_ylim(-.8, 1); ax[0].set_ylabel("126-day rolling correlation")
ax[0].legend(fontsize=8, ncol=3, loc="lower left")
ax[0].set_title("Rolling correlation swings far from the constant CCC value (dashed)\n"
"-> conditional correlation is NOT constant (CCC is refuted)")
for lab in ax[0].get_xticklabels(): lab.set_rotation(20)
im = ax[1].imshow(Rbar, cmap="RdBu_r", vmin=-1, vmax=1)
ax[1].set_xticks(range(len(names))); ax[1].set_yticks(range(len(names)))
ax[1].set_xticklabels([n.upper() for n in names]); ax[1].set_yticklabels([n.upper() for n in names])
for i in range(len(names)):
for j in range(len(names)):
ax[1].text(j, i, "%.2f" % Rbar[i, j], ha="center", va="center",
color="white" if abs(Rbar[i, j]) > .5 else "black", fontsize=9)
ax[1].set_title("The CCC estimate:\none constant R (European bloc vs JPY)")
fig.colorbar(im, ax=ax[1], fraction=.046, pad=.04)
plt.tight_layout(); plt.show()
Univariate GARCH(1,1) per currency (stage 1):
ccy omega alpha beta persist vol%
EUR 0.0013 0.0461 0.9528 0.999 0.716
GBP 0.0042 0.0541 0.9348 0.989 0.599
JPY 0.0034 0.0630 0.9325 0.996 0.741
CHF 0.0026 0.0423 0.9576 1.000 0.711
Constant conditional correlation R (stage 2):
EUR GBP JPY CHF
EUR 1.000 0.624 0.303 0.678
GBP 0.624 1.000 0.234 0.483
JPY 0.303 0.234 1.000 0.426
CHF 0.678 0.483 0.426 1.000
CCC Gaussian loglik -15321.5 AIC 30679 (18 params)
Rolling 126-day correlation vs the constant CCC value:
EUR-CHF: constant 0.68 but rolling ranges [0.07, 0.99] (spread 0.92)
EUR-JPY: constant 0.30 but rolling ranges [-0.84, 0.74] (spread 1.58)
GBP-CHF: constant 0.48 but rolling ranges [0.05, 0.89] (spread 0.84)
Reading the figure¶
- Right — the CCC estimate. After GARCH-filtering the volatility, the constant correlation $R$ recovers exactly Bollerslev's stylised picture: a tight European bloc — EUR-CHF 0.68, EUR-GBP 0.62 — and the safe-haven JPY loosely attached (0.23–0.43). (Note these are higher than the raw-return correlations: filtering out the common volatility bursts sharpens the underlying co-movement.) All four univariate GARCHs are near-integrated ($\alpha+\beta\approx0.99$–1.00) — the familiar volatility persistence, now per currency.
- Left — why the single number is not enough. The 126-day rolling correlations (solid) wander enormously around the constant CCC values (dashed). EUR-JPY ranges from $-0.84$ in the 2008 crisis to $+0.74$ — a 1.58 spread around a "constant" 0.30. EUR-CHF collapses at the January-2015 SNB de-pegging and again in 2008. Correlation clearly moves with the market regime — exactly the time-variation CCC rules out by assumption.
Results — CCC is the right decomposition but the wrong constraint¶
- The decomposition is the keeper. Splitting $H_t=D_tRD_t$ into univariate vols and a correlation is clean, keeps $H_t$ positive-definite for free, and scales — every model that follows (DCC, and even the factor-SV capstone) inherits this volatility/correlation split.
- "Constant" is the flaw. The data emphatically reject a constant $R$: correlations rise in crises and fall in calm ("diversification meltdown"). CCC cannot capture the single most important feature of a covariance matrix for risk — that diversification evaporates exactly when you need it.
- The fix defines Part 2. Let $R$ become time-varying, $R_t$, driven by its own GARCH-like recursion — Engle's (2002) Dynamic Conditional Correlation. CCC is precisely DCC's null hypothesis ($a=b=0$), so the next notebook nests this one and tests it formally.
References¶
- Bollerslev, T. (1990). Modelling the coherence in short-run nominal exchange rates: a multivariate generalized ARCH model. Review of Economics and Statistics 72, 498–505.
- Engle, R. F. (2002). Dynamic conditional correlation. J. Business & Economic Statistics 20, 339–350.
Next: dcc_python.ipynb — the dynamic conditional correlation model (the workhorse), from scratch.