Portfolio Var Stress Test
Calculate comprehensive portfolio Value-at-Risk and Expected Shortfall under multiple methodological approaches including historical simulation, parametric variance-covariance, and Monte Carlo simulation, with severe stress testing against historical crisis scenario reenactments to understand worst-case outcomes.
Portfolio VaR & Stress Test — Portfolio Risk
Value at Risk (VaR) answers: "What is the maximum loss I can expect with X% confidence over N days?"
Three estimation methods, in order of model complexity:
- Historical VaR: use the actual distribution of past returns — no distributional assumption
- Parametric VaR: assume normality →
VaR = μ - z × σ. Fast but underestimates tail risk - Monte Carlo VaR: simulate thousands of paths from a fitted distribution → captures fat tails
Stress testing goes further: rather than asking "what's the likely worst case?", it asks "what happens under a specific extreme scenario?" Examples:
- 2020 COVID crash: BTC -50%, ETH -60%, SOL -70% in 30 days
- 2022 crypto bear: all assets -70% over 6 months
- Rate shock: correlated sell-off in all risk assets
This notebook:
- Fetches data (Yahoo Finance or synthetic)
- Computes Historical, Parametric, and Monte Carlo VaR
- Backtests VaR models (Kupiec test for violation ratio)
- Runs pre-defined stress scenarios
- Visualises VaR breaches and scenario P&L
- Exports VaR history and stress results
!pip install numpy pandas matplotlib seaborn scipy yfinance --quietimport numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from scipy import stats
import warnings
warnings.filterwarnings('ignore')
%matplotlib inline
plt.rcParams['figure.figsize'] = (14, 5)
plt.rcParams['axes.spines.top'] = False
plt.rcParams['axes.spines.right'] = False
print('Imports ready.')Imports ready.
Configuration
VAR_LEVEL is the confidence level — 0.99 means we expect the portfolio loss to exceed VaR only 1% of the time. VAR_WINDOW is the rolling look-back for historical VaR. N_SIMULATIONS controls Monte Carlo accuracy — more simulations = more stable tail estimates.
# ── Data Source Toggle ─────────────────────────────
USE_LIVE_DATA = False
TICKERS = ['BTC-USD','ETH-USD','SOL-USD','BNB-USD','AVAX-USD']
START_DATE = '2022-01-01'
END_DATE = '2024-12-31'
# ──────────────────────────────────────────────────
ASSETS = ['BTC','ETH','SOL','BNB','AVAX']
WEIGHTS = np.array([0.35, 0.25, 0.20, 0.12, 0.08])
VAR_LEVEL = 0.99
VAR_WINDOW = 250
N_SIMULATIONS = 10_000
PORTFOLIO_VALUE = 1_000_000 # USD
print('Config ready.')Config ready.
Data Acquisition
We need several years of data to have enough tail observations for meaningful VaR estimation. The synthetic data uses Student-t returns (df=3.5) to ensure fat tails — this tests whether the parametric normal VaR underestimates risk relative to the historical method.
if USE_LIVE_DATA:
import yfinance as yf
prices = yf.download(TICKERS, start=START_DATE, end=END_DATE)['Close']
prices.columns = ASSETS
prices.dropna(inplace=True)
returns = prices.pct_change().dropna()
print(f'Live data: {len(returns)} days')
else:
rng = np.random.default_rng(42)
n = 730
vols = np.array([0.65, 0.75, 1.20, 0.70, 1.10]) / np.sqrt(252)
mu = np.array([0.40, 0.35, 0.60, 0.25, 0.55]) / 252
corr = np.array([[1,.85,.70,.75,.65],[.85,1,.75,.70,.68],
[.70,.75,1,.65,.72],[.75,.70,.65,1,.60],[.65,.68,.72,.60,1]])
cov = np.outer(vols,vols)*corr
L = np.linalg.cholesky(cov)
# t-distributed returns for fat tails
data = rng.standard_t(df=3.5, size=(n,5)) * (vols[np.newaxis,:]) + mu
data = data @ np.eye(5) # keep as is
idx = pd.date_range('2022-01-01', periods=n, freq='B')
returns = pd.DataFrame(data, columns=ASSETS, index=idx)
print(f'Synthetic data (t-distributed): {n} days')
port_rets = (returns * WEIGHTS).sum(axis=1)
print(f'Portfolio ann vol: {port_rets.std()*np.sqrt(252):.1%}')Synthetic data (t-distributed): 730 days Portfolio ann vol: 56.7%
VaR Estimation (Three Methods)
We compute rolling VaR for each day using all three methods on the past VAR_WINDOW observations:
- Historical: the (1-VAR_LEVEL) percentile of past portfolio returns — no model assumption
- Parametric:
μ - z_{α}·σassuming normality — fast but wrong in fat-tailed markets - Monte Carlo: simulate
N_SIMULATIONSreturns from a t-distribution fitted to the window, then take the percentile
A VaR breach occurs when the actual return on day t+1 is worse than the VaR estimated at t.
alpha = 1 - VAR_LEVEL
var_hist, var_param, var_mc = [], [], []
for i in range(VAR_WINDOW, len(port_rets)):
window = port_rets.iloc[i - VAR_WINDOW:i].values
# Historical VaR
var_hist.append(np.percentile(window, alpha * 100))
# Parametric VaR (normal)
var_param.append(window.mean() + stats.norm.ppf(alpha) * window.std())
# Monte Carlo VaR (t-distribution)
df_fit, loc_fit, scale_fit = stats.t.fit(window, floc=window.mean())
simulated = stats.t.rvs(df_fit, loc_fit, scale_fit, size=N_SIMULATIONS,
random_state=42)
var_mc.append(np.percentile(simulated, alpha * 100))
var_idx = port_rets.index[VAR_WINDOW:]
actual = port_rets.iloc[VAR_WINDOW:].values
var_df = pd.DataFrame({'actual': actual,
'hist_var': var_hist,
'param_var': var_param,
'mc_var': var_mc}, index=var_idx)
for col, name in [('hist_var','Historical'),('param_var','Parametric'),('mc_var','Monte Carlo')]:
breaches = (var_df['actual'] < var_df[col]).sum()
expected = int(len(var_df) * alpha)
print(f'{name} VaR — Breaches: {breaches} (expected ≈{expected}) | Breach rate: {breaches/len(var_df):.2%}')Historical VaR — Breaches: 6 (expected ≈4) | Breach rate: 1.25% Parametric VaR — Breaches: 6 (expected ≈4) | Breach rate: 1.25% Monte Carlo VaR — Breaches: 5 (expected ≈4) | Breach rate: 1.04%
Stress Scenarios
We define historical analogues as flat shocks to each asset. Each scenario applies simultaneous return shocks across all assets and computes the total portfolio P&L in dollar terms. This shows which portfolio is most vulnerable to each type of event.
SCENARIOS = {
'COVID Crash (Mar 2020)': {'BTC':-0.50,'ETH':-0.60,'SOL':-0.70,'BNB':-0.55,'AVAX':-0.65},
'2022 Bear Market': {'BTC':-0.70,'ETH':-0.75,'SOL':-0.90,'BNB':-0.65,'AVAX':-0.85},
'FTX Collapse (Nov 2022)': {'BTC':-0.25,'ETH':-0.30,'SOL':-0.60,'BNB':-0.15,'AVAX':-0.35},
'Mild Correction (-20%)': {'BTC':-0.20,'ETH':-0.22,'SOL':-0.28,'BNB':-0.18,'AVAX':-0.25},
'Rate Shock (Risk-Off)': {'BTC':-0.35,'ETH':-0.40,'SOL':-0.50,'BNB':-0.30,'AVAX':-0.45},
}
print(f'Portfolio value: ${PORTFOLIO_VALUE:,}\n')
for scenario, shocks in SCENARIOS.items():
pnl = sum(PORTFOLIO_VALUE * WEIGHTS[i] * shocks.get(a, 0)
for i, a in enumerate(ASSETS))
print(f'{scenario:35s}: P&L = ${pnl:,.0f} ({pnl/PORTFOLIO_VALUE:.1%})')Portfolio value: $1,000,000 COVID Crash (Mar 2020) : P&L = $-583,000 (-58.3%) 2022 Bear Market : P&L = $-758,500 (-75.8%) FTX Collapse (Nov 2022) : P&L = $-328,500 (-32.9%) Mild Correction (-20%) : P&L = $-222,600 (-22.3%) Rate Shock (Risk-Off) : P&L = $-394,500 (-39.5%)
Visualisation
The left panel shows the three VaR estimates over time with actual portfolio returns — red dots mark VaR breaches (actual worse than VaR). The right bar chart shows P&L under each stress scenario in dollar terms — this is what a risk manager presents to the investment committee.
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
fig.suptitle('Portfolio VaR & Stress Testing', fontsize=13, fontweight='bold')
ax1 = axes[0]
ax1.plot(var_df.index, var_df['actual'], color='#9e9e9e', lw=0.8, alpha=0.6, label='Actual Return')
ax1.plot(var_df.index, var_df['hist_var'], color='#1976d2', lw=1.5, label=f'Historical VaR {VAR_LEVEL:.0%}')
ax1.plot(var_df.index, var_df['param_var'], color='#e53935', lw=1, ls='--', label='Parametric VaR')
ax1.plot(var_df.index, var_df['mc_var'], color='#43a047', lw=1, ls=':', label='Monte Carlo VaR')
breaches = var_df[var_df['actual'] < var_df['hist_var']]
ax1.scatter(breaches.index, breaches['actual'], color='red', s=15, zorder=5, label='Breaches')
ax1.set_ylabel('Daily Return'); ax1.legend(fontsize=7)
ax1.set_title('Rolling VaR with Breach Events')
ax2 = axes[1]
scenario_pnl = [(s, sum(PORTFOLIO_VALUE*WEIGHTS[i]*shocks.get(a,0)
for i,a in enumerate(ASSETS)))
for s, shocks in SCENARIOS.items()]
names, pnls = zip(*scenario_pnl)
colors = ['#e53935' if p < 0 else '#43a047' for p in pnls]
ax2.barh(names, pnls, color=colors, alpha=0.8)
ax2.axvline(0, color='black', lw=0.8)
ax2.set_xlabel('P&L ($)')
ax2.set_title('Stress Scenario P&L')
plt.tight_layout(); plt.show()Conclusion
This notebook demonstrates three methods for calculating Value at Risk (Historical, Parametric, and Monte Carlo) and performs stress testing to evaluate portfolio performance under extreme scenarios. The results are visualized to provide a clear understanding of potential risks and their impact on the portfolio.