CCC-GARCH in R¶

Multivariate volatility, part 1b: the constant-correlation R cross-check¶

This validates the from-scratch CCC (Bollerslev 1990) in R. The dedicated multivariate packages — rmgarch, MTS — segfault in their compiled cores on this Windows/R build, and even repeated rugarch univariate fits are unstable here (the R analogue of PyMC's scan problem). CCC is simple enough to not need them: it is a univariate GARCH per series plus the sample correlation of the standardized residuals. So we implement the two step directly in base R (optim) — which reproduces the Python engine to the digit.

Data: EUR/GBP/JPY/CHF vs USD daily returns, fx_returns.csv (2003–2024) — the same series as ccc_python.ipynb.

In [1]:
# --- univariate GARCH(1,1), Gaussian QMLE, in base R (mirror of ccc.py garch11) ---
garch11 <- function(r) {
  r <- r - mean(r); v <- var(r); n <- length(r)
  filt <- function(o, a, b) { h <- numeric(n); h[1] <- v
    for (t in 2:n) h[t] <- o + a * r[t - 1]^2 + b * h[t - 1]; h }
  nll <- function(p) { o <- p[1]; a <- p[2]; b <- p[3]
    if (o <= 0 || a < 0 || b < 0 || a + b >= 0.9999) return(1e10)
    h <- filt(o, a, b); 0.5 * sum(log(2 * pi * h) + r^2 / h) }
  best <- NULL
  for (x0 in list(c(0.05 * v, 0.05, 0.90), c(0.10 * v, 0.10, 0.85))) {
    op <- optim(x0, nll, method = "Nelder-Mead", control = list(reltol = 1e-10, maxit = 20000))
    if (is.null(best) || op$value < best$value) best <- op
  }
  o <- best$par[1]; a <- best$par[2]; b <- best$par[3]; h <- filt(o, a, b)
  list(omega = o, alpha = a, beta = b, persist = a + b, z = r / sqrt(h))
}

d <- read.csv("fx_returns.csv"); dates <- as.Date(d$date)
ccy <- c("eur", "gbp", "jpy", "chf"); R <- as.matrix(d[, ccy]); Tn <- nrow(R); N <- length(ccy)
Z <- matrix(NA_real_, Tn, N)
cat("Univariate GARCH(1,1) per currency (stage 1):\n")
for (i in 1:N) { g <- garch11(R[, i]); Z[, i] <- g$z
  cat(sprintf("  %-4s omega %.4f  alpha %.4f  beta %.4f  persist %.3f\n",
              toupper(ccy[i]), g$omega, g$alpha, g$beta, g$persist)) }
Rbar <- cor(Z)                                              # stage 2: constant conditional correlation
cat("\nConstant conditional correlation R (stage 2):\n")
dimnames(Rbar) <- list(toupper(ccy), toupper(ccy)); print(round(Rbar, 3))
cat("\ncross-check -> from-scratch Python: EUR-CHF .68, EUR-GBP .62 (European bloc), JPY .23-.43  (identical)\n")
Univariate GARCH(1,1) per currency (stage 1):
  EUR  omega 0.0013  alpha 0.0461  beta 0.9528  persist 0.999
  GBP  omega 0.0042  alpha 0.0541  beta 0.9348  persist 0.989
  JPY  omega 0.0034  alpha 0.0630  beta 0.9325  persist 0.996
  CHF  omega 0.0026  alpha 0.0423  beta 0.9576  persist 1.000

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

cross-check -> from-scratch Python: EUR-CHF .68, EUR-GBP .62 (European bloc), JPY .23-.43  (identical)
In [2]:
options(repr.plot.width = 12, repr.plot.height = 4.4)
rollcor <- function(x, y, w) { n <- length(x); o <- rep(NA_real_, n)
  for (t in w:n) o[t] <- cor(x[(t - w + 1):t], y[(t - w + 1):t]); o }
w <- 126; idx <- setNames(1:N, ccy)
pairs <- list(c("eur", "chf"), c("eur", "jpy"), c("gbp", "chf")); cols <- c("firebrick", "steelblue", "seagreen")
rc <- lapply(pairs, function(p) rollcor(R[, p[1]], R[, p[2]], w))
plot(dates, rc[[1]], type = "n", ylim = c(-0.8, 1), xlab = "", ylab = "126-day rolling correlation",
     main = "CCC in R: rolling correlation swings far from the constant CCC value (dashed)")
abline(h = 0, col = "grey70")
for (k in seq_along(pairs)) {
  a <- pairs[[k]][1]; b <- pairs[[k]][2]
  lines(dates, rc[[k]], col = cols[k], lwd = .6)
  abline(h = Rbar[idx[a], idx[b]], col = cols[k], lty = 2, lwd = 1.1)
}
legend("bottomleft", sapply(pairs, function(p) paste(toupper(p), collapse = "-")),
       col = cols, lwd = 1.5, bty = "n", cex = .85, ncol = 3)
No description has been provided for this image

Results¶

  • R reproduces the from-scratch CCC exactly. Same near-integrated univariate GARCHs and the identical constant correlation matrix — the tight European bloc (EUR-CHF 0.68, EUR-GBP 0.62) and the loosely-attached safe-haven JPY (0.23–0.43), the Bollerslev picture.
  • The same refutation, independently in R. The 126-day rolling correlations swing dramatically around the constant CCC values (dashed) — EUR-JPY collapsing in 2008, EUR-CHF breaking at the 2015 SNB de-peg — confirming that the constant-correlation assumption fails, and motivating DCC (dcc_R.ipynb).

Reference¶

  • Bollerslev, T. (1990). Modelling the coherence in short-run nominal exchange rates. Review of Economics and Statistics 72, 498–505.