\[ % MathJax has no bm package; redefine \bm in terms of \boldsymbol, which it supports natively \newcommand{\bm}[1]{\boldsymbol{#1}} % --- Operators ----------------------------------------------------------- % Expectation operator \DeclareMathOperator{\E}{E} % Variance operator \DeclareMathOperator{\Var}{Var} % Covariance operator \DeclareMathOperator{\Cov}{Cov} % Correlation operator \DeclareMathOperator{\Corr}{Corr} % Rank operator (Spearman rank correlation) \DeclareMathOperator{\Rank}{Rank} % Skewness operator \DeclareMathOperator{\Skewness}{Skewness} % Kurtosis operator \DeclareMathOperator{\Kurtosis}{Kurtosis} % Difference/differential operator (upright d per ISO 80000-2) \newcommand{\Diff}{\mathrm{d}} % --- Risk Measures ------------------------------------------------------- % Value at Risk \DeclareMathOperator{\VaR}{VaR} % Expected Shortfall \DeclareMathOperator{\ES}{ES} % Marginal Value at Risk \DeclareMathOperator{\MVaR}{MVaR} % Component Value at Risk \DeclareMathOperator{\CompVaR}{CVaR} % Incremental Value at Risk \DeclareMathOperator{\IVaR}{IVaR} % Component Expected Shortfall \DeclareMathOperator{\CompES}{CES} % Weighted sensitivity for risk factor in FRTB SBM bucket \DeclareMathOperator{\WS}{WS} % Hedge-benefit ratio for FRTB DRC bucket \DeclareMathOperator{\HBR}{HBR} % Stress scenario risk measure for FRTB NMRF capital add-on \DeclareMathOperator{\SES}{SES} % Jump-to-default exposure for obligor (FRTB DRC) \DeclareMathOperator{\JTD}{JTD} % Loss given default for obligor (FRTB DRC) \DeclareMathOperator{\LGD}{LGD} % Default risk weight for obligor (FRTB DRC) \DeclareMathOperator{\RW}{RW} % ES ratio function \newcommand{\ESratio}{\lambda} % Arbitrary risk measure function (coherence axioms) \newcommand{\RiskMeasure}{\varphi} % Asset position (coherence axioms) \newcommand{\Asset}{A} % First named asset in examples \newcommand{\AssetA}{A} % Second named asset in examples \newcommand{\AssetB}{B} % Third named asset in examples \newcommand{\AssetC}{C} % Constant (risk measure axioms) \newcommand{\Constant}{c} % Bucket-level aggregate sensitivity in FRTB SBM \newcommand{\BucketAgg}{S} % Cross-bucket correlation in FRTB SBM \newcommand{\CrossBucketCorr}{\gamma} % Notional amount for an instrument (FRTB DRC and RRAO) \newcommand{\Notional}{\text{Notional}} % --- Distributions ------------------------------------------------------- % Binomial distribution \DeclareMathOperator{\Binomial}{Binomial} % Uniform distribution \DeclareMathOperator{\Uniform}{Uniform} % Normal distribution \newcommand{\NormalDist}{\mathcal{N}} % Student-t CDF \newcommand{\StudentCDF}{t} % Student-t PDF \newcommand{\StudentPDF}{f} % --- Returns ------------------------------------------------------------- % Price \newcommand{\Price}{P} % Simple (arithmetic) return \newcommand{\SimpleReturns}{R} % Log (compound) return \newcommand{\CompoundReturns}{y} % Matrix of compound returns \newcommand{\ReturnMatrix}{\bm{y}} % Dividend payment \newcommand{\dividend}{d} % --- Volatility ---------------------------------------------------------- % Realised variance \DeclareMathOperator{\RealisedVar}{RV} % Realised volatility (square root of realised variance) \DeclareMathOperator{\RealisedVol}{RVol} % Volatility \newcommand{\Vol}{\sigma} % GARCH intercept parameter \newcommand{\GARCHconst}{\omega} % ARCH coefficient (weight on past squared returns) \newcommand{\ARCHcoeff}{\alpha} % GARCH coefficient (weight on past variance) \newcommand{\GARCHcoeff}{\beta} % EWMA decay factor (typically 0.94) \newcommand{\EWMAdecay}{\lambda} % Standardized residual / error term \newcommand{\StdNormal}{\epsilon} % Leverage parameter in apARCH \newcommand{\APARCHleverage}{\zeta} % Power parameter in apARCH \newcommand{\APARCHpower}{\delta} % Degrees of freedom (Student-t) \newcommand{\DOF}{\nu} % GJR-GARCH leverage parameter \newcommand{\GJRleverage}{\gamma} % GARCH-X external regressor coefficient \newcommand{\RegressorCoeff}{\xi} % Mean (first moment) \newcommand{\Mean}{\mu} % Lag order in volatility models \newcommand{\Lag}{L} % Dummy variable (indicator in regressions) \newcommand{\DummyVar}{D} % --- Portfolio ----------------------------------------------------------- % Portfolio weight (scalar) \newcommand{\weight}{w} % Portfolio weight vector \newcommand{\weights}{\bm{w}} % Covariance matrix \newcommand{\CovMatrix}{\bm{\Sigma}} % Correlation matrix \newcommand{\CorrMatrix}{\bm{C}} % Correlation coefficient \newcommand{\correlation}{\rho} % Number of assets in portfolio \newcommand{\NumberAssets}{K} % Portfolio value \newcommand{\PortfolioValue}{\vartheta} % Asset beta (CAPM) \newcommand{\AssetBeta}{\beta} % Ledoit-Wolf shrinkage intensity \newcommand{\ShrinkageIntensity}{\delta} % Sample covariance matrix \newcommand{\SampleCov}{\bm{S}} % --- Time Series --------------------------------------------------------- % Sample size \newcommand{\SampleSize}{T} % Generic count (number of lags, payments, etc.) \newcommand{\Count}{N} % Estimation window length \newcommand{\EstWindow}{W_E} % Testing window length \newcommand{\TestWindow}{W_T} % Stress window length \newcommand{\StressWindow}{W_S} % AR coefficient \newcommand{\ARcoeff}{\phi} % MA coefficient \newcommand{\MAcoeff}{\psi} % --- Probability --------------------------------------------------------- % Probability level (e.g. 0.01 for 1% VaR) \newcommand{\probability}{p} % Cumulative sorted scenario weight \newcommand{\CumScenarioWeight}{\Omega} % p-quantile of the P and L distribution \newcommand{\Quantile}{q} % Quantile function \newcommand{\QuantileFunction}{Q} % Probability density function \newcommand{\PDF}{f} % Cumulative distribution function \newcommand{\CDF}{F} % Standard normal CDF \newcommand{\NormalCDF}{\Phi} % Inverse standard normal (quantile function) \newcommand{\NormalQuantile}{\Phi^{-1}} % Standard normal density function \newcommand{\NormalPDF}{\phi} % Likelihood function \newcommand{\lik}{\mathcal{L}} % Log-likelihood \newcommand{\LogLikelihood}{\ell} % Parameter vector (MLE estimation) \newcommand{\ParamSet}{\theta} % Parameter space \newcommand{\ParamSpace}{\Theta} % Fisher information matrix \newcommand{\FisherInfo}{\mathcal{I}} % Statistical power (Type II error rate) \newcommand{\StatPower}{\beta} % Significance level (CI coverage complement) \newcommand{\SignifLevel}{\gamma} % --- Options ------------------------------------------------------------- % Call option label \newcommand{\CallOption}{\text{call}} % Put option label \newcommand{\PutOption}{\text{put}} % Strike price \newcommand{\Strike}{X} % Risk-free interest rate \newcommand{\RiskFree}{r_f} % Option maturity (time to expiration) \newcommand{\OptionMaturity}{\tau} % Option delta \newcommand{\OptionDelta}{\Delta} % Option gamma \newcommand{\OptionGamma}{\Gamma} % Option vega \newcommand{\Vega}{\mathcal{V}} % Holding period \newcommand{\HoldingPeriod}{H} % Simulation profit/loss (signed; negative is a loss) \newcommand{\ProfitLoss}{\Pi} % Futures price \newcommand{\Futures}{F} % Hedge ratio \newcommand{\HedgeRatio}{h} % Units of basic asset held \newcommand{\StockHolding}{x^b} % Units of options held \newcommand{\OptionHolding}{x^o} % --- Interest Rates ------------------------------------------------------ % Bond convexity \newcommand{\Convexity}{C} % Interest rate / yield \newcommand{\Yields}{r} % Key rate change, basis points (scalar tenor component); used as \KeyRateChange_k or \KeyRateChange_{t,k}. EXCEPTION to the no-macro-to-macro-indirection rule, decided 2026-08-09: composed from \Yields on purpose, so a future change to the rate glyph propagates here without a follow-up edit. \Yields is defined earlier in generated output (same interest_rates category, TOML order), so expansion order is safe; verify this still holds if either entry is reordered. \newcommand{\KeyRateChange}{\Delta \Yields^{\mathrm{bp}}} % Key rate changes, basis points (vector); used bare or as \KeyRateChanges_t. EXCEPTION to the no-macro-to-macro-indirection rule, decided 2026-08-09: composed from \Yields on purpose, so a future change to the rate glyph propagates here without a follow-up edit. \bm{\Yields} depends on \bm/\boldsymbol correctly expanding a macro argument rather than a bare letter --- confirm this renders correctly the first time either consumer compiles it. \newcommand{\KeyRateChanges}{\Delta \bm{\Yields}^{\mathrm{bp}}} % Dollar value of a basis point \newcommand{\DV}{\text{DV01}} % Mean reversion speed (interest rate models) \newcommand{\MeanReversion}{\varkappa} % Modified duration \newcommand{\ModDur}{D} % Macaulay duration \newcommand{\MacDur}{D_{\text{Mac}}} % Effective duration \newcommand{\EffDur}{D_{\text{eff}}} % DV01 vector \newcommand{\DVvec}{\bm{d}} % Long-run mean rate (Vasicek/CIR/Hull-White) \newcommand{\LongRunRate}{\theta} % Hull-White time-varying drift \newcommand{\HullWhiteDrift}{\varphi} % Cash flow (coupon/principal payment) \newcommand{\CashFlow}{c} % --- Extreme Value ------------------------------------------------------- % Tail index (EVT, Pareto-type tails) \newcommand{\TailIndex}{\iota} % Shape parameter (xi = 1/iota) \newcommand{\ShapeParam}{\varsigma} % Threshold value (EVT) \newcommand{\Threshold}{u} % Extremal index (Leadbetter 1983) \newcommand{\ExtremalIndex}{\theta} % Multivariate extremal coefficient, theta in [1,d]. theta=1 complete tail dependence; theta=d asymptotic independence. Glyph collides with ExtremalIndex and ParamSet; disambiguated at source by macro name. \newcommand{\ExtremalCoef}{\theta} % GEV distribution function \newcommand{\GEV}{\mathcal{H}} % GPD distribution function \newcommand{\GPD}{\mathcal{G}} % GPD scale parameter \newcommand{\GPDscale}{\beta} % GPD scale parameter at a threshold u, distinct from the generic GPD scale \newcommand{\GPDscaleAtThreshold}{\beta_u} % Number of threshold exceedances (EVT) \newcommand{\TailCount}{C} % Sample maximum (block maxima, EVT) \newcommand{\SampleMax}{M} % Kurtosis value (realized, e.g. sample kurtosis) \newcommand{\KurtosisVal}{\kappa} % Pareto scaling constant (EVT) \newcommand{\ParetoConst}{\mathcal{A}} % Generic constant (EVT asymptotics) \newcommand{\RemainderConst}{\mathcal{C}} % Little-o asymptotic notation \newcommand{\LittleO}{o} % Moment order (EVT) \newcommand{\MomentOrder}{\mathscr{m}} % Doubled sample size (EVT block maxima) \newcommand{\DoubleSample}{D} % GEV normalizing location constant \newcommand{\GEVloc}{a} % GEV normalizing scale constant \newcommand{\GEVscale}{b} % Negative return (loss, EVT context) \newcommand{\NegativeReturn}{L} % --- Copulas ------------------------------------------------------------- % Copula function \newcommand{\Copula}{\mathcal{C}} % Copula density \newcommand{\CopulaDensity}{c} % Generator function (Archimedean copulas) \newcommand{\generator}{\varphi} % Copula dependence parameter \newcommand{\CopulaParam}{\theta} % Copula marginal parameter set \newcommand{\MarginalParamSet}{\eta} % Lower tail dependence coefficient \newcommand{\LowerTailDep}{\lambda_L} % Upper tail dependence coefficient \newcommand{\UpperTailDep}{\lambda_U} % Marginal CDF (second variable) \newcommand{\MarginalCDF}{G} % Joint density function \newcommand{\JointDensity}{h} % Marginal density function \newcommand{\MarginalDensity}{g} % Joint distribution function \newcommand{\JointCDF}{H} % Gaussian copula correlation parameter \newcommand{\CopulaCorr}{\rho} % --- Multivariate -------------------------------------------------------- % Diagonal matrix of conditional volatilities \newcommand{\DiagVolD}{\bm{D}} % Idiosyncratic variance matrix (diagonal, factor models) \newcommand{\IdioVarMatrix}{\bm{\Psi}} % Covariance matrix entry (two indices) \newcommand{\CovElement}{\sigma} % Auxiliary matrix in DCC dynamics \newcommand{\DCCauxQ}{\bm{Q}} % BEKK constant matrix \newcommand{\BEKKconst}{\bm{\Omega}} % BEKK ARCH parameter matrix \newcommand{\BEKKarch}{\bm{A}} % BEKK GARCH parameter matrix \newcommand{\BEKKgarch}{\bm{B}} % DCC rescaling diagonal matrix \newcommand{\DCCrescaleZ}{\bm{Z}} % DCC auxiliary matrix element \newcommand{\DCCelement}{q} % Factor loadings matrix (loadings/eigenvectors of the covariance matrix) \newcommand{\FactorLoadings}{\bm{\Lambda}} % Factor subscript label \newcommand{\factor}{\text{factor}} % PCA eigenvalue \newcommand{\Eigenvalue}{\lambda} % Number of factors in factor model \newcommand{\NumberFactors}{m} % Factor return vector \newcommand{\FactorReturn}{\bm{g}} % PCA factor score (scalar; not an eigenvector) \newcommand{\PCAFactor}{F} % BEKK cross-covariance coefficient \newcommand{\BEKKcross}{\delta} % Factor loading element \newcommand{\FactorLoading}{\Lambda} % DCC correlation persistence parameter \newcommand{\DCCxi}{\xi} % DCC news coefficient (weight on recent shocks) \newcommand{\DCCzeta}{\zeta} % --- Simulation ---------------------------------------------------------- % Number of simulation paths \newcommand{\NumberSims}{B} % Cholesky factor of covariance matrix \newcommand{\Cholesky}{\bm{L}} % Uniform random number \newcommand{\UniformDraw}{\tilde{u}} % --- Backtesting --------------------------------------------------------- % Violation ratio (observed/expected violations) \DeclareMathOperator{\ViolRatio}{VR} % Stressed Value at Risk \DeclareMathOperator{\StressedVaR}{SVaR} % QLIKE loss function for variance forecasts \DeclareMathOperator{\QLIKE}{QLIKE} % Mean squared error (forecast loss function) \DeclareMathOperator{\MSE}{MSE} % Mean absolute error (forecast loss function) \DeclareMathOperator{\MAE}{MAE} % Markov transition probability \newcommand{\TransProb}{\pi} % Exception indicator: 1 if y_t < -VaR_t \newcommand{\ExceptionInd}{\eta} % Exception count \newcommand{\ExceptionCount}{\upsilon} % Probability Integral Transform \newcommand{\ProbIntegralTransform}{\hat{u}} % Quantile score (pinball loss function) \newcommand{\QuantileScore}{\mathcal{S}} % Kolmogorov-Smirnov test statistic \newcommand{\KSstat}{D} % Test statistic (generic base letter) \newcommand{\TestStat}{J} % --- General ------------------------------------------------------------- % Regression intercept \newcommand{\RegressionIntercept}{a} % Regression slope \newcommand{\RegressionSlope}{\beta} % Risk factor \newcommand{\RiskFactor}{x} % Forecast horizon \newcommand{\ForecastHorizon}{h} % Block length (bootstrap) \newcommand{\BlockLength}{\ell} % Capital tau variant \newcommand{\DeliveryTime}{\Upsilon} % Today's calendar time in years (option pricing input; distinct from trading-date index t) \newcommand{\CalendarTime}{t^*} % Probability measure \newcommand{\ProbMeasure}{\mathbb{P}} % Risk-neutral measure \newcommand{\RiskNeutral}{\mathbb{Q}} % Pricing function (Black-Scholes, bond pricing, etc.) \newcommand{\PricingFn}{V} % Indicator function \newcommand{\Indicator}{\bm{1}} % Vector of ones \newcommand{\OnesVector}{\bm{1}} % Risk factor sensitivity (first-order P&L) \newcommand{\Sensitivity}{\delta} % Second-order sensitivity (convexity/gamma) \newcommand{\ConvexitySens}{\gamma} % Output floor percentage (Basel) \newcommand{\OutputFloor}{\alpha} % --- Subscript Labels ---------------------------------------------------- % Subscript label: annualized \newcommand{\Annual}{a} % Subscript label: implied \newcommand{\Implied}{I} % Subscript label: portfolio \newcommand{\Portfolio}{\pi} % --- Text Abbreviations -------------------------------------------- % S&P 500 index \newcommand{\SP}{\text{S\&P-500}} % Student-t distribution (text) \newcommand{\St}{\text{Student-t}} % Geopolitical Risk index (Caldara and Iacoviello) \newcommand{\GPR}{\text{GPR}} \]

24  Portfolio risk

Portfolio risk is not the sum of individual asset risks. Correlations between assets drive the diversification benefit, and these correlations change over time. While the VaR methods so far applied to a single return series, practical risk management requires understanding how individual asset risks combine and contribute to overall portfolio risk.

We start with three standard VaR methods. The parametric and Monte Carlo approaches use the sample covariance as a benchmark, historical simulation works directly from the empirical returns, and an EWMA covariance variant follows. We then show how replacing the sample covariance with DCC covariance forecasts from Chapter 22 captures time-varying correlations.

24.1 Data and libraries

A multivariate normal generator for the Monte Carlo method, and tsmarch for the DCC forecast.

library(xts)
library(tsmarch)
library(tsgarch)
library(MASS)
source("common/functions.r", chdir = TRUE)
import numpy as np
from scipy import stats
import sys
sys.path.insert(0, 'common')
from functions import ProcessRawData, EmpiricalES
include("common/functions.jl");
data = ProcessRawData()
Return = data$Return
dates = Return$date

We work with a two-asset portfolio of JPM and Intel:

assets = c("JPM", "INTC")
y = as.matrix(Return[, assets])
y_xts = xts(y, order.by = dates)
data_py = ProcessRawData()
Return_py = data_py['Return']
assets_py = ['JPM', 'INTC']
y_py = Return_py[assets_py].values
data_jl = ProcessRawData();
Return_jl = data_jl["Return"];
assets_jl = ["JPM", "INTC"];
y_jl = Matrix(Return_jl[:, Symbol.(assets_jl)]);

24.2 Portfolio returns

For a portfolio with weight vector \(\weights\), the portfolio return at time \(t\) is:

\[\CompoundReturns_{\Portfolio,t} = \weights^\top \CompoundReturns_t = \sum_{i=1}^{\NumberAssets} \weight_i \CompoundReturns_{i,t}\]

where \(\CompoundReturns_t\) is the vector of individual asset returns and \(\NumberAssets\) is the number of assets. This is exact for simple returns. For log returns it is a first-order approximation accurate for daily data.

We use equal weights for illustration:

w = c(0.5, 0.5)
names(w) = assets

# Portfolio returns
r_portfolio = y %*% w
head(r_portfolio)
          [,1]
2  0.005217571
3  0.056833446
4  0.002702257
5 -0.039573955
6  0.021066306
7  0.006579230
w_py = np.array([0.5, 0.5])

r_portfolio_py = y_py @ w_py
print(r_portfolio_py[:6])
[ 0.00521757  0.05683345  0.00270226 -0.03957396  0.02106631  0.00657923]
w_jl = [0.5, 0.5];

r_portfolio_jl = y_jl * w_jl;
println(r_portfolio_jl[1:6])
Union{Missing, Float64}[0.005217571194306103, 0.05683344608209895, 0.002702256936126801, -0.03957395509423889, 0.021066305570529575, 0.006579229777937012]
# Compare individual and portfolio volatility
cat("JPM annualised volatility:      ", round(sd(y[, "JPM"]) * sqrt(252) * 100, 1), "%\n")
cat("INTC annualised volatility:     ", round(sd(y[, "INTC"]) * sqrt(252) * 100, 1), "%\n")
cat("Portfolio annualised volatility:", round(sd(r_portfolio) * sqrt(252) * 100, 1), "%\n")
cat("Correlation:                    ", round(cor(y[, 1], y[, 2]), 3), "\n")
JPM annualised volatility:       36.3 %
INTC annualised volatility:      31.4 %
Portfolio annualised volatility: 29.1 %
Correlation:                     0.478
print(f"JPM annualised volatility:       {np.std(y_py[:, 0], ddof=1) * np.sqrt(252) * 100:.1f}%")
print(f"INTC annualised volatility:      {np.std(y_py[:, 1], ddof=1) * np.sqrt(252) * 100:.1f}%")
print(f"Portfolio annualised volatility: {np.std(r_portfolio_py, ddof=1) * np.sqrt(252) * 100:.1f}%")
print(f"Correlation:                     {np.corrcoef(y_py[:, 0], y_py[:, 1])[0, 1]:.3f}")
JPM annualised volatility:       36.3%
INTC annualised volatility:      31.4%
Portfolio annualised volatility: 29.1%
Correlation:                     0.478
using Printf, Statistics
@printf("JPM annualised volatility:       %.1f%%\n", std(y_jl[:, 1]) * sqrt(252) * 100)
@printf("INTC annualised volatility:      %.1f%%\n", std(y_jl[:, 2]) * sqrt(252) * 100)
@printf("Portfolio annualised volatility: %.1f%%\n", std(r_portfolio_jl) * sqrt(252) * 100)
@printf("Correlation:                     %.3f\n", cor(y_jl[:, 1], y_jl[:, 2]))
JPM annualised volatility:       36.3%
INTC annualised volatility:      31.4%
Portfolio annualised volatility: 29.1%
Correlation:                     0.478

In this example, the portfolio volatility is lower than the weighted average of individual volatilities due to diversification.

24.3 Portfolio VaR and ES: three approaches

The parametric and Monte Carlo methods begin with a static sample covariance. Historical simulation uses the empirical portfolio returns directly. Section 24.3.4 replaces the sample covariance with an EWMA covariance, and Section 24.4 with DCC forecasts, to capture time-varying correlations.

The parametric method (also called variance-covariance) assumes returns are multivariate normal. Historical simulation uses the empirical distribution of portfolio returns instead, with no distributional assumption. Monte Carlo simulates from the estimated distribution to build the same empirical quantile.

All three use the same parameters:

par = list()
par$probability = 0.05 # 5% VaR
par$value       = 1000 # Portfolio value
par$WE          = 500  # Estimation window
par_py = {}
par_py['probability'] = 0.05
par_py['value'] = 1000
par_py['WE'] = 500
par_jl = Dict{String, Any}();
par_jl["probability"] = 0.05;
par_jl["value"] = 1000;
par_jl["WE"] = 500;

24.3.1 Parametric VaR and ES

The parametric approach uses the covariance matrix to compute portfolio variance:

\[\Vol_\Portfolio^2 = \weights^\top \CovMatrix \weights\]

For normally distributed returns with zero mean, VaR as a positive loss magnitude is:

\[\VaR(\probability) = -\NormalQuantile(\probability)\,\Vol_\Portfolio \times \PortfolioValue\]

where \(\NormalQuantile(\probability)\) is the normal quantile at probability \(\probability\). We assume zero-mean returns, which is reasonable for daily data where the mean is small relative to volatility.

ParametricVaR = function(w, Sigma, p = 0.05, value = 1000) {
    sigma_p = sqrt(t(w) %*% Sigma %*% w)
    VaR = -qnorm(p) * sigma_p * value
    ES = (dnorm(qnorm(p)) / p) * sigma_p * value
    return(list(VaR = as.numeric(VaR), ES = as.numeric(ES), sigma = as.numeric(sigma_p)))
}
def ParametricVaR(w, Sigma, p=0.05, value=1000):
    sigma_p = np.sqrt(w @ Sigma @ w)
    VaR = -stats.norm.ppf(p) * sigma_p * value
    ES = (stats.norm.pdf(stats.norm.ppf(p)) / p) * sigma_p * value
    return {'VaR': VaR, 'ES': ES, 'sigma': sigma_p}
using Distributions
function ParametricVaR(w, Sigma, p=0.05, value=1000)
    sigma_p = sqrt(w' * Sigma * w)
    VaR = -quantile(Normal(), p) * sigma_p * value
    ES = (pdf(Normal(), quantile(Normal(), p)) / p) * sigma_p * value
    return Dict("VaR" => VaR, "ES" => ES, "sigma" => sigma_p)
end

Using the sample covariance matrix:

Sigma_sample = cov(tail(y, par$WE))
res_param = ParametricVaR(w, Sigma_sample, par$probability, par$value)
cat("Parametric VaR:", round(res_param$VaR, 2), "\n")
cat("Parametric ES: ", round(res_param$ES, 2), "\n")
Parametric VaR: 26.95 
Parametric ES:  33.8
Sigma_sample_py = np.cov(y_py[-par_py['WE']:], rowvar=False)
res_param_py = ParametricVaR(w_py, Sigma_sample_py, par_py['probability'], par_py['value'])
print(f"Parametric VaR: {res_param_py['VaR']:.2f}")
print(f"Parametric ES:  {res_param_py['ES']:.2f}")
Parametric VaR: 26.95
Parametric ES:  33.80
using Statistics, Printf
Sigma_sample_jl = cov(y_jl[end-par_jl["WE"]+1:end, :]);
res_param_jl = ParametricVaR(w_jl, Sigma_sample_jl, par_jl["probability"], par_jl["value"]);
@printf("Parametric VaR: %.2f\n", res_param_jl["VaR"])
@printf("Parametric ES:  %.2f\n", res_param_jl["ES"])
Parametric VaR: 26.95
Parametric ES:  33.80

The parametric figures are the benchmark the other three methods are measured against. Everything about them follows from the covariance matrix and the normal quantile, so they inherit whatever that assumption gets wrong.

24.3.2 Historical simulation

Historical simulation computes portfolio returns for each historical day and takes the empirical quantile, using the rank convention set out in Section 23.5. Here \(\probability \EstWindow = 0.05 \times 500 = 25\), so ceiling and truncation give the same rank.

HistoricalVaR = function(y, w, p = 0.05, value = 1000, WE = 500) {
    y_window = tail(y, WE)
    r_portfolio = y_window %*% w
    r_sorted = sort(r_portfolio)
    idx = ceiling(p * WE)
    VaR = -r_sorted[idx] * value
    ES = -EmpiricalES(r_sorted, p) * value
    return(list(VaR = VaR, ES = ES))
}

res_hs = HistoricalVaR(y, w, par$probability, par$value, par$WE)
cat("Historical VaR:", round(res_hs$VaR, 2), "\n")
cat("Historical ES: ", round(res_hs$ES, 2), "\n")
Historical VaR: 25.89 
Historical ES:  35.91
def HistoricalVaR(y, w, p=0.05, value=1000, WE=500):
    y_window = y[-WE:]
    r_portfolio = y_window @ w
    r_sorted = np.sort(r_portfolio)
    idx = int(np.ceil(p * WE))
    VaR = -r_sorted[idx - 1] * value  # -1 for 0-based indexing
    ES = -EmpiricalES(r_sorted, p) * value
    return {'VaR': VaR, 'ES': ES}

res_hs_py = HistoricalVaR(y_py, w_py, par_py['probability'], par_py['value'], par_py['WE'])
print(f"Historical VaR: {res_hs_py['VaR']:.2f}")
print(f"Historical ES:  {res_hs_py['ES']:.2f}")
Historical VaR: 25.89
Historical ES:  35.91
using Statistics, Printf
function HistoricalVaR(y, w, p=0.05, value=1000, WE=500)
    y_window = y[end-WE+1:end, :]
    r_portfolio = y_window * w
    r_sorted = sort(r_portfolio)
    idx = ceil(Int, p * WE)
    VaR = -r_sorted[idx] * value
    ES = -EmpiricalES(r_sorted, p) * value
    return Dict("VaR" => VaR, "ES" => ES)
end

res_hs_jl = HistoricalVaR(y_jl, w_jl, par_jl["probability"], par_jl["value"], par_jl["WE"]);
@printf("Historical VaR: %.2f\n", res_hs_jl["VaR"])
@printf("Historical ES:  %.2f\n", res_hs_jl["ES"])
Historical VaR: 25.89
Historical ES:  35.91

Historical VaR and ES are deterministic given the data and window, so all three languages return the same values.

The comparison with the parametric figures is worth reading now rather than later. Historical simulation gives the lower VaR of the two and the higher ES. The normal assumption puts the 5% quantile slightly further out than the data do, and then understates how bad things are beyond it. That is the shape of a fat tail, and it is exactly what a single VaR number cannot show.

24.3.3 Monte Carlo VaR and ES

Monte Carlo simulates from the multivariate normal distribution using the estimated covariance matrix, drawing on each language’s own multivariate-normal generator.

MonteCarloVaR = function(w, Sigma, p = 0.05, value = 1000, nsim = 10000) {
    Y_sim = mvrnorm(nsim, mu = rep(0, length(w)), Sigma = Sigma)

    # Portfolio returns
    r_portfolio = Y_sim %*% w
    r_sorted = sort(r_portfolio)
    idx = ceiling(p * nsim)
    VaR = -r_sorted[idx] * value
    ES = -EmpiricalES(r_sorted, p) * value
    return(list(VaR = VaR, ES = ES))
}

set.seed(42)
res_mc = MonteCarloVaR(w, Sigma_sample, par$probability, par$value)
cat("Monte Carlo VaR:", round(res_mc$VaR, 2), "\n")
cat("Monte Carlo ES: ", round(res_mc$ES, 2), "\n")
Monte Carlo VaR: 27.22 
Monte Carlo ES:  34.03
def MonteCarloVaR(w, Sigma, p=0.05, value=1000, nsim=10000, seed=42):
    rng = np.random.default_rng(seed)
    Y_sim = rng.multivariate_normal(np.zeros(len(w)), Sigma, size=nsim)

    r_portfolio = Y_sim @ w
    r_sorted = np.sort(r_portfolio)
    idx = int(np.ceil(p * nsim))
    VaR = -r_sorted[idx - 1] * value
    ES = -EmpiricalES(r_sorted, p) * value
    return {'VaR': VaR, 'ES': ES}

res_mc_py = MonteCarloVaR(w_py, Sigma_sample_py, par_py['probability'], par_py['value'])
print(f"Monte Carlo VaR: {res_mc_py['VaR']:.2f}")
print(f"Monte Carlo ES:  {res_mc_py['ES']:.2f}")
Monte Carlo VaR: 26.82
Monte Carlo ES:  33.37

rand(MvNormal(...), nsim) returns assets × simulations, the transpose of R and Python’s orientation, so the draws are transposed before use.

using Random, Distributions, Statistics, Printf
function MonteCarloVaR(w, Sigma, p=0.05, value=1000, nsim=10000; seed=42)
    Random.seed!(seed)
    draws = rand(MvNormal(zeros(length(w)), Sigma), nsim)'
    r_portfolio = draws * w

    r_sorted = sort(r_portfolio)
    idx = ceil(Int, p * nsim)
    VaR = -r_sorted[idx] * value
    ES = -EmpiricalES(r_sorted, p) * value
    return Dict("VaR" => VaR, "ES" => ES)
end

res_mc_jl = MonteCarloVaR(w_jl, Sigma_sample_jl, par_jl["probability"], par_jl["value"]);
@printf("Monte Carlo VaR: %.2f\n", res_mc_jl["VaR"])
@printf("Monte Carlo ES:  %.2f\n", res_mc_jl["ES"])
Monte Carlo VaR: 26.92
Monte Carlo ES:  33.84

Monte Carlo lands close to the parametric figure, as it must — both draw on the same covariance matrix and the same normality assumption. The gap between them is simulation error and nothing else, which makes it a useful check that the simulation is doing what we think.

24.3.4 EWMA covariance

The sample covariance weights every observation in the window equally. The EWMA recursion from Chapter 22 instead gives more weight to recent observations, producing a next-day forecast \(\CovMatrix_{\SampleSize+1|\SampleSize}\). The recursion is initialised with the full-sample covariance (Sigma_ewma_initial) — a separate quantity from the 500-observation Sigma_sample used above — playing the role of \(\CovMatrix_1\), and run forward through the last observation:

\[\CovMatrix_{t+1} = \EWMAdecay \times \CovMatrix_t + (1-\EWMAdecay) \times \CompoundReturns_t \times \CompoundReturns_t'\]

with \(\EWMAdecay = 0.94\). Running the recursion through \(t = \SampleSize\) (the last observation) gives the one-step-ahead forecast \(\CovMatrix_{\SampleSize+1|\SampleSize}\), which feeds the same parametric VaR formula.

Sigma_ewma_initial = cov(y)
lambda = 0.94
S = Sigma_ewma_initial
for (t in 1:nrow(y)) {
    S = lambda * S + (1 - lambda) * y[t, ] %*% t(y[t, ])
}
Sigma_ewma = S
res_ewma = ParametricVaR(w, Sigma_ewma, par$probability, par$value)
cat("EWMA VaR:", round(res_ewma$VaR, 2), "\n")
cat("EWMA ES: ", round(res_ewma$ES, 2), "\n")
EWMA VaR: 24.74 
EWMA ES:  31.02
Sigma_ewma_initial_py = np.cov(y_py, rowvar=False)
lam = 0.94
S_py = Sigma_ewma_initial_py.copy()
for t in range(len(y_py)):
    S_py = lam * S_py + (1 - lam) * np.outer(y_py[t, :], y_py[t, :])
Sigma_ewma_py = S_py
res_ewma_py = ParametricVaR(w_py, Sigma_ewma_py, par_py['probability'], par_py['value'])
print(f"EWMA VaR: {res_ewma_py['VaR']:.2f}")
print(f"EWMA ES:  {res_ewma_py['ES']:.2f}")
EWMA VaR: 24.74
EWMA ES:  31.02
using Statistics, Printf
Sigma_ewma_initial_jl = cov(y_jl);
lambda_jl = 0.94;
S_jl = copy(Sigma_ewma_initial_jl);
for t in 1:size(y_jl, 1)
    global S_jl = lambda_jl * S_jl + (1 - lambda_jl) * y_jl[t, :] * y_jl[t, :]'
end
Sigma_ewma_jl = S_jl;
res_ewma_jl = ParametricVaR(w_jl, Sigma_ewma_jl, par_jl["probability"], par_jl["value"]);
@printf("EWMA VaR: %.2f\n", res_ewma_jl["VaR"])
@printf("EWMA ES:  %.2f\n", res_ewma_jl["ES"])
EWMA VaR: 24.74
EWMA ES:  31.02

EWMA returns the lowest VaR of the four. The sample covariance averages the whole 500-day window, while EWMA weights the recent past most heavily, so the difference says that the last few weeks were calmer than the window as a whole. Whether that is prudent or complacent depends on whether the calm holds.

24.3.5 Comparison

comparison = data.frame(
    Method = c("Parametric", "Historical", "Monte Carlo", "EWMA"),
    VaR = round(c(res_param$VaR, res_hs$VaR, res_mc$VaR, res_ewma$VaR), 2),
    ES  = round(c(res_param$ES, res_hs$ES, res_mc$ES, res_ewma$ES), 2)
)
comparison
       Method   VaR    ES
1  Parametric 26.95 33.80
2  Historical 25.89 35.91
3 Monte Carlo 27.22 34.03
4        EWMA 24.74 31.02
import pandas as pd
comparison_py = pd.DataFrame({
    'Method': ['Parametric', 'Historical', 'Monte Carlo', 'EWMA'],
    'VaR': [round(res_param_py['VaR'], 2), round(res_hs_py['VaR'], 2), round(res_mc_py['VaR'], 2), round(res_ewma_py['VaR'], 2)],
    'ES': [round(res_param_py['ES'], 2), round(res_hs_py['ES'], 2), round(res_mc_py['ES'], 2), round(res_ewma_py['ES'], 2)]
})
print(comparison_py.to_string(index=False))
     Method   VaR    ES
 Parametric 26.95 33.80
 Historical 25.89 35.91
Monte Carlo 26.82 33.37
       EWMA 24.74 31.02
using DataFrames
comparison_jl = DataFrame(
    Method = ["Parametric", "Historical", "Monte Carlo", "EWMA"],
    VaR = round.([res_param_jl["VaR"], res_hs_jl["VaR"], res_mc_jl["VaR"], res_ewma_jl["VaR"]], digits=2),
    ES = round.([res_param_jl["ES"], res_hs_jl["ES"], res_mc_jl["ES"], res_ewma_jl["ES"]], digits=2)
);
println(comparison_jl)
4×3 DataFrame
 Row │ Method       VaR      ES
     │ String       Float64  Float64
─────┼───────────────────────────────
   1 │ Parametric     26.95    33.8
   2 │ Historical     25.89    35.91
   3 │ Monte Carlo    26.92    33.84
   4 │ EWMA           24.74    31.02

The parametric and Monte Carlo results are similar because both assume multivariate normality. Historical simulation can differ when returns are non-normal. EWMA and the DCC forecast below both respond more quickly to new observations than the sample covariance, and DCC additionally allows correlations themselves to change.

24.4 Using DCC covariances

The DCC model differs from the sample covariance in letting correlations change over time, which the sample covariance cannot do. That is not the same as being better in every case.

Standard DCC buys its tractability with restrictions. A single pair of parameters governs the correlation dynamics of every asset pair, so all pairs adapt at the same speed, and the update responds to the outer product of the standardised shocks, so a shock vector and its sign-reversed counterpart move correlations identically.

Estimation also gets harder as the cross-section grows. Each likelihood evaluation factorises an \(\NumberAssets \times \NumberAssets\) matrix at every date, and the correlation target is singular once the sample is short relative to the number of assets.

Whether the added dynamics repay those restrictions is an empirical question, settled out of sample rather than by assumption. We use the DCC model from Chapter 22, which is R-only here for the reasons given in that chapter.

# Fit DCC model
garch_models = lapply(1:ncol(y_xts), function(i) {
    spec = garch_modelspec(y_xts[, i], model = "garch", order = c(1, 1), constant = FALSE)
    estimate(spec, keep_tmb = TRUE)
})
names(garch_models) = colnames(y_xts)

multi_garch = to_multi_estimate(garch_models)
dcc_spec = dcc_modelspec(multi_garch, dynamics = "dcc", dcc_order = c(1, 1), distribution = "mvn")
dcc_res = estimate(dcc_spec)
Attaching package: ‘data.table’

The following objects are masked from ‘package:reshape2’:

    dcast, melt

The following objects are masked from ‘package:lubridate’:

    hour, isoweek, mday, minute, month, quarter, second, wday, week,
    yday, year

The following objects are masked from ‘package:xts’:

    first, last

The following objects are masked from ‘package:zoo’:

    yearmon, yearqtr

Extract the one-step-ahead conditional covariance forecast:

# Forecast the conditional covariance one step ahead
dcc_pred = predict(dcc_res, h = 1, seed = 42)
Sigma_dcc = tscov(dcc_pred, distribution = FALSE)[, , 1]
Sigma_dcc
             [,1]         [,2]
[1,] 0.0001292289 0.0001102352
[2,] 0.0001102352 0.0004799305
res_dcc = ParametricVaR(w, Sigma_dcc, par$probability, par$value)
cat("DCC-based VaR:", round(res_dcc$VaR, 2), "\n")
cat("DCC-based ES: ", round(res_dcc$ES, 2), "\n")
DCC-based VaR: 23.69 
DCC-based ES:  29.71

The DCC forecast gives the lowest VaR of any method here, below even EWMA. Both weight recent data, but DCC also lets the JPM–INTC correlation itself move, and it evidently reads that correlation as currently below its long-run level. A lower correlation means more diversification and a smaller portfolio loss for the same two volatilities.

24.5 Risk decomposition

How much does each asset contribute to total portfolio risk? The Euler decomposition provides an answer that sums to total VaR.

For parametric VaR, the component VaR for asset \(i\) is:

\[\CompVaR_i = \weight_i \frac{(\CovMatrix \weights)_i}{\Vol_\Portfolio^2} \VaR(\probability)\]

where \((\CovMatrix \weights)_i\) is the \(i\)-th element of the vector \(\CovMatrix \weights\). “Component VaR” is distinct from “Conditional VaR” (another name for Expected Shortfall). We decompose on the sample covariance Sigma_sample here, in all three languages. The DCC-based decomposition below stays R-only, consistent with the DCC forecast itself.

ComponentVaR = function(w, Sigma, p = 0.05, value = 1000) {
    sigma_p = as.numeric(sqrt(t(w) %*% Sigma %*% w))
    VaR_p = -qnorm(p) * sigma_p * value

    # Marginal VaR per unit of normalised weight, not per dollar of exposure.
    # Component VaR is the same either way: w_i * marginal_per_weight
    # equals value * w_i * marginal_per_dollar.
    Sigma_w = Sigma %*% w
    marginal_per_weight = as.numeric(Sigma_w) / sigma_p * (-qnorm(p)) * value

    # Component VaR
    component = w * marginal_per_weight

    return(list(
        total_VaR = VaR_p,
        component_VaR = component,
        pct_contribution = component / VaR_p * 100
    ))
}
def ComponentVaR(w, Sigma, p=0.05, value=1000):
    sigma_p = np.sqrt(w @ Sigma @ w)
    VaR_p = -stats.norm.ppf(p) * sigma_p * value

    # Marginal VaR per unit of normalised weight, not per dollar of exposure.
    Sigma_w = Sigma @ w
    marginal_per_weight = Sigma_w / sigma_p * (-stats.norm.ppf(p)) * value

    component = w * marginal_per_weight

    return {
        'total_VaR': VaR_p,
        'component_VaR': component,
        'pct_contribution': component / VaR_p * 100
    }
using Distributions
function ComponentVaR(w, Sigma, p=0.05, value=1000)
    sigma_p = sqrt(w' * Sigma * w)
    VaR_p = -quantile(Normal(), p) * sigma_p * value

    Sigma_w = Sigma * w
    # Marginal VaR per unit of normalised weight, not per dollar of exposure.
    marginal_per_weight = Sigma_w ./ sigma_p .* (-quantile(Normal(), p)) .* value

    component = w .* marginal_per_weight

    return Dict(
        "total_VaR" => VaR_p,
        "component_VaR" => component,
        "pct_contribution" => component ./ VaR_p .* 100
    )
end
decomp = ComponentVaR(w, Sigma_sample, par$probability, par$value)
cat("Total VaR:", round(decomp$total_VaR, 2), "\n\n")
cat("Component VaR:\n")
for (i in 1:length(assets)) {
    cat("  ", assets[i], ":", round(decomp$component_VaR[i], 2),
        "(", round(decomp$pct_contribution[i], 1), "%)\n")
}
cat("\nSum of components:", round(sum(decomp$component_VaR), 2), "\n")
Total VaR: 26.95 

Component VaR:
   JPM : 10.57 ( 39.2 %)
   INTC : 16.38 ( 60.8 %)

Sum of components: 26.95
decomp_py = ComponentVaR(w_py, Sigma_sample_py, par_py['probability'], par_py['value'])
print(f"Total VaR: {decomp_py['total_VaR']:.2f}\n")
print("Component VaR:")
for i, a in enumerate(assets_py):
    print(f"  {a}: {decomp_py['component_VaR'][i]:.2f} ({decomp_py['pct_contribution'][i]:.1f}%)")
print(f"\nSum of components: {sum(decomp_py['component_VaR']):.2f}")
Total VaR: 26.95

Component VaR:
  JPM: 10.57 (39.2%)
  INTC: 16.38 (60.8%)

Sum of components: 26.95
using Printf
decomp_jl = ComponentVaR(w_jl, Sigma_sample_jl, par_jl["probability"], par_jl["value"]);
@printf("Total VaR: %.2f\n\n", decomp_jl["total_VaR"])
println("Component VaR:")
for i in 1:length(assets_jl)
    @printf("  %s: %.2f (%.1f%%)\n", assets_jl[i], decomp_jl["component_VaR"][i], decomp_jl["pct_contribution"][i])
end
@printf("\nSum of components: %.2f\n", sum(decomp_jl["component_VaR"]))
Total VaR: 26.95

Component VaR:
  JPM: 10.57 (39.2%)
  INTC: 16.38 (60.8%)

Sum of components: 26.95

The components sum to total VaR in all three languages. The percentage contributions show each asset’s share of portfolio risk.

24.5.1 DCC-based decomposition (R only)

Since the DCC forecast is R-only, so is its risk decomposition:

decomp_dcc = ComponentVaR(w, Sigma_dcc, par$probability, par$value)
cat("Total VaR (DCC):", round(decomp_dcc$total_VaR, 2), "\n\n")
cat("Component VaR (DCC):\n")
for (i in 1:length(assets)) {
    cat("  ", assets[i], ":", round(decomp_dcc$component_VaR[i], 2),
        "(", round(decomp_dcc$pct_contribution[i], 1), "%)\n")
}
cat("\nSum of components:", round(sum(decomp_dcc$component_VaR), 2), "\n")
Total VaR (DCC): 23.69 

Component VaR (DCC):
   JPM : 6.84 ( 28.9 %)
   INTC : 16.85 ( 71.1 %)

Sum of components: 23.69

The two decompositions disagree about how much. On the sample covariance INTC carries about 61% of the risk, and on the DCC forecast about 71%. Equal money was never equal risk, and which covariance we use changes the answer by ten percentage points. A risk budget built on one of these numbers would look wrong under the other.

24.6 Summary

Portfolio VaR extends single-asset methods to account for correlations, and the four methods above trade off differently. The parametric method reads VaR off the portfolio variance \(\weights^\top\CovMatrix \weights\) and is the cheapest to compute, at the price of assuming normality. Historical simulation drops that assumption and takes the empirical quantile of past portfolio returns, so it inherits whatever tail the window holds and adapts slowly. Monte Carlo simulates from the estimated distribution, which accommodates payoffs the other two cannot handle, and costs the most to run. EWMA keeps the parametric formula but replaces the sample covariance with one that weights recent observations more heavily, so it adapts faster to changing conditions.

The DCC model, R-only in this notebook, provides time-varying covariance forecasts that capture changing correlations. Risk decomposition shows where the danger sits in the portfolio. That matters when you set risk budgets or size positions.