Coherent Risk: VaR, ES & Extreme-Value Theory — the R engine¶
The independent R implementation. VaR and Expected Shortfall are coded from scratch (they are one-liners), and the tail is fit with the classic evir package (Embrechts–McNeil GPD peaks-over-threshold) as the authoritative cross-check. It reproduces the from-scratch Python results: the Gaussian understates the tail, Cornish–Fisher explodes at high kurtosis, EVT extrapolates the far tail, VaR fails sub-additivity while ES is coherent, and a static VaR fails its backtest through clustered violations. Self-contained; no prior reading required.
options(repr.plot.width=11, repr.plot.height=4.6)
.libPaths(Sys.getenv("R_LIBS_USER"))
suppressMessages(library(evir))
BLUE<-"#2b6cb0"; ORANGE<-"#dd6b20"; GREEN<-"#2f855a"; RED<-"#c53030"; GREY<-"#718096"; PURP<-"#6b46c1"
R <- read.csv("crossasset_daily.csv", row.names=1)
A <- colnames(R); X <- as.matrix(R); spy <- R$SPY
cat(sprintf("Daily cross-asset panel: %d days, %s\n", nrow(R), paste(A, collapse=", ")))
cat(sprintf("SPY daily: mean %.3f sd %.3f worst %.2f excess-kurtosis %.1f\n",
mean(spy), sd(spy), min(spy), mean(((spy-mean(spy))/sd(spy))^4)-3))
Daily cross-asset panel: 3772 days, SPY, TLT, GLD, HYG, EEM
SPY daily: mean 0.051 sd 1.077 worst -11.59 excess-kurtosis 11.5
The dataset¶
Daily log-returns (%) of five cross-asset ETFs, 2010–2024 (3,772 days): SPY, TLT, GLD, HYG, EEM. Daily frequency gives the depth the tail needs; the five assets give a portfolio for coherence and contributions. SPY's worst day is the −11.6% COVID crash.
1. VaR and Expected Shortfall, four ways¶
VaR$_\alpha$ = minus the $\alpha$-quantile of returns; ES$_\alpha$ = the average loss beyond it. Historical (empirical), Gaussian (normal), Cornish–Fisher (skew/kurtosis-adjusted), and Student-$t$ (fat-tailed fit).
skew_ <- function(x) mean(((x-mean(x))/sd(x))^3); exk_ <- function(x) mean(((x-mean(x))/sd(x))^4)-3
hist_var <- function(r,a) -quantile(r,a,names=FALSE)
hist_es <- function(r,a){ s<-sort(r); k<-max(1,ceiling(a*length(r))); -mean(s[1:k]) }
norm_var <- function(r,a) -(mean(r)+sd(r)*qnorm(a)); norm_es <- function(r,a) -(mean(r)-sd(r)*dnorm(qnorm(a))/a)
cf_z <- function(a,s,k){ z<-qnorm(a); z+(z^2-1)*s/6+(z^3-3*z)*k/24-(2*z^3-5*z)*s^2/36 }
cf_var <- function(r,a) -(mean(r)+sd(r)*cf_z(a,skew_(r),exk_(r)))
tab <- t(sapply(c(0.05,0.01,0.001), function(a) c(alpha=a,
hist=hist_var(spy,a), norm=norm_var(spy,a), CF=cf_var(spy,a),
ES.hist=hist_es(spy,a), ES.norm=norm_es(spy,a))))
cat("SPY daily VaR / ES (positive = loss %):\n"); print(round(tab,2))
cat("\nGaussian understates every tail; Cornish-Fisher explodes at 0.1% (excess-kurtosis ~12 breaks it);\n")
cat("historical is the trustworthy benchmark. Same pattern as the Python engine.\n")
SPY daily VaR / ES (positive = loss %):
alpha hist norm CF ES.hist ES.norm [1,] 0.05 1.67 1.72 1.68 2.66 2.17 [2,] 0.01 3.10 2.46 5.72 4.48 2.82 [3,] 0.00 6.12 3.28 14.17 9.13 3.58
Gaussian understates every tail; Cornish-Fisher explodes at 0.1% (excess-kurtosis ~12 breaks it);
historical is the trustworthy benchmark. Same pattern as the Python engine.
2. Extreme-Value Theory with evir¶
The evir package fits the Generalized Pareto Distribution to peaks over a high threshold and returns VaR (quantile) and ES (sfall) at any level. We select the threshold with the mean-excess plot and read off the far-tail risk that history alone cannot.
L <- -spy; u <- quantile(L, 0.90)
fit <- gpd(L, threshold=u)
cat(sprintf("evir GPD fit: xi = %.3f (>0 heavy tail), beta = %.3f, %d exceedances over u=%.2f\n\n",
fit$par.ests["xi"], fit$par.ests["beta"], length(fit$data), u))
rmv <- riskmeasures(fit, c(0.99, 0.999)) # 99% and 99.9% -> VaR & ES
cat(sprintf("evir riskmeasures: 99%% VaR %.2f ES %.2f | 99.9%% VaR %.2f ES %.2f\n\n",
rmv[1,2], rmv[1,3], rmv[2,2], rmv[2,3]))
par(mfrow=c(1,2))
meplot(L, main="Mean-excess plot (linear -> GPD tail)"); abline(v=u, col=RED, lty=2)
# VaR term structure: historical vs Gaussian vs EVT
alphas <- 10^seq(-3.3,-1.3,length.out=25)
vh<-sapply(alphas,function(a)hist_var(spy,a)); vn<-sapply(alphas,function(a)norm_var(spy,a))
ve<-riskmeasures(fit, 1-alphas)[,2]
plot(alphas, ve, type="l", col=GREEN, lwd=3, log="x", xlim=rev(range(alphas)),
xlab="tail probability alpha (rarer ->)", ylab="VaR (loss %)", main="Into the far tail")
lines(alphas, vh, type="b", pch=19, col=BLUE); lines(alphas, vn, type="b", pch=15, col=RED)
legend("topleft", c("EVT (GPD)","historical","Gaussian"), col=c(GREEN,BLUE,RED), lwd=c(3,1,1), pch=c(NA,19,15), bty="n")
par(mfrow=c(1,1))
cat(sprintf("At 0.1%%: historical %.2f Gaussian %.2f EVT %.2f -- EVT extrapolates where data runs out.\n",
hist_var(spy,0.001), norm_var(spy,0.001), rmv[2,2]))
evir GPD fit: xi = 0.155 (>0 heavy tail), beta = 0.763, 378 exceedances over u=1.10
evir riskmeasures: 99% VaR 3.21 ES 4.50 | 99.9% VaR 6.23 ES 8.07
At 0.1%: historical 6.12 Gaussian 3.28 EVT 6.23 -- EVT extrapolates where data runs out.
3. Coherence: VaR fails sub-additivity, ES does not¶
A coherent risk measure is sub-additive — diversifying never raises risk. VaR breaks this; ES does not. Two independent defaultable bonds (rare large loss, small coupon): each bond's default is rarer than the VaR level so its VaR looks safe, but combined they push a loss into the VaR window — the diversified book reports a larger VaR.
set.seed(0); n <- 200000
bond <- function(p=0.04) ifelse(runif(n)<p, -100, 2)
Aa <- bond(); Bb <- bond(); port <- 0.5*Aa + 0.5*Bb; al <- 0.05
vA<-hist_var(Aa,al); vB<-hist_var(Bb,al); vP<-hist_var(port,al)
eA<-hist_es(Aa,al); eB<-hist_es(Bb,al); eP<-hist_es(port,al)
par(mfrow=c(1,2))
barplot(c(vA+vB, vP), names.arg=c("VaR(A)+VaR(B)","VaR(A+B)"), col=c(GREY,RED),
main="VaR FAILS sub-additivity", ylab="VaR")
barplot(c(eA+eB, eP), names.arg=c("ES(A)+ES(B)","ES(A+B)"), col=c(GREY,GREEN),
main="ES is coherent", ylab="ES")
par(mfrow=c(1,1))
cat(sprintf("VaR: sum %.1f vs diversified %.1f -> %s\n", vA+vB, vP, ifelse(vP>vA+vB,"VIOLATED (diversification punished!)","ok")))
cat(sprintf("ES : sum %.1f vs diversified %.1f -> coherent (%s)\n", eA+eB, eP, ifelse(eP<=eA+eB,"yes","no")))
VaR: sum -4.0 vs diversified 49.0 -> VIOLATED (diversification punished!)
ES : sum 161.3 vs diversified 50.5 -> coherent (yes)
4. Risk contributions and backtesting¶
Component VaR (Euler) splits portfolio risk into per-asset pieces summing to the total. And a VaR is only trustworthy if it survives a backtest: the Kupiec test checks the violation rate, Christoffersen checks independence.
# component VaR (Gaussian Euler) for the equal-weight portfolio
w <- rep(1/length(A), length(A)); mu <- colMeans(X); Sig <- cov(X)
sdp <- sqrt(as.numeric(t(w)%*%Sig%*%w)); cvar <- w * (-mu - qnorm(0.01)*as.numeric(Sig%*%w)/sdp)
cat("Component VaR (% of total):", paste(sprintf("%s %.0f%%", A, 100*cvar/sum(cvar)), collapse=" "), "\n\n")
# rolling 1% VaR backtest
kupiec <- function(x,n,a){ pi<-x/n; ll0<-(n-x)*log(1-a)+x*log(a); ll1<-if(pi>0&&pi<1)(n-x)*log(1-pi)+x*log(pi) else 0
LR<--2*(ll0-ll1); c(LR=LR, p=1-pchisq(LR,1)) }
christo <- function(v){ n00<-sum(v[-length(v)]==0&v[-1]==0);n01<-sum(v[-length(v)]==0&v[-1]==1)
n10<-sum(v[-length(v)]==1&v[-1]==0);n11<-sum(v[-length(v)]==1&v[-1]==1)
p01<-n01/max(n00+n01,1);p11<-n11/max(n10+n11,1);p<-(n01+n11)/max(length(v)-1,1)
s<-function(a,b,pp) if(pp>0&&pp<1) a*log(1-pp)+b*log(pp) else 0
LR<--2*((s(n00,n01,p)+s(n10,n11,p))-(s(n00,n01,p01)+s(n10,n11,p11))); c(LR=LR,p=1-pchisq(LR,1)) }
bt <- function(r,W,method,a=0.01){ v<-c(); for(t in (W+1):length(r)){ past<-r[(t-W):(t-1)]
vv<-if(method=="normal") norm_var(past,a) else hist_var(past,a); v<-c(v, as.integer(r[t] < -vv)) }; v }
cat("Rolling 1% VaR backtest (window=500):\n")
for(meth in c("normal","historical")){ v<-bt(spy,500,meth); k<-kupiec(sum(v),length(v),0.01); ch<-christo(v)
cat(sprintf(" %-11s %4d viol (%.2f%%) Kupiec p=%.3f Christoffersen p=%.3f\n",
meth, sum(v), 100*mean(v), k["p"], ch["p"])) }
cat("\nGaussian breached too often (fails Kupiec); historical rate is fine but BOTH cluster violations\n")
cat("(fail Christoffersen) -> volatility clustering, which only a dynamic GARCH-VaR removes.\n")
Component VaR (% of total): SPY 27% TLT 4% GLD 17% HYG 14% EEM 38%
Rolling 1% VaR backtest (window=500):
normal 82 viol (2.51%) Kupiec p=0.000 Christoffersen p=0.000 historical 43 viol (1.31%) Kupiec p=0.085 Christoffersen p=0.000
Gaussian breached too often (fails Kupiec); historical rate is fine but BOTH cluster violations
(fail Christoffersen) -> volatility clustering, which only a dynamic GARCH-VaR removes.
5. Summary¶
- From-scratch VaR/ES in R reproduce the Python engine: the Gaussian understates the tail, Cornish–Fisher explodes at equity kurtosis, historical is the reliable benchmark.
evirconfirms the tail: a Generalized-Pareto fit with heavy shape $\xi\approx0.16$ extrapolates the far-tail VaR/ES that history cannot.- VaR fails sub-additivity (punishes diversification); ES is coherent — the reason regulation moved to ES.
- A static VaR fails its backtest through clustered violations (volatility clustering) — a pointer to dynamic GARCH-VaR.
Three engines — from-scratch Python, Bayesian PyMC (posterior of the far-tail VaR), and R (evir) — agree, closing the risk-measurement chapter of the Asset Risk arc.