Copula-GARCH: Time-Varying Volatility Meets Dependence¶
The capstone of the Risk and Asset Allocation arc — from scratch¶
"Markets have two moods that a covariance matrix cannot tell apart: sometimes everything is turbulent at once, and sometimes everything moves together. Copula-GARCH separates them."
Self-contained; no prior reading of Meucci's Risk and Asset Allocation (Springer 2005) required.
The problem this solves¶
Two earlier notebooks left threads dangling that meet here:
- The invariance notebook found that asset returns are only approximately i.i.d. — they cluster in volatility — so treating them as fixed-distribution invariants is a compromise.
- The coherent-risk notebook found that a static Value-at-Risk fails its independence backtest: violations bunch together in crises, because a constant risk number cannot rise when markets turn turbulent.
Both symptoms have one cause — volatility clustering — and one cure: model each asset's time-varying volatility with GARCH, and model the dependence of what is left over with a copula. That is copula-GARCH, and it is built from two engines already in this collection (garch_scratch and copulas).
The recipe:
- GARCH marginals. Fit a GARCH(1,1)-$t$ to each asset $\Rightarrow$ conditional volatility $\sigma_{j,t}$ and standardized residual $z_{j,t}=(r_{j,t}-\mu_j)/\sigma_{j,t}$. GARCH strips out the clustering, so the residuals are genuinely i.i.d. — the invariants, at last.
- Copula on the residuals. Fit a copula to the residuals' dependence — cleaner than the raw-return dependence, which was contaminated by common volatility swings.
- Re-assemble $r_{j,t}=\mu_j+\sigma_{j,t}z_{j,t}$ with $z$ drawn from the copula $\Rightarrow$ a portfolio whose risk breathes: a one-day VaR that climbs in turbulence and passes the backtest a static VaR failed.
Roadmap¶
- GARCH marginals and the disappearance of volatility clustering.
- The copula on residuals vs on raw returns.
- Dynamic VaR that tracks the market's mood.
- The backtest the static model failed, now passed.
import numpy as np, pandas as pd, matplotlib.pyplot as plt
import copulagarch as cg, copulas as cp, riskmeasures as rm
plt.rcParams.update({"figure.figsize": (9,4.5), "axes.grid": True, "grid.alpha": .25,
"axes.spines.top": False, "axes.spines.right": False, "font.size": 11})
BLUE, ORANGE, GREEN, RED, GREY, PURP = "#2b6cb0","#dd6b20","#2f855a","#c53030","#718096","#6b46c1"
np.set_printoptions(precision=3, suppress=True)
R = pd.read_csv("crossasset_daily.csv", index_col=0, parse_dates=True)
ASSETS = list(R.columns); X = R.values; dates = R.index; T, N = X.shape
print("Daily cross-asset panel:", X.shape, "->", ASSETS)
Daily cross-asset panel: (3772, 5) -> ['SPY', 'TLT', 'GLD', 'HYG', 'EEM']
The dataset¶
Daily log-returns (%) of five cross-asset ETFs, 2010–2024 (3,772 days): SPY (equity), TLT (Treasuries), GLD (gold), HYG (credit), EEM (EM equity). The sample spans two violent volatility regimes — the 2020 COVID crash and the 2022 rate shock — which is exactly what a dynamic risk model must handle.
1. GARCH marginals: the volatility that clusters¶
We fit a GARCH(1,1) with Student-$t$ innovations to each asset. GARCH models today's variance as a blend of yesterday's shock and yesterday's variance, $\sigma_t^2=\omega+\alpha r_{t-1}^2+\beta\sigma_{t-1}^2$, so it captures the way turbulence persists. The persistence $\alpha+\beta$ (near 1) measures how long volatility shocks last; the fat-tailed $\nu$ captures jumps. The acid test: the standardized residuals $z_t=r_t/\sigma_t$ should have no remaining volatility clustering — a Ljung–Box test on their squares should go quiet.
marg = cg.fit_all(X)
def lb_sq(x, lags=20):
from scipy.stats import chi2
x2 = x**2 - np.mean(x**2); n = len(x); d = np.dot(x2, x2)
ac = [np.dot(x2[:-k], x2[k:])/d for k in range(1, lags+1)]
Q = n*(n+2)*sum(a**2/(n-k) for k,a in zip(range(1,lags+1),ac)); return Q, 1-chi2.cdf(Q,lags)
print("GARCH(1,1)-t fits, and volatility clustering before vs after (Ljung-Box on squares):\n")
print("%-5s %7s %7s %7s %6s %8s %14s -> %14s" % ("asset","omega","alpha","beta","nu","persist","LB(ret^2)","LB(resid^2)"))
pz_all = {}
for a, m in zip(ASSETS, marg):
qr,_ = lb_sq(X[:,ASSETS.index(a)]); qz,pz = lb_sq(m["z"]); pz_all[a] = pz
print("%-5s %7.3f %7.3f %7.3f %6.1f %8.3f Q=%-9.0f -> Q=%-6.0f (p=%.2f)" %
(a, m["theta"][0], m["theta"][1], m["theta"][2], m["nu"], m["persistence"], qr, qz, pz))
clean = [a for a,p in pz_all.items() if p > 0.10]; dirty = [a for a,p in pz_all.items() if p <= 0.10]
print("\nRaw returns show massive volatility clustering -- Q in the thousands against a chi-square(20)")
print("critical value of about 31. After GARCH the clustering is gone for %s" % ", ".join(clean))
print("(p = %s)." % ", ".join("%.2f" % pz_all[a] for a in clean))
print("It is NOT gone for %s (p = %s): a single GARCH(1,1)-t does not fully whiten"
% (" and ".join(dirty), ", ".join("%.2f" % pz_all[a] for a in dirty)))
print("every series, and EEM's residual squares in particular remain autocorrelated. The residuals are")
print("close to the i.i.d. invariants the copula step assumes -- close, and worth stating as close.")
hyg = marg[ASSETS.index("HYG")]["persistence"]
print("\nOne more thing the table shows: HYG's persistence alpha+beta = %.4f, pinned against the" % hyg)
print("boundary the optimiser enforces (alpha+beta < 1 strictly). Its variance process is effectively")
print("INTEGRATED -- the unconditional variance does not exist and its long-horizon volatility forecast")
print("does not mean-revert. Fine for the one-day-ahead VaR built below, which never needs either.")
GARCH(1,1)-t fits, and volatility clustering before vs after (Ljung-Box on squares): asset omega alpha beta nu persist LB(ret^2) -> LB(resid^2) SPY 0.024 0.163 0.828 5.7 0.990 Q=5427 -> Q=15 (p=0.77) TLT 0.012 0.061 0.925 16.5 0.986 Q=2806 -> Q=14 (p=0.82) GLD 0.011 0.041 0.948 4.9 0.989 Q=305 -> Q=24 (p=0.24) HYG 0.003 0.160 0.840 5.8 1.000 Q=4187 -> Q=30 (p=0.07) EEM 0.052 0.094 0.876 9.7 0.970 Q=2696 -> Q=42 (p=0.00) Raw returns show massive volatility clustering -- Q in the thousands against a chi-square(20) critical value of about 31. After GARCH the clustering is gone for SPY, TLT, GLD (p = 0.77, 0.82, 0.24). It is NOT gone for HYG and EEM (p = 0.07, 0.00): a single GARCH(1,1)-t does not fully whiten every series, and EEM's residual squares in particular remain autocorrelated. The residuals are close to the i.i.d. invariants the copula step assumes -- close, and worth stating as close. One more thing the table shows: HYG's persistence alpha+beta = 1.0000, pinned against the boundary the optimiser enforces (alpha+beta < 1 strictly). Its variance process is effectively INTEGRATED -- the unconditional variance does not exist and its long-horizon volatility forecast does not mean-revert. Fine for the one-day-ahead VaR built below, which never needs either.
# The conditional volatility breathes: calm and crisis regimes
fig, ax = plt.subplots(figsize=(12, 4.6))
for a, c in zip(["SPY","HYG","TLT"], [BLUE, RED, GREEN]):
m = marg[ASSETS.index(a)]
ax.plot(dates, m["sigma"]*np.sqrt(252), color=c, lw=.8, label="%s conditional vol" % a)
ax.set_ylabel("annualised volatility (%)"); ax.set_title("GARCH conditional volatility spikes in every crisis")
ax.legend(); plt.tight_layout(); plt.show()
print("Volatility is not constant: it explodes in March 2020 (COVID) and through 2022 (rate shock).")
print("A static risk model, blind to this, is doomed to under-warn in storms and over-warn in calm.")
Volatility is not constant: it explodes in March 2020 (COVID) and through 2022 (rate shock). A static risk model, blind to this, is doomed to under-warn in storms and over-warn in calm.
2. The copula on residuals vs on raw returns¶
Now the dependence. We fit a Student-$t$ copula to the standardized residuals and compare it to the same copula fit to the raw returns. The subtle, important finding: some of what looks like tail dependence in raw returns is really just common volatility — when the whole market is turbulent, everything is large at once, inflating apparent co-movement. Conditioning on each asset's own volatility (via GARCH) strips that away, leaving the genuine residual dependence — usually weaker, and the honest input for risk.
Z = cg.std_resid_matrix(marg)
Ur, Uz = cp.pseudo_obs(X), cp.pseudo_obs(Z)
Rt_raw, nu_raw = cp.fit_t_copula(Ur); Rt_z, nu_z = cp.fit_t_copula(Uz)
i, j = ASSETS.index("SPY"), ASSETS.index("HYG")
fig, ax = plt.subplots(1, 2, figsize=(12, 4.6))
ax[0].scatter(Ur[:,i], Ur[:,j], s=5, alpha=.3, color=GREY); ax[0].set_title("Copula of RAW returns (SPY-HYG)")
ax[1].scatter(Uz[:,i], Uz[:,j], s=5, alpha=.3, color=BLUE); ax[1].set_title("Copula of GARCH RESIDUALS (SPY-HYG)")
for a in ax: a.set_xlabel("SPY rank"); a.set_xticks([0,1]); a.set_yticks([0,1])
ax[0].set_ylabel("HYG rank"); plt.tight_layout(); plt.show()
print("Student-t copula dof: raw returns nu=%.0f residuals nu=%.0f (higher nu = thinner joint tails)" % (nu_raw, nu_z))
print("SPY-HYG lower-tail dependence: raw %.2f -> residuals %.2f" %
(cp.tail_dep_t(Rt_raw[i,j], nu_raw), cp.tail_dep_t(Rt_z[i,j], nu_z)))
print("The residual tail dependence is LOWER: part of the raw joint-crash signal was shared volatility,")
print("not true dependence. Copula-GARCH attributes each effect to its proper source.")
Student-t copula dof: raw returns nu=5 residuals nu=10 (higher nu = thinner joint tails) SPY-HYG lower-tail dependence: raw 0.38 -> residuals 0.22 The residual tail dependence is LOWER: part of the raw joint-crash signal was shared volatility, not true dependence. Copula-GARCH attributes each effect to its proper source.
The one relationship the whole arc assumed was a constant¶
The residual correlation matrix contains a number worth stopping on. SPY–TLT — equities against long Treasuries — is the diversification relationship a multi-asset portfolio leans on hardest, and it is the one pair whose sign is not stable.
Two questions, in order. Does the volatility decomposition explain the equity–bond relationship? And is that relationship even the kind of thing a covariance matrix can hold?
from scipy.stats import norm
b = ASSETS.index("TLT"); sp = ASSETS.index("SPY")
Rg_raw, Rg_z = cp.fit_gaussian_copula(Ur), cp.fit_gaussian_copula(Uz)
print("Q1. Does GARCH explain it? SPY-TLT dependence, raw returns vs GARCH residuals:")
print(" raw %.3f -> residuals %.3f (change %+.3f)" % (Rg_raw[sp,b], Rg_z[sp,b], Rg_z[sp,b]-Rg_raw[sp,b]))
print(" No. Unlike the equity-credit pair, where stripping volatility cut tail dependence almost in")
print(" half, here the decomposition barely moves the number. The negative equity-bond relationship")
print(" is NOT a shared-volatility artefact -- it is in the raw returns and it survives intact.\n")
# Q2: is it stable? rolling 500-day correlation of the residual normal scores
Zs = norm.ppf(np.clip(Uz, 1e-6, 1-1e-6))
s_ = pd.Series(Zs[:,sp], index=dates); t_ = pd.Series(Zs[:,b], index=dates)
roll = s_.rolling(500).corr(t_).dropna()
roll_raw = pd.Series(X[:,sp], index=dates).rolling(500).corr(pd.Series(X[:,b], index=dates)).dropna()
fig, ax = plt.subplots(figsize=(12, 4.4))
ax.plot(roll.index, roll.values, color=BLUE, lw=1.4, label="GARCH residuals")
ax.plot(roll_raw.index, roll_raw.values, color=GREY, lw=1.0, alpha=.8, label="raw returns")
ax.axhline(0, color="k", lw=1.2, ls="--")
ax.fill_between(roll.index, 0, roll.values, where=roll.values>0, color=RED, alpha=.25)
ax.set_ylabel("500-day correlation"); ax.set_title("SPY vs TLT: the diversification benefit that changed sign")
ax.legend(); plt.tight_layout(); plt.show()
ann = roll.groupby(roll.index.year).mean()
first_pos = roll[roll > 0].index[0] if (roll > 0).any() else None
early = X[dates.year <= 2021]; late = X[dates.year >= 2022]
print("Q2. Is it stable? Annual mean of the rolling residual correlation:")
print(" " + " ".join("%d %+0.2f" % (y, v) for y, v in ann.items()))
print("\n %.0f%% of all windows are negative, so the textbook story holds for most of the sample." % (100*(roll<0).mean()))
print(" But it drifts steadily -- from %+.2f in %d to %+.2f in %d -- and crosses zero on %s."
% (ann.iloc[0], ann.index[0], ann.iloc[-1], ann.index[-1], first_pos.date()))
print(" Split the sample: SPY-TLT correlation is %+.2f over 2010-2021 and %+.2f over 2022-2024."
% (np.corrcoef(early[:,sp], early[:,b])[0,1], np.corrcoef(late[:,sp], late[:,b])[0,1]))
print("\n This is the sharpest limitation the arc runs into, and it is worth stating bluntly.")
print(" Shrinkage, Bayesian estimation, Black-Litterman and robust allocation all treat the")
print(" covariance matrix as a FIXED object to be estimated better from noisy data. No amount of")
print(" shrinkage repairs an estimate of a parameter that is not constant -- and the single most")
print(" important diversification relationship in a multi-asset portfolio is exactly one that")
print(" changed sign. An investor who bought the 60/40 hedge on its 2010s correlation was holding")
print(" a position whose defining property had quietly reversed. Copula-GARCH lets volatility and")
print(" dependence move over time; that is the direction the answer lies in, and this pair is the")
print(" clearest argument in the whole arc for why the moving version is not optional.")
Q1. Does GARCH explain it? SPY-TLT dependence, raw returns vs GARCH residuals:
raw -0.298 -> residuals -0.282 (change +0.016)
No. Unlike the equity-credit pair, where stripping volatility cut tail dependence almost in
half, here the decomposition barely moves the number. The negative equity-bond relationship
is NOT a shared-volatility artefact -- it is in the raw returns and it survives intact.
Q2. Is it stable? Annual mean of the rolling residual correlation: 2011 -0.56 2012 -0.57 2013 -0.60 2014 -0.41 2015 -0.34 2016 -0.39 2017 -0.33 2018 -0.25 2019 -0.30 2020 -0.41 2021 -0.32 2022 -0.13 2023 +0.02 2024 +0.16 88% of all windows are negative, so the textbook story holds for most of the sample. But it drifts steadily -- from -0.56 in 2011 to +0.16 in 2024 -- and crosses zero on 2023-01-06. Split the sample: SPY-TLT correlation is -0.45 over 2010-2021 and +0.09 over 2022-2024. This is the sharpest limitation the arc runs into, and it is worth stating bluntly. Shrinkage, Bayesian estimation, Black-Litterman and robust allocation all treat the covariance matrix as a FIXED object to be estimated better from noisy data. No amount of shrinkage repairs an estimate of a parameter that is not constant -- and the single most important diversification relationship in a multi-asset portfolio is exactly one that changed sign. An investor who bought the 60/40 hedge on its 2010s correlation was holding a position whose defining property had quietly reversed. Copula-GARCH lets volatility and dependence move over time; that is the direction the answer lies in, and this pair is the clearest argument in the whole arc for why the moving version is not optional.
3. Dynamic Value-at-Risk that tracks the market's mood¶
Re-assembly gives a one-day-ahead portfolio VaR for every day: draw a large pool of residual vectors from the fitted copula (semiparametric — with the residuals' empirical marginals), scale each asset by that day's conditional volatility $\sigma_{j,t}$, aggregate to the portfolio, and read off the 1% quantile. Because $\sigma_{j,t}$ rises in turbulence, so does the VaR — automatically.
w = np.ones(N)/N; r_port = X @ w
rng = np.random.default_rng(0)
Zpool = cg.residual_pool(marg, Rt_z, nu_z, 20000, rng) # semiparametric residual pool
dyn = cg.dynamic_var(marg, w, Zpool, 0.01) # copula-GARCH dynamic VaR
stat = cg.static_var_rolling(r_port, 500, 0.01) # static rolling-historical VaR
fig, ax = plt.subplots(figsize=(12, 4.8))
ax.plot(dates, r_port, color=GREY, lw=.4, alpha=.7, label="portfolio return")
ax.plot(dates, -dyn, color=BLUE, lw=1.2, label="copula-GARCH 1% VaR (dynamic)")
ax.plot(dates, -stat, color=RED, lw=1.2, label="static rolling VaR")
viol = r_port < -dyn
ax.scatter(dates[viol], r_port[viol], s=12, color=RED, zorder=5, label="dynamic-VaR violations")
ax.set_ylim(-12, 8); ax.set_ylabel("daily return / -VaR (%)")
ax.set_title("The dynamic VaR rises in crises; the static VaR reacts late and lingers"); ax.legend(ncol=2, fontsize=8)
plt.tight_layout(); plt.show()
print("The blue (dynamic) VaR jumps the moment volatility spikes and relaxes as calm returns; the red")
print("(static) VaR only drifts up slowly AFTER a window fills with bad days, and stays high too long.")
The blue (dynamic) VaR jumps the moment volatility spikes and relaxes as calm returns; the red (static) VaR only drifts up slowly AFTER a window fills with bad days, and stays high too long.
4. The backtest the static model failed¶
The decisive test. A good VaR must satisfy Kupiec (violation rate $\approx1\%$) and Christoffersen (violations independent, not clustered). The coherent-risk notebook showed the static VaR passing the rate test but failing independence — its breaches bunched in crises. The dynamic copula-GARCH VaR, which rises with volatility, should finally pass both.
def backtest(varr):
m = ~np.isnan(varr); v = (r_port[m] < -varr[m]).astype(int)
return v.sum(), len(v), rm.kupiec_pof(v.sum(), len(v), 0.01)[1], rm.christoffersen_independence(v)[1]
print("1% VaR backtest on the equal-weight portfolio:\n")
print("%-26s %12s %12s %18s %s" % ("model", "violations", "Kupiec p", "Christoffersen p", "verdict"))
for name, varr in [("static (rolling historical)", stat), ("copula-GARCH (dynamic)", dyn)]:
nv, n, pk, pc = backtest(varr)
verdict = "PASS (rate & independence)" if pk>0.05 and pc>0.05 else ("FAIL: clustered" if pk>0.05 else "FAIL")
print("%-26s %6d (%.2f%%) %12.3f %18.3f %s" % (name, nv, 100*nv/n, pk, pc, verdict))
print("\nThe static VaR fails Christoffersen -- its violations cluster in crises. The copula-GARCH VaR")
print("passes BOTH: by letting risk breathe with volatility, its breaches are spread out and rate-correct.")
print("This closes the loop opened in the coherent-risk notebook.")
1% VaR backtest on the equal-weight portfolio: model violations Kupiec p Christoffersen p verdict static (rolling historical) 34 (1.04%) 0.823 0.000 FAIL: clustered copula-GARCH (dynamic) 39 (1.03%) 0.835 0.423 PASS (rate & independence) The static VaR fails Christoffersen -- its violations cluster in crises. The copula-GARCH VaR passes BOTH: by letting risk breathe with volatility, its breaches are spread out and rate-correct. This closes the loop opened in the coherent-risk notebook.
Is the comparison fair?¶
One asymmetry has to be dealt with before the verdict counts. The copula-GARCH VaR above uses GARCH parameters and a residual pool estimated on the whole sample, while the static baseline sees only a trailing 500-day window. That is a real advantage for the model we are declaring the winner, and a backtest is exactly the place where such an advantage should not go unexamined.
So we redo it honestly: fit the marginals and the copula on a 1,000-day burn-in only, roll the conditional variances forward with those frozen parameters, and score both models on the days that follow — genuinely out of sample for each.
B = 1000 # burn-in: parameters and copula estimated here only
margB = cg.fit_all(X[:B])
Rt_B, nu_B = cp.fit_t_copula(cp.pseudo_obs(cg.std_resid_matrix(margB)))
# roll each variance recursion forward over the full sample with the FROZEN burn-in parameters
sig = np.empty((T, N))
for k in range(N):
o, a_, b_ = margB[k]["theta"][:3]; rc = X[:,k] - margB[k]["mu"]
s2 = np.empty(T); s2[0] = np.var(X[:B,k])
for t in range(1, T): s2[t] = o + a_*rc[t-1]**2 + b_*s2[t-1]
sig[:,k] = np.sqrt(s2)
rng2 = np.random.default_rng(0)
Up = cp.sim_t_copula(Rt_B, nu_B, 20000, rng2)
ZpoolB = np.column_stack([np.quantile(margB[k]["z"], np.clip(Up[:,k], 1e-4, 1-1e-4)) for k in range(N)])
mu_v = np.array([m["mu"] for m in margB])
dyn_oos = np.array([-np.quantile((ZpoolB * (w * sig[t])).sum(1) + float(w @ mu_v), 0.01) for t in range(T)])
oos = np.zeros(T, bool); oos[B:] = True
def backtest_on(varr, mask):
m = (~np.isnan(varr)) & mask; v = (r_port[m] < -varr[m]).astype(int)
return v.sum(), len(v), rm.kupiec_pof(v.sum(), len(v), 0.01)[1], rm.christoffersen_independence(v)[1]
print("1%% VaR backtest, everything out of sample (parameters from the first %d days,\n"
"both models scored on the remaining days):\n" % B)
print("%-26s %12s %12s %18s %s" % ("model", "violations", "Kupiec p", "Christoffersen p", "verdict"))
for name, varr in [("static (rolling historical)", stat), ("copula-GARCH (dynamic)", dyn_oos)]:
nv, n, pk, pc = backtest_on(varr, oos)
verdict = "PASS (rate & independence)" if pk>0.05 and pc>0.05 else ("FAIL: clustered" if pk>0.05 else "FAIL")
print("%-26s %6d (%.2f%%) %12.3f %18.3f %s" % (name, nv, 100*nv/n, pk, pc, verdict))
print("\nThe verdict survives the fair comparison. Copula-GARCH was not winning because it had seen the")
print("future: with parameters frozen after %d days it still passes both tests, and the static VaR still" % B)
print("fails independence with a p-value indistinguishable from zero. The advantage is structural --")
print("a VaR that breathes with volatility spreads its breaches out -- not an artefact of the fitting window.")
1% VaR backtest, everything out of sample (parameters from the first 1000 days, both models scored on the remaining days): model violations Kupiec p Christoffersen p verdict static (rolling historical) 31 (1.12%) 0.539 0.000 FAIL: clustered copula-GARCH (dynamic) 30 (1.08%) 0.668 0.335 PASS (rate & independence) The verdict survives the fair comparison. Copula-GARCH was not winning because it had seen the future: with parameters frozen after 1000 days it still passes both tests, and the static VaR still fails independence with a p-value indistinguishable from zero. The advantage is structural -- a VaR that breathes with volatility spreads its breaches out -- not an artefact of the fitting window.
5. Summary¶
- Copula-GARCH decomposes multi-asset returns into two cleanly separated pieces: each asset's time-varying volatility (GARCH) and the dependence of the residuals (a copula).
- GARCH removes the volatility clustering — for three of the five series completely, on a Ljung–Box test of the squared residuals; HYG and EEM retain some, so the residuals are close to the i.i.d. invariants the copula step assumes rather than exactly them. Worth stating plainly, since every later step inherits the approximation.
- The residual dependence is weaker in the tail than the raw-return dependence: part of the apparent joint-crash risk was merely shared volatility, and copula-GARCH assigns each effect to its true cause. The equity–bond pair is the instructive exception — GARCH barely touches it, because that relationship was never a volatility artefact.
- SPY–TLT changed sign. The rolling equity–bond correlation runs near $-0.55$ early in the sample, drifts through the 2010s, and turns positive in 2023. Every earlier project in this arc treats the covariance matrix as a fixed quantity to be estimated better; no amount of shrinkage repairs an estimate of a parameter that is not constant, and the relationship a 60/40 portfolio depends on most is precisely the one that reversed. It is the strongest argument the arc makes for letting dependence move.
- The re-assembled model gives a dynamic VaR that breathes with the market and passes the Kupiec and Christoffersen backtests — the very independence test that the static VaR of the coherent-risk notebook failed. The comparison is made out of sample as well as in, with parameters frozen after a burn-in, and the verdict holds either way: the advantage is structural, not an artefact of the fitting window.
This is the capstone of the Risk and Asset Allocation arc: it unifies volatility modelling (GARCH), dependence modelling (copulas), risk measurement (VaR/backtesting) and the invariance principle into one coherent, testable, dynamic risk engine.