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

  1. 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}}$.
  2. 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$.

In [1]:
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)
No description has been provided for this image

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.