The portfolio horse-race¶

Multivariate volatility, part 4c: does a better covariance model build a better portfolio?¶

Every model in this project estimates the same object — the conditional covariance matrix $H_t$. The only test that ultimately matters is a decision: does a richer $H_t$ let you build a portfolio with lower risk? This is Aguilar & West's (2000) original motivation, and the capstone of the whole arc. We run the classic experiment: the global-minimum-variance (GMV) portfolio, $$w_t = \frac{H_t^{-1}\mathbf 1}{\mathbf 1'H_t^{-1}\mathbf 1},$$ formed each day from the previous day's covariance estimate and held for one day, and we compare the realized annualized volatility of the resulting portfolio across every covariance model we have built.

The contestants (on the five sector ETFs, 2004–2024):

  • equal-weight — the no-model benchmark ($w=\mathbf 1/N$);
  • sample covariance — GMV with a single static $H$;
  • CCC — dynamic vols, constant correlation;
  • DCC — dynamic correlation (frequentist workhorse);
  • factor SV (Bayesian) — the posterior-mean covariance from factorsv_numpyro.

Method and a fairness note¶

At each $t$ we solve the GMV weights from $H_{t-1}$ (so the weights use only information available before $r_t$) and record the portfolio return $w_{t-1}'r_t$; the score is the annualized standard deviation of that return series — lower is better, since GMV's sole objective is risk.

One honest caveat. The DCC covariance is filtered (each $H_t$ uses only past returns), whereas the factor-SV covariance is the smoothed posterior mean (each $H_t$ conditions on the whole sample). So the factor-SV number carries a mild in-sample information advantage and should be read as an upper bound on its edge. What is not sensitive to this — the large gaps from equal-weight to static to dynamic — is where the robust lesson lives.

In [1]:
import numpy as np, pandas as pd, matplotlib.pyplot as plt
import ccc as C, dcc as D

df = pd.read_csv("etf_returns.csv", parse_dates=["date"], index_col="date")
names = list(df.columns); dates = df.index; R = df.values; T, N = R.shape
Rd = R - R.mean(0); one = np.ones(N)

def gmv(H):                                                # unconstrained global-minimum-variance weights
    x = np.linalg.solve(H, one); return x / x.sum()

cf = C.ccc_fit(Rd)                                         # CCC on the 5 ETFs
Dc = np.sqrt(cf["H"]); Hccc = Dc[:, :, None] * Dc[:, None, :] * cf["Rbar"][None]
dfit = D.dcc_fit(Rd)                                       # DCC on the 5 ETFs
Dd = np.sqrt(dfit["H"]); Hdcc = Dd[:, :, None] * Dd[:, None, :] * dfit["Rt"]
Hfsv = np.load("fsv_Hbar.npy")                             # Bayesian factor SV (posterior mean)
Hsamp = np.cov(Rd, rowvar=False)

def prseries(H, kind=None):
    pr = np.empty(T - 1)
    for t in range(1, T):
        w = one / N if kind == "eq" else (gmv(Hsamp) if kind == "samp" else gmv(H[t - 1]))
        pr[t - 1] = w @ R[t]
    return pr

models = {"equal-weight": ("eq", None), "sample cov (static)": ("samp", None),
          "CCC": (None, Hccc), "DCC": (None, Hdcc), "factor SV (Bayes)": (None, Hfsv)}
res = {}
print("GMV portfolios (weights from H_{t-1} applied to r_t):")
for nm, (kind, H) in models.items():
    res[nm] = prseries(H, kind).std() * np.sqrt(252)
    print("  %-22s realized ann. vol %.2f%%" % (nm, res[nm]))
print("\nlowest-variance portfolio: %s (%.2f%%)" % (min(res, key=res.get), min(res.values())))
print("factor-SV %.2f%%  vs  DCC %.2f%%  -> factor-SV %.1f%% lower"
      % (res["factor SV (Bayes)"], res["DCC"], (res["DCC"] - res["factor SV (Bayes)"]) / res["DCC"] * 100))

fig, ax = plt.subplots(1, 2, figsize=(13, 4.4), gridspec_kw=dict(width_ratios=[1, 1.3]))
order = ["equal-weight", "sample cov (static)", "CCC", "DCC", "factor SV (Bayes)"]
ax[0].bar(range(5), [res[m] for m in order], color=["0.6", "0.45", "steelblue", "darkorange", "firebrick"])
ax[0].set_xticks(range(5)); ax[0].set_xticklabels(order, rotation=25, ha="right", fontsize=8)
ax[0].set_ylabel("realized annualized vol (%)"); ax[0].set_ylim(min(res.values()) * .92, max(res.values()) * 1.02)
ax[0].set_title("Min-variance portfolio: realized risk\n(lower = better covariance model)")
for nm, H, col in [("equal-weight", None, "0.6"), ("DCC", Hdcc, "darkorange"), ("factor SV (Bayes)", Hfsv, "firebrick")]:
    pr = prseries(H, "eq" if nm == "equal-weight" else None)
    ax[1].plot(dates[1:], pd.Series(pr, index=dates[1:]).rolling(252).std() * np.sqrt(252), color=col, lw=.9, label=nm)
ax[1].set_ylabel("rolling 1y portfolio vol (%)"); ax[1].legend(fontsize=8)
ax[1].set_title("Realized portfolio risk over time\n(dynamic covariance cuts risk, esp. in crises)")
for l in ax[1].get_xticklabels(): l.set_rotation(20)
plt.tight_layout(); plt.show()
GMV portfolios (weights from H_{t-1} applied to r_t):
  equal-weight           realized ann. vol 19.06%
  sample cov (static)    realized ann. vol 15.23%
  CCC                    realized ann. vol 15.17%
  DCC                    realized ann. vol 14.54%
  factor SV (Bayes)      realized ann. vol 13.81%

lowest-variance portfolio: factor SV (Bayes) (13.81%)
factor-SV 13.81%  vs  DCC 14.54%  -> factor-SV 5.1% lower
No description has been provided for this image

Reading the figure¶

  • Left — the risk ladder is the whole project. Realized GMV volatility falls monotonically as the covariance model improves: equal-weight 19.1% → static 15.2% → CCC 15.2% → DCC 14.5% → factor SV 13.8%. Modelling any covariance structure buys the big first drop (19% → 15%); modelling its dynamics (DCC, factor SV) buys the rest.
  • Right — the gains concentrate in crises. The rolling one-year portfolio volatility shows the naive equal-weight book blowing out to ~47% in 2008 and ~39% in 2020, while the dynamic-covariance portfolios hold those peaks down to ~29–30%. A model that knows correlations spike in a crash rebalances away from the risk exactly when it matters — the practical cash value of everything in this project.
  • Factor SV edges DCC (13.8% vs 14.5%), consistent across the sample and clearest at the crisis peaks — though, per the caveat, part of that gap is its smoothed-covariance information advantage.

Results — and the close of the multivariate-volatility arc¶

  • A better covariance is worth real risk reduction. Going from no model to a dynamic covariance cut realized portfolio volatility by ~28% (19.1% → 13.8%), most of it harvested exactly in the 2008 and 2020 crises. This is why multivariate volatility modelling exists.
  • The four models, in their place. CCC gave the decomposition; DCC made the correlation move and scales to large $N$; BEKK guaranteed a positive-definite $H_t$ for small systems; and factor SV unified the virtues — Bayesian, positive-definite, scalable, and here the lowest-risk portfolio — by routing co-movement through a latent common-volatility factor.
  • The through-line of the whole volatility arc. From a single GARCH, through stochastic volatility, realized volatility, the long-memory-vs-regimes persistence debate, and now the cross-asset covariance, one lesson recurs: volatility (and co-volatility) is persistent, time-varying, and rises together in crises — and modelling that structure, rather than assuming it away, is what turns a covariance matrix into a better decision.

References¶

  • Aguilar, O. & West, M. (2000). Bayesian dynamic factor models and portfolio allocation. JBES 18, 338–357.
  • Engle, R. F. (2002). Dynamic conditional correlation. JBES 20, 339–350.
  • Bauwens, L., Laurent, S. & Rombouts, J. V. K. (2006). Multivariate GARCH models: a survey. J. Applied Econometrics 21, 79–109.