Applying Value-at-Risk and Expected Shortfall measures to real market data using different estimation approaches
This page applies the VaR and ES concepts from the previous section to real Tesla stock returns. We estimate these risk measures using three approaches: unconditional (full-sample) estimation, the RiskMetrics EWMA model, and rolling windows, and examine how the choice of method affects the risk estimates over time (see Hull 2023, chap. 11; Christoffersen 2012, chap. 1).
// Standard normal CDF (Abramowitz & Stegun approximation)normalCDF = x => {const a1 =0.254829592, a2 =-0.284496736, a3 =1.421413741const a4 =-1.453152027, a5 =1.061405429, p =0.3275911const sign = x <0?-1:1const z =Math.abs(x) /Math.sqrt(2)const t =1.0/ (1.0+ p * z)const y =1- (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t *Math.exp(-z * z)return0.5* (1+ sign * y)}
// Standard normal PDFnormalPDF = x =>Math.exp(-x * x /2) /Math.sqrt(2*Math.PI)
where \(\alpha\) is the confidence level, \(1-\alpha\) is the tail probability, \(\Phi^{-1}\) is the inverse standard normal CDF, and \(\phi\) is the standard normal PDF. Note that \(\Phi^{-1}(1-\alpha)\) is negative for \(\alpha > 0.5\), so both VaR and ES are positive. For short horizons, \(\mu\) is often set to zero, making VaR proportional to \(\sigma\).
Tip
How to experiment
Adjust the confidence level to see how the VaR and ES thresholds move relative to the return distribution. At higher confidence levels, VaR and ES move further into the left tail. Compare the number of actual returns that fall below \(-\text{VaR}\) with what the normal distribution predicts.
{const l =legend([ {label:"Daily return",color:"#4682b4",type:"dot"}, {label:"VaR violation",color:"#d62728",type:"dot"}, {label:"−VaR",color:"#ff7f0e",type:"dashed"}, {label:"−ES",color:"#d62728",type:"line"} ])const p =html`<p style="color:#666; font-size:0.85rem;">VaR violations: ${uncond.exceedances} out of ${nObs} (${(uncond.exceedances/ nObs *100).toFixed(1)}% vs ${((1- confLevelU) *100).toFixed(1)}% expected). Both thresholds are constant because they use the full-sample (unconditional) standard deviation.</p>`returnhtml`${l}${p}`}
{const l =legend([ {label:"Returns",color:"#4682b4",type:"line"}, {label:"Beyond −VaR",color:"#d62728",type:"line"}, {label:"Normal fit",color:"#333",type:"line"}, {label:"−VaR",color:"#ff7f0e",type:"dashed"}, {label:"−ES",color:"#d62728",type:"dashed"} ])const p =html`<p style="color:#666; font-size:0.85rem;">Histogram of Tesla daily returns with fitted normal distribution (μ = ${(retMean *100).toFixed(4)}%, σ = ${(retSD *100).toFixed(2)}%). Note how the empirical distribution has fatter tails than the normal, so more extreme returns than the model predicts.</p>`returnhtml`${l}${p}`}
html`<table class="table" style="width:100%;"><thead><tr><th colspan="2">Unconditional VaR and ES at ${(confLevelU *100).toFixed(1)}% confidence</th></tr></thead><tbody><tr><td style="font-weight:500;">z-score Φ⁻¹(1−α)</td><td>${(-uncond.z).toFixed(4)}</td></tr><tr><td style="font-weight:500;">Sample σ</td><td>${(retSD *100).toFixed(4)}%</td></tr><tr><td style="font-weight:500;">VaR (% of portfolio)</td><td style="font-weight:700;">${(uncond.var_*100).toFixed(4)}%</td></tr><tr><td style="font-weight:500;">ES (% of portfolio)</td><td style="font-weight:700;">${(uncond.es*100).toFixed(4)}%</td></tr><tr><td style="font-weight:500;">$VaR on $1M portfolio</td><td>$${((1-Math.exp(-uncond.var_)) *1e6).toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g,",")}</td></tr><tr><td style="font-weight:500;">$ES on $1M portfolio</td><td>$${((1-Math.exp(-uncond.es)) *1e6).toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g,",")}</td></tr><tr><td style="font-weight:500;">ES / VaR ratio</td><td>${(uncond.es/ uncond.var_).toFixed(4)}</td></tr><tr><td style="font-weight:500;">Actual exceedances</td><td>${uncond.exceedances} of ${nObs} (${(uncond.exceedances/ nObs *100).toFixed(1)}%)</td></tr><tr><td style="font-weight:500;">Expected exceedances</td><td>${uncond.expectedExc.toFixed(1)} (${((1- confLevelU) *100).toFixed(1)}%)</td></tr></tbody></table><p style="color:#666; font-size:0.85rem;">The unconditional approach uses a single volatility estimate for the entire sample. It cannot adapt to changing market conditions as the same VaR applies during calm and turbulent periods. The mismatch between actual and expected exceedances hints at the normal distribution's inability to capture Tesla's fat tails.</p>`
3. RiskMetrics VaR and ES
The RiskMetrics model uses an exponentially weighted moving average (EWMA) for volatility, which adapts to recent market conditions (see Christoffersen 2012, chap. 1, section 7):
Increase \(\lambda\) (closer to 1) to make volatility estimates smoother and slower to react. Decrease \(\lambda\) to make them more responsive to recent shocks.
Change the confidence level to see VaR and ES widen or narrow relative to volatility.
{const exc = rmData.filter(d => d.ret<-d.var_).lengthconst l =legend([ {label:"Daily return",color:"#4682b4",type:"dot"}, {label:"VaR violation",color:"#d62728",type:"dot"}, {label:"−VaR",color:"#ff7f0e",type:"line"}, {label:"−ES",color:"#d62728",type:"line"} ])const p =html`<p style="color:#666; font-size:0.85rem;">VaR violations: ${exc} of ${nObs} (${(exc / nObs *100).toFixed(1)}% vs ${((1- confLevelRM) *100).toFixed(1)}% expected). Both thresholds adapt to changing volatility via the EWMA model (λ = ${lambdaRM.toFixed(2)}).</p>`returnhtml`${l}${p}`}
{const l =legend([ {label:"RiskMetrics σ (annualised)",color:"#4682b4",type:"line"}, {label:"Unconditional σ",color:"#ff7f0e",type:"dashed"} ])const p =html`<p style="color:#666; font-size:0.85rem;">The EWMA model captures volatility clustering (periods of high volatility tend to persist).</p>`returnhtml`${l}${p}`}
{const varSeries = rmData.map(d => d.var_)const esSeries = rmData.map(d => d.es)const sigSeries = rmData.map(d => d.sigma)const avg = arr => arr.reduce((a, b) => a + b,0) / arr.lengthconst min = arr =>Math.min(...arr)const max = arr =>Math.max(...arr)const exc = rmData.filter(d => d.ret<-d.var_).lengthreturnhtml`<table class="table" style="width:100%;"><thead><tr><th colspan="2">RiskMetrics VaR and ES (λ = ${lambdaRM.toFixed(2)}, α = ${(confLevelRM *100).toFixed(1)}%)</th></tr></thead><tbody><tr><td style="font-weight:500;">Average daily σ</td><td>${(avg(sigSeries) *100).toFixed(4)}%</td></tr><tr><td style="font-weight:500;">Min / Max daily σ</td><td>${(min(sigSeries) *100).toFixed(4)}% / ${(max(sigSeries) *100).toFixed(4)}%</td></tr><tr><td style="font-weight:500;">Average VaR</td><td>${(avg(varSeries) *100).toFixed(4)}%</td></tr><tr><td style="font-weight:500;">Average ES</td><td>${(avg(esSeries) *100).toFixed(4)}%</td></tr><tr><td style="font-weight:500;">Exceedances</td><td>${exc} of ${nObs} (${(exc / nObs *100).toFixed(1)}%)</td></tr><tr><td style="font-weight:500;">Expected exceedances</td><td>${(nObs * (1- confLevelRM)).toFixed(1)} (${((1- confLevelRM) *100).toFixed(1)}%)</td></tr></tbody></table><p style="color:#666; font-size:0.85rem;">The RiskMetrics model produces time-varying VaR and ES that adapt to market conditions. During volatile periods, the estimates widen; during calm periods, they narrow. Compare the exceedance rate with the expected rate to gauge model adequacy.</p>`}
4. Rolling window VaR and ES
An alternative to EWMA is a rolling (moving) window approach: compute the sample standard deviation over the most recent \(W\) days and use it to estimate VaR and ES. This gives all observations in the window equal weight, unlike EWMA which decays exponentially.
How rolling window estimation works
The idea is simple: on each day \(t\), look back at the most recent \(W\) returns and compute their sample standard deviation. This \(\hat\sigma_t\) is then used to estimate VaR and ES for the next day. As the window slides forward, old observations drop out and new ones enter, so the estimate adapts to changing conditions.
Tip
How to experiment
Drag the current day slider to move the window through the sample. Change the window size to see how a shorter window captures local volatility (but is noisier) while a longer window is smoother (but slower to react). The highlighted region shows exactly which returns are used to compute VaR and ES for the current day.
viewof windowSizeDemo = Inputs.range([20,500], {label:"Window size W (days)",step:10,value:250})
Plot.plot({height:400,marginLeft:60,marginRight:20,x: { label:"Date",type:"time" },y: { label:"Daily return",tickFormat: d => (d *100).toFixed(0) +"%",grid:true },marks: [// All returns (grey) Plot.dot(returnsRaw, {x:"date",y:"ret",fill:"#999",fillOpacity:0.15,r:1.2 }),// Window returns (blue) Plot.dot(returnsRaw.slice(windowDemo.winStart, windowDemo.winEnd), {x:"date",y:"ret",fill:"#4682b4",fillOpacity:0.6,r:2 }),// Window boundaries (vertical bands) Plot.rectY([{x1: windowDemo.dateStart,x2: windowDemo.dateEnd,y1:Math.min(...retValues) *1.05,y2:Math.max(...retValues) *1.05 }], {x1:"x1",x2:"x2",y1:"y1",y2:"y2",fill:"#4682b4",fillOpacity:0.06 }),// Current day marker Plot.dot([returnsRaw[windowDemo.idx]], {x:"date",y:"ret",fill:"#ff7f0e",r:6,stroke:"#fff",strokeWidth:2 }),// VaR line within window Plot.ruleY([-windowDemo.var99], {stroke:"#ff7f0e",strokeWidth:2,strokeDash: [6,3] }),// ES line within window Plot.ruleY([-windowDemo.es99], {stroke:"#d62728",strokeWidth:2 }), Plot.ruleY([0], { stroke:"#888",strokeOpacity:0.3 }) ]})
{const l =legend([ {label:"Outside window",color:"#999",type:"dot"}, {label:"In window",color:"#4682b4",type:"dot"}, {label:"Current day",color:"#ff7f0e",type:"dot"}, {label:"−VaR",color:"#ff7f0e",type:"dashed"}, {label:"−ES",color:"#d62728",type:"line"} ])const p =html`<p style="color:#666; font-size:0.85rem;">Window: ${windowDemo.dateStart.toISOString().slice(0,10)} to ${windowDemo.dateEnd.toISOString().slice(0,10)} (${windowDemo.W} days). Current day: ${windowDemo.currentDate.toISOString().slice(0,10)} (return = ${(windowDemo.currentRet*100).toFixed(2)}%). VaR and ES are computed from the window's σ.</p>`returnhtml`${l}${p}`}
html`<table class="table" style="width:100%;"><thead><tr><th colspan="2">Rolling window statistics at ${windowDemo.currentDate.toISOString().slice(0,10)} (W = ${windowDemo.W})</th></tr></thead><tbody><tr><td style="font-weight:500;">Window period</td><td>${windowDemo.dateStart.toISOString().slice(0,10)} to ${windowDemo.dateEnd.toISOString().slice(0,10)}</td></tr><tr><td style="font-weight:500;">Window mean μ̂</td><td>${(windowDemo.wMean*100).toFixed(4)}%</td></tr><tr><td style="font-weight:500;">Window σ̂</td><td>${(windowDemo.wSD*100).toFixed(4)}%</td></tr><tr><td style="font-weight:500;">Annualised σ̂</td><td>${(windowDemo.wSD*Math.sqrt(252) *100).toFixed(2)}%</td></tr><tr><td style="font-weight:500;">99% VaR</td><td style="font-weight:700;">${(windowDemo.var99*100).toFixed(4)}%</td></tr><tr><td style="font-weight:500;">99% ES</td><td style="font-weight:700;">${(windowDemo.es99*100).toFixed(4)}%</td></tr><tr><td style="font-weight:500;">Current day return</td><td style="font-weight:700; color:${windowDemo.currentRet<-windowDemo.var99?'#d62728':'#2ca02c'};">${(windowDemo.currentRet*100).toFixed(2)}% ${windowDemo.currentRet<-windowDemo.var99?"(VaR violation!)":""}</td></tr></tbody></table><p style="color:#666; font-size:0.85rem;">The window's sample standard deviation σ̂ is used to compute next-day VaR and ES under the normality assumption. As you slide through time, notice how σ̂ changes as volatile days enter or leave the window, and how this affects the risk estimates.</p>`
Full rolling window VaR and ES
We now apply this procedure to every day in the sample, producing a time series of VaR and ES estimates.
Tip
How to experiment
A short window (e.g. 60 days) reacts quickly to regime changes but produces noisy estimates.
A long window (e.g. 500 days) is smoother but slow to adapt.
Compare with the RiskMetrics line to see how the two approaches differ.
{const exc = rwData.filter(d => d.ret<-d.var_).lengthconst total = rwData.lengthconst l =legend([ {label:"Daily return",color:"#4682b4",type:"dot"}, {label:"VaR violation",color:"#d62728",type:"dot"}, {label:"−VaR",color:"#ff7f0e",type:"line"}, {label:"−ES",color:"#d62728",type:"line"} ])const p =html`<p style="color:#666; font-size:0.85rem;">W = ${windowSize} days. VaR violations: ${exc} of ${total} (${(exc / total *100).toFixed(1)}% vs ${((1- confLevelRW) *100).toFixed(1)}% expected). The first ${windowSize} observations initialise the window and are not shown.</p>`returnhtml`${l}${p}`}
// Merge rolling and RiskMetrics data on common dates for comparisoncomparisonRM = {const zAlpha =qnorm(confLevelRW)const pdfZ =normalPDF(zAlpha)const tailProb =1- confLevelRW// Recompute RiskMetrics with the rolling window's confidence levelconst n = retValues.lengthconst sig2 =newArray(n) sig2[0] = retValues.reduce((a, b) => a + (b - retMean) **2,0) / (n -1)for (let i =1; i < n; i++) { sig2[i] = lambdaRM * sig2[i -1] + (1- lambdaRM) * retValues[i -1] **2 }return returnsRaw.slice(windowSize).map((d, idx) => {const i = idx + windowSizeconst rmSigma =Math.sqrt(sig2[i])return {date: d.date,rwVar: rwData[idx].var_,rmVar: rmSigma * zAlpha,rwES: rwData[idx].es,rmES: rmSigma * pdfZ / tailProb } })}
{const l =legend([ {label:`Rolling window (W=${windowSize})`,color:"#4682b4",type:"line"}, {label:`RiskMetrics (λ=${lambdaRM.toFixed(2)})`,color:"#ff7f0e",type:"line"} ])const p =html`<p style="color:#666; font-size:0.85rem;">Both use the ${(confLevelRW *100).toFixed(1)}% confidence level. RiskMetrics reacts faster to volatility shocks because recent observations receive higher weight. The rolling window treats all W observations equally, creating a delayed response to regime changes.</p>`returnhtml`${l}${p}`}
{const varS = rwData.map(d => d.var_)const esS = rwData.map(d => d.es)const sigS = rwData.map(d => d.sigma)const avg = arr => arr.reduce((a, b) => a + b,0) / arr.lengthconst min = arr =>Math.min(...arr)const max = arr =>Math.max(...arr)const exc = rwData.filter(d => d.ret<-d.var_).lengthreturnhtml`<table class="table" style="width:100%;"><thead><tr><th colspan="2">Rolling window VaR and ES (W = ${windowSize}, α = ${(confLevelRW *100).toFixed(1)}%)</th></tr></thead><tbody><tr><td style="font-weight:500;">Average daily σ (rolling)</td><td>${(avg(sigS) *100).toFixed(4)}%</td></tr><tr><td style="font-weight:500;">Min / Max daily σ</td><td>${(min(sigS) *100).toFixed(4)}% / ${(max(sigS) *100).toFixed(4)}%</td></tr><tr><td style="font-weight:500;">Average VaR</td><td>${(avg(varS) *100).toFixed(4)}%</td></tr><tr><td style="font-weight:500;">Average ES</td><td>${(avg(esS) *100).toFixed(4)}%</td></tr><tr><td style="font-weight:500;">Exceedances</td><td>${exc} of ${rwData.length} (${(exc / rwData.length*100).toFixed(1)}%)</td></tr><tr><td style="font-weight:500;">Expected exceedances</td><td>${(rwData.length* (1- confLevelRW)).toFixed(1)} (${((1- confLevelRW) *100).toFixed(1)}%)</td></tr></tbody></table><p style="color:#666; font-size:0.85rem;">The rolling window gives equal weight to all observations in the window. A larger window produces smoother estimates but responds slowly to changing conditions. A shorter window is more responsive but noisier.</p>`}
5. Time horizon scaling
The square-root-of-time rule scales a 1-day risk measure to a \(T\)-day horizon:
This is exact when daily changes are i.i.d. normal with zero mean. We apply this to the most recent RiskMetrics and rolling window estimates.
Tip
How to experiment
Adjust the horizon to see how risk scales with time. At 10 days (the Basel regulatory horizon), VaR is roughly 3.16× the 1-day value. At 252 days (one year), it is about 15.9×. Remember: this assumes independence of daily returns.
viewof horizonT = Inputs.range([1,60], {label:"Horizon T (days)",step:1,value:10})
{const l =legend([ {label:"RM VaR",color:"#ff7f0e",type:"line"}, {label:"RM ES",color:"#d62728",type:"line"}, {label:"RW VaR",color:"#ff7f0e",type:"dashed"}, {label:"RW ES",color:"#d62728",type:"dashed"}, {label:`T = ${horizonT}`,color:"#888",type:"dashed"} ])const p =html`<p style="color:#666; font-size:0.85rem;">Solid lines: RiskMetrics. Dashed lines: rolling window. Both methods use the most recent 1-day volatility estimate as the starting point.</p>`returnhtml`${l}${p}`}
scalingTableHorizons = [1,2,5,10,20,60].filter(t => t <=60)
html`<table class="table" style="width:100%;"><thead><tr><th rowspan="2">T (days)</th><th rowspan="2">√T</th><th colspan="2">RiskMetrics (λ=${lambdaRM.toFixed(2)})</th><th colspan="2">Rolling (W=${windowSize})</th></tr><tr><th>VaR</th><th>ES</th><th>VaR</th><th>ES</th></tr></thead><tbody>${scalingTableHorizons.map(T => {const sqrtT =Math.sqrt(T)const highlight = T === horizonT ?' style="background:#fff3cd;"':''return`<tr${highlight}> <td style="font-weight:500;">${T}</td> <td>${sqrtT.toFixed(3)}</td> <td>${(latestRM.var_* sqrtT *100).toFixed(2)}%</td> <td>${(latestRM.es* sqrtT *100).toFixed(2)}%</td> <td>${(latestRW.var_* sqrtT *100).toFixed(2)}%</td> <td>${(latestRW.es* sqrtT *100).toFixed(2)}%</td> </tr>`}).join("")}</tbody></table><p style="color:#666; font-size:0.85rem;">All values use the most recent 1-day estimates scaled by the square-root-of-time rule. The highlighted row corresponds to the selected horizon T = ${horizonT}. The RiskMetrics 1-day σ is ${(latestRM.sigma*100).toFixed(4)}%; the rolling window σ is ${(latestRW.sigma*100).toFixed(4)}%.</p>`
NoteKey takeaways
Unconditional estimates use a single volatility for the entire sample, simple but unable to adapt to changing market conditions.
RiskMetrics (EWMA) reacts quickly to volatility shocks through exponential weighting, controlled by the decay parameter \(\lambda\).
Rolling windows give equal weight to all observations in the window, making it responsive with short windows, smooth with long ones.
Time horizon scaling via the \(\sqrt{T}\) rule is a quick approximation but assumes independent daily returns.
All three approaches assume normality, which underestimates tail risk for Tesla’s fat-tailed return distribution. More sophisticated methods (e.g. Student-\(t\) innovations, historical simulation) can address this.
References
Christoffersen, Peter F. 2012. Elements of Financial Risk Management. 2nd ed. Academic Press.
Hull, John. 2023. Risk Management and Financial Institutions. 6th ed. John Wiley & Sons.