// Seeded PRNG (Mulberry32)
prng = {
function mulberry32 (seed) {
return function () {
seed |= 0 ; seed = seed + 0x6D2B79F5 | 0
let t = Math . imul (seed ^ seed >>> 15 , 1 | seed)
t = t + Math . imul (t ^ t >>> 7 , 61 | t) ^ t
return ((t ^ t >>> 14 ) >>> 0 ) / 4294967296
}
}
function boxMuller (rng) {
const u1 = rng (), u2 = rng ()
return Math . sqrt (- 2 * Math . log (u1)) * Math . cos (2 * Math . PI * u2)
}
return { mulberry32, boxMuller }
}
fmt = (x, d) => x === undefined || isNaN (x) ? "N/A" : x. toFixed (d)
// Clickable legend: returns a Set of hidden keys
legend = (items) => {
const el = document . createElement ("div" )
el. style . cssText = "display:flex; flex-wrap:wrap; margin-top:-4px; margin-bottom:6px;"
const hidden = new Set ()
for (const d of items) {
const key = d. key || d. label
const span = document . createElement ("span" )
span. style . cssText = "display:inline-flex; align-items:center; gap:4px; margin-right:14px; cursor:pointer; user-select:none; transition:opacity 0.15s;"
let swatchHTML
if (d. type === "dot" ) {
swatchHTML = `<svg width="12" height="12"><circle cx="6" cy="6" r="5" fill=" ${ d. color } " opacity="0.8"/></svg>`
} else if (d. type === "dashed" ) {
swatchHTML = `<svg width="22" height="12"><line x1="0" y1="6" x2="22" y2="6" stroke=" ${ d. color } " stroke-width="2" stroke-dasharray="4 2"/></svg>`
} else if (d. type === "rect" ) {
swatchHTML = `<svg width="14" height="14"><rect width="14" height="14" fill=" ${ d. color } "/></svg>`
} else {
swatchHTML = `<svg width="22" height="12"><line x1="0" y1="6" x2="22" y2="6" stroke=" ${ d. color } " stroke-width="2"/></svg>`
}
span. innerHTML = ` ${ swatchHTML} <span style="font-size:0.82rem;"> ${ d. label } </span>`
span. addEventListener ("click" , () => {
const nowHidden = ! hidden. has (key)
if (nowHidden) hidden. add (key); else hidden. delete (key)
span. style . opacity = nowHidden ? "0.35" : "1"
span. querySelector ("span" ). style . textDecoration = nowHidden ? "line-through" : "none"
el. value = new Set (hidden)
el. dispatchEvent (new Event ("input" , {bubbles : true }))
})
el. appendChild (span)
}
el. value = new Set (hidden)
return el
}
Stress Testing Integration
VaR and ES models are inherently backward looking : they assume past data are a good guide to near-future behavior. Stress testing is forward looking , considering extreme but plausible scenarios that may not appear in the historical record (Christoffersen 2012, chap. 13 , section 6; Hull 2023, chap. 16 ) .
Standard stress testing defines scenarios and evaluates their impact, but without assigning probabilities, it is unclear how the portfolio manager should react. Coherent stress testing solves this by assigning a probability \(\alpha\) to each stress scenario and combining the scenario distribution with the historical data:
\[
f_{comb}(\cdot) = \begin{cases} f(\cdot), & \text{with probability } (1-\alpha) \\ f_{stress}(\cdot), & \text{with probability } \alpha \end{cases}
\]
Once scenario probabilities are assigned, we can compute VaR and ES from the combined distribution by ranking all scenarios (historical and stress) by loss, assigning their probabilities, and computing cumulative probabilities from the worst scenario to the best.
Why assign probabilities? Without probabilities, the portfolio manager may overreact to an extremely unlikely scenario or underreact to a less extreme but more frequent one. Assigning probabilities also enables backtesting of the combined model.
Historical scenarios. The historical simulation scenarios are generated from a GARCH(1,1) process: \(R_t = \sigma_t z_t\) with \(z_t \sim N(0,1)\) and \(\sigma^2_{t+1} = \omega + \alpha R_t^2 + \beta \sigma^2_t\) . This produces realistic fat-tailed returns with volatility clustering, representing the “historical” data available to the risk manager. Losses are defined as \(-R_t\) , so positive values represent adverse outcomes.
How to experiment
Start with the default 3 stress scenarios and observe where they fall in the ranked loss table relative to the historical simulation scenarios. Increase the loss magnitudes or probabilities of the stress scenarios and watch how VaR and ES shift. Try reducing the number of HS scenarios to 250 to see how stress scenarios gain more weight in the tail.
stConfNum = stConf === "99%" ? 0.99 : 0.95
stTailProb = 1 - stConfNum
// Generate HS scenarios from GARCH(1,1) process
stHSData = {
stSeed // reactivity
const rng = prng. mulberry32 (99 + stSeed)
const n = stNHS
const alpha = stGARCHAlpha, beta = stGARCHBeta
const vl = (stLRVol) ** 2 // long-run variance in ($000s)^2
const omega = vl * (1 - alpha - beta)
const losses = []
let sig2 = vl
for (let t = 0 ; t < n; t++ ) {
const z = prng. boxMuller (rng)
const ret = Math . sqrt (sig2) * z
losses. push (- ret) // loss = -return (positive loss = bad)
sig2 = omega + alpha * ret * ret + beta * sig2
}
return losses
}
// Build stress scenarios
stStressScenarios = {
const extra = stExtraScenarios
const scenarios = [
{ loss : stLoss1, prob : stProb1, label : "s1" },
{ loss : extra[0 ]. loss , prob : extra[0 ]. prob , label : "s2" },
{ loss : extra[1 ]. loss , prob : extra[1 ]. prob , label : "s3" },
{ loss : extra[2 ]. loss , prob : extra[2 ]. prob , label : "s4" },
{ loss : extra[3 ]. loss , prob : extra[3 ]. prob , label : "s5" }
]
return scenarios. slice (0 , stNStress)
}
stPStressTotal = stStressScenarios. reduce ((a, s) => a + s. prob , 0 )
// Combine HS and stress scenarios, rank by loss
stCombined = {
const hsProb = (1 - stPStressTotal) / stNHS
const all = []
// HS scenarios
for (let i = 0 ; i < stHSData. length ; i++ ) {
all. push ({ loss : stHSData[i], prob : hsProb, label : `v ${ i + 1 } ` , type : "HS" })
}
// Stress scenarios
for (const s of stStressScenarios) {
all. push ({ loss : s. loss , prob : s. prob , label : s. label , type : "Stress" })
}
// Sort by loss descending (worst first)
all. sort ((a, b) => b. loss - a. loss )
// Compute cumulative probability
let cumProb = 0
for (const s of all) {
cumProb += s. prob
s. cumProb = cumProb
}
return all
}
// Compute VaR and ES from combined and HS-only
stRiskMeasures = {
const tailProb = stTailProb
// Combined VaR: first loss where cumProb > tailProb
let varCombined = 0
for (const s of stCombined) {
if (s. cumProb >= tailProb) { varCombined = s. loss ; break }
}
// Combined ES: prob-weighted average of losses beyond VaR
let esNum = 0 , esDen = 0
for (const s of stCombined) {
if (s. cumProb <= tailProb) {
esNum += s. loss * s. prob
esDen += s. prob
} else {
// Include partial contribution at the boundary
const remaining = tailProb - (s. cumProb - s. prob )
if (remaining > 0 ) {
esNum += s. loss * remaining
esDen += remaining
}
break
}
}
const esCombined = esDen > 0 ? esNum / esDen : varCombined
// HS-only: equal-weight ranking
const hsOnly = stHSData. slice (). sort ((a, b) => b - a)
const hsN = hsOnly. length
const hsIdx = Math . ceil (hsN * tailProb)
const varHS = hsOnly[Math . min (hsIdx - 1 , hsN - 1 )]
const esHS = hsOnly. slice (0 , hsIdx). reduce ((a, b) => a + b, 0 ) / hsIdx
return { varCombined, esCombined, varHS, esHS }
}
{
const top = stCombined. slice (0 , 25 )
const tailProb = stTailProb
let varIdx = - 1
for (let i = 0 ; i < top. length ; i++ ) {
if (top[i]. cumProb >= tailProb && varIdx < 0 ) varIdx = i
}
const rows = top. map ((s, i) => {
const isVarRow = i === varIdx
const isStress = s. type === "Stress"
const bg = isVarRow ? "background:#fff3cd;" : (isStress ? "background:#fff0f0;" : "" )
return `<tr style="border-bottom:1px solid #eee; ${ bg} ">
<td style="padding:3px 6px;text-align:center;"> ${ i + 1 } </td>
<td style="padding:3px 6px;font-weight: ${ isStress ? '600' : '400' } ;color: ${ isStress ? '#d62728' : '#333' } ;"> ${ s. label } </td>
<td style="padding:3px 6px;text-align:right;"> ${ fmt (s. loss , 2 )} </td>
<td style="padding:3px 6px;text-align:right;"> ${ (s. prob * 100 ). toFixed (3 )} %</td>
<td style="padding:3px 6px;text-align:right;"> ${ (s. cumProb * 100 ). toFixed (3 )} %</td>
</tr>`
}). join ("" )
return html `<div style="max-height:500px;overflow-y:auto;">
<table style="font-size:0.85rem;border-collapse:collapse;width:100%;">
<thead>
<tr style="border-bottom:2px solid #333;position:sticky;top:0;background:white;">
<th style="padding:3px 6px;text-align:center;">Rank</th>
<th style="padding:3px 6px;text-align:left;">Scenario</th>
<th style="padding:3px 6px;text-align:right;">Loss ($000s)</th>
<th style="padding:3px 6px;text-align:right;">Probability</th>
<th style="padding:3px 6px;text-align:right;">Cum. prob.</th>
</tr>
</thead>
<tbody> ${ rows} </tbody>
</table>
</div>
<p style="color:#666;font-size:0.82rem;margin-top:6px;">
<span style="background:#fff3cd;padding:1px 6px;">Yellow row</span> = VaR threshold (first loss where cum. prob. ≥ ${ (tailProb* 100 ). toFixed (1 )} %).
<span style="color:#d62728;font-weight:600;">Red labels</span> = stress scenarios.
</p>`
}
{
const r = stRiskMeasures
const deltaVar = r. varCombined - r. varHS
const deltaES = r. esCombined - r. esHS
return html `<table style="font-size:0.9rem;border-collapse:collapse;width:100%;max-width:600px;">
<thead>
<tr style="border-bottom:2px solid #333;">
<th style="text-align:left;padding:4px 8px;">Measure</th>
<th style="text-align:right;padding:4px 8px;">HS only ($000s)</th>
<th style="text-align:right;padding:4px 8px;">With stress ($000s)</th>
<th style="text-align:right;padding:4px 8px;">Change ($000s)</th>
</tr>
</thead>
<tbody>
<tr style="border-bottom:1px solid #eee;">
<td style="padding:4px 8px;font-weight:500;">VaR ( ${ stConf} )</td>
<td style="text-align:right;padding:4px 8px;"> ${ fmt (r. varHS , 2 )} </td>
<td style="text-align:right;padding:4px 8px;"> ${ fmt (r. varCombined , 2 )} </td>
<td style="text-align:right;padding:4px 8px;color: ${ deltaVar > 0 ? '#d62728' : '#2e8b57' } ;"> ${ deltaVar > 0 ? '+' : '' }${ fmt (deltaVar, 2 )} </td>
</tr>
<tr>
<td style="padding:4px 8px;font-weight:500;">ES ( ${ stConf} )</td>
<td style="text-align:right;padding:4px 8px;"> ${ fmt (r. esHS , 2 )} </td>
<td style="text-align:right;padding:4px 8px;"> ${ fmt (r. esCombined , 2 )} </td>
<td style="text-align:right;padding:4px 8px;color: ${ deltaES > 0 ? '#d62728' : '#2e8b57' } ;"> ${ deltaES > 0 ? '+' : '' }${ fmt (deltaES, 2 )} </td>
</tr>
</tbody>
</table>
<p style="color:#666;font-size:0.85rem;margin-top:8px;">
Active stress scenarios: ${ stNStress} . Total stress probability: ${ (stPStressTotal * 100 ). toFixed (2 )} %.
Each HS scenario probability: ${ ((1 - stPStressTotal) / stNHS * 100 ). toFixed (4 )} %.
</p>`
}
viewof stCDFLegend = legend ([
{ key : "hs" , label : "HS only" , color : "#2f71d5" , type : "line" },
{ key : "comb" , label : "Combined (with stress)" , color : "#d62728" , type : "line" },
{ key : "tail" , label : `Tail probability ( ${ fmt (stTailProb* 100 , 1 )} %)` , color : "#999" , type : "dashed" }
])
{
const h = stCDFLegend
// Build CDF data for HS-only and combined
const hsOnly = stHSData. slice (). sort ((a, b) => b - a)
const hsN = hsOnly. length
const hsCDF = hsOnly. map ((loss, i) => ({ loss, cumProb : (i + 1 ) / hsN }))
const combCDF = stCombined. map (s => ({ loss : s. loss , cumProb : s. cumProb }))
const r = stRiskMeasures
return Plot. plot ({
height : 380 ,
marginLeft : 60 ,
x : { label : "Loss ($000s)" , grid : true },
y : { label : "Cumulative probability" , grid : true },
marks : [
... (! h. has ("hs" ) ? [
Plot. line (hsCDF, { x : "loss" , y : "cumProb" , stroke : "#2f71d5" , strokeWidth : 1.5 }),
Plot. ruleX ([r. varHS ], { stroke : "#2f71d5" , strokeDasharray : "6 3" , strokeWidth : 1.5 })
] : []),
... (! h. has ("comb" ) ? [
Plot. line (combCDF, { x : "loss" , y : "cumProb" , stroke : "#d62728" , strokeWidth : 1.5 }),
Plot. ruleX ([r. varCombined ], { stroke : "#d62728" , strokeDasharray : "6 3" , strokeWidth : 1.5 })
] : []),
... (! h. has ("tail" ) ? [
Plot. ruleY ([stTailProb], { stroke : "#999" , strokeDasharray : "4 2" })
] : [])
]
})
}
html `<p style="color:#666;font-size:0.85rem;">Vertical dashed lines mark VaR levels. The combined distribution shifts the upper tail when stress scenarios enter the worst losses. Click legend items to show/hide series.</p>`
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.