Interactive exploration of Multivariate Filtered Historical Simulation with constant correlations for portfolio risk measurement
Multivariate Filtered Historical Simulation extends the univariate FHS approach to portfolios of multiple assets (Christoffersen 2012, chap. 8). The key idea: estimate individual GARCH models for each asset, extract standardized residuals, and then draw entire shock vectors from the same historical day to preserve the cross-asset correlation structure.
Under constant correlations, the procedure is:
Estimate GARCH for each asset \(i\) to obtain \(\hat{\sigma}_{i,t}\)
Calculate VaR and ES from the simulated distribution
Note
Why draw entire vectors? Drawing each asset’s shock independently would destroy the historical correlation structure, leading to systematic underestimation of portfolio tail risk. By drawing all assets’ shocks from the same historical day, we preserve whatever dependence (linear and nonlinear) existed in the data.
rng_utils = {functionmulberry32(seed) {returnfunction() { seed |=0; seed = seed +0x6D2B79F5|0let t =Math.imul(seed ^ seed >>>15,1| seed) t = t +Math.imul(t ^ t >>>7,61| t) ^ treturn ((t ^ t >>>14) >>>0) /4294967296 } }functionboxMuller(rng) {const u1 =rng(), u2 =rng()returnMath.sqrt(-2*Math.log(u1)) *Math.cos(2*Math.PI* u2) }functiongammaSample(rng, shape) {if (shape <1) returngammaSample(rng, shape +1) *Math.pow(rng(),1/ shape)const d = shape -1/3, c =1/Math.sqrt(9* d)while (true) {let x, vdo { x =boxMuller(rng); v =1+ c * x } while (v <=0) v = v * v * v;const u =rng()if (u <1-0.0331* (x * x) * (x * x)) return d * vif (Math.log(u) <0.5* x * x + d * (1- v +Math.log(v))) return d * v } }functionstdTSample(rng, d) {const normal =boxMuller(rng)const chi2 =2*gammaSample(rng, d /2)return normal /Math.sqrt(chi2 / d) *Math.sqrt((d -2) / d) }return { mulberry32, boxMuller, stdTSample }}
fmt = (x, d) => x ===undefined||isNaN(x) ?"N/A": x.toFixed(d)pctFmt = x => (x *100).toFixed(2) +"%"
The critical implementation choice in multivariate FHS is whether to draw shocks for all assets from the same historical day or to draw them independently. This illustration shows the dramatic impact on portfolio risk measurement.
Tip
How to experiment
Start with a positive correlation (\(\rho\) around 0.5) and compare the distributions in the “Same-day vs. independent” tab. The independent draw distribution will have thinner tails because it destroys the correlation. Increase the correlation to amplify the difference. Also try increasing the horizon to see how the effect compounds over multiple days.
viewof mfHistLegend =legend([ { key:"same",label:"Same-day draws (correct)",color:"#2f71d5",type:"area" }, { key:"indep",label:"Independent draws (incorrect)",color:"#e67e22",type:"area" }])
{const h = mfHistLegendconst marks = [Plot.ruleY([0])]if (!h.has("same")) marks.push(Plot.rectY(mfResults.pfRetSame, Plot.binX({ y:"count" }, {x: d => d,fill:"#2f71d5",fillOpacity:0.4,thresholds:80 })))if (!h.has("indep")) marks.push(Plot.rectY(mfResults.pfRetIndep, Plot.binX({ y:"count" }, {x: d => d,fill:"#e67e22",fillOpacity:0.3,thresholds:80 })))// VaR linesif (!h.has("same")) marks.push(Plot.ruleX([-mfResults.varSame], { stroke:"#2f71d5",strokeWidth:2 }))if (!h.has("indep")) marks.push(Plot.ruleX([-mfResults.varIndep], { stroke:"#e67e22",strokeWidth:2,strokeDasharray:"6 3" }))return Plot.plot({height:400,marginLeft:60,x: { label:`${mfK}-day portfolio return`,grid:false,tickFormat: pctFmt },y: { label:"Count",grid:true }, marks })}
{const diff = ((mfResults.varSame- mfResults.varIndep) / mfResults.varSame*100)returnhtml`<p style="color:#666;font-size:0.85rem;">Distribution of simulated ${mfK}-day portfolio returns. The <span style="color:#2f71d5;font-weight:700;">blue distribution</span> draws entire shock vectors from the same day (correct), preserving the correlation ρ = ${fmt(mfRho,2)}. The <span style="color:#e67e22;font-weight:700;">orange distribution</span> draws each asset independently (incorrect), destroying the correlation. Vertical lines mark the respective VaR levels. The independent approach ${mfRho >0?"underestimates":"overestimates"} risk by ${fmt(Math.abs(diff),1)}%.</p>`}
// Compute VaR across horizonsmfTermStructure = {const rng = rng_utils.mulberry32(300+ mfSeed)const { z1, z2, curSig1, curSig2, N } = mfHistconst FH =Math.min(mfFH,3000)const w1 = mfW1, w2 =1- mfW1const p = mfP /100const maxK =20const result = []for (let K =1; K <= maxK; K++) {const pfRet =newArray(FH)for (let i =0; i < FH; i++) {let cum =0, s2_1 = curSig1**2, s2_2 = curSig2**2for (let k =0; k < K; k++) {const idx =Math.floor(rng() * N)const rs1 =Math.sqrt(s2_1) * z1[idx]const rs2 =Math.sqrt(s2_2) * z2[idx] cum += w1 * rs1 + w2 * rs2 s2_1 = mfOmega1 + mfAlpha1 * rs1**2+ mfBeta1 * s2_1 s2_2 = mfOmega2 + mfAlpha2 * rs2**2+ mfBeta2 * s2_2 } pfRet[i] = cum } pfRet.sort((a, b) => a - b)const posIdx =Math.max(0,Math.ceil(FH * p) -1)const fhsVaR =-pfRet[posIdx]const tail = pfRet.slice(0, posIdx +1)const fhsES =-tail.reduce((a, b) => a + b,0) / tail.length// Normal scalingconst pfSig =Math.sqrt( w1*w1*curSig1**2+ w2*w2*curSig2**2+2*w1*w2*curSig1*curSig2*mfRho )const normVaR = pfSig *Math.abs(qnorm(p)) *Math.sqrt(K)const normES = pfSig *normalPDF(qnorm(p)) / p *Math.sqrt(K) result.push({ K,fhsVaR: fhsVaR *100,fhsES: fhsES *100,normVaR: normVaR *100,normES: normES *100,fhsVaRPerDay: fhsVaR /Math.sqrt(K) *100,normVaRPerDay: normVaR /Math.sqrt(K) *100 }) }return result}
{const h = mfTSLegendconst marks = []if (!h.has("normvar")) marks.push(Plot.line(mfTermStructure, { x:"K",y:"normVaR",stroke:"#2f71d5",strokeWidth:1.5,strokeDasharray:"6 3" }))if (!h.has("normes")) marks.push(Plot.line(mfTermStructure, { x:"K",y:"normES",stroke:"#d62728",strokeWidth:1.5,strokeDasharray:"6 3" }))if (!h.has("fhsvar")) marks.push(Plot.line(mfTermStructure, { x:"K",y:"fhsVaR",stroke:"#2f71d5",strokeWidth:2.5 }))if (!h.has("fhses")) marks.push(Plot.line(mfTermStructure, { x:"K",y:"fhsES",stroke:"#d62728",strokeWidth:2.5 }))// Highlight current K marks.push(Plot.ruleX([mfK], { stroke:"#999",strokeDasharray:"2 3" }))return Plot.plot({height:400,marginLeft:60,x: { label:"Horizon K (days)",grid:false,ticks: [1,5,10,15,20] },y: { label:"Risk measure (% of portfolio)",grid:true }, marks })}
html`<p style="color:#666;font-size:0.85rem;">Term structure of portfolio VaR and ES from multivariate FHS (solid) vs. normal √K scaling (dashed). With fat-tailed shocks, FHS produces higher risk estimates, especially at shorter horizons. The vertical gray line marks the currently selected horizon K = ${mfK}.</p>`
{const r = mfResultsreturnhtml`<table class="table" style="width:100%;"><thead><tr> <th>Measure</th> <th style="color:#2f71d5;">Same-day draws</th> <th style="color:#e67e22;">Independent draws</th> <th>Normal</th></tr></thead><tbody><tr><td style="font-weight:500;">${mfK}-day VaR (${fmt(mfP,1)}%)</td> <td style="font-weight:700;">${fmt(r.varSame*100,3)}%</td> <td>${fmt(r.varIndep*100,3)}%</td> <td>${fmt(r.normVaR*100,3)}%</td></tr><tr><td style="font-weight:500;">${mfK}-day ES (${fmt(mfP,1)}%)</td> <td style="font-weight:700;">${fmt(r.esSame*100,3)}%</td> <td>${fmt(r.esIndep*100,3)}%</td> <td>${fmt(r.normES*100,3)}%</td></tr><tr><td style="font-weight:500;">VaR underestimation (independent)</td> <td colspan="3" style="color:#d62728;font-weight:700;">${fmt((r.varSame- r.varIndep) / r.varSame*100,1)}%</td></tr><tr><td style="font-weight:500;">ES underestimation (independent)</td> <td colspan="3" style="color:#d62728;font-weight:700;">${fmt((r.esSame- r.esIndep) / r.esSame*100,1)}%</td></tr></tbody></table><p style="color:#666;font-size:0.85rem;">Current portfolio: w₁ = ${fmt(mfW1,2)}, w₂ = ${fmt(1-mfW1,2)}. Current volatilities: σ₁ = ${fmt(mfHist.curSig1*100,3)}%, σ₂ = ${fmt(mfHist.curSig2*100,3)}%. Correlation: ρ = ${fmt(mfRho,2)}. Simulated with ${mfFH} paths over ${mfK} days.</p>`}
References
Christoffersen, Peter F. 2012. Elements of Financial Risk Management. 2nd ed. Academic Press.