Conditional Value at Risk (CVaR), also known as Expected Shortfall (ES), addresses VaR's critical limitation: it tells you not just the threshold, but the expected loss when things go wrong.
Key Points:
Bottom Line: If VaR tells you "how bad can it get?", CVaR tells you "how bad will it be when it gets bad?"
Consider two portfolios with identical 99% VaR of $1M:
Portfolio A:
Portfolio B:
VaR says these portfolios have the same risk! This is absurd.
VaR only tells you the threshold—it ignores what happens beyond that threshold. This is especially dangerous for portfolios with:
CVaR (Conditional Value at Risk) fixes this by measuring the expected loss in the tail:
For Portfolio A: CVaR ≈ 50M
Now the risk difference is clear.
For a portfolio with P&L distribution (where negative values = losses):
Equivalently:
Interpretation: CVaR is the average of all losses that exceed VaR.
CVaR satisfies all four axioms of coherent risk measures (Artzner et al., 1999):
VaR violates subadditivity! This means VaR can increase when you diversify—a fatal flaw for a risk measure.
CVaR satisfies all four axioms, making it theoretically superior.
The simplest approach:
1import numpy as np
2import pandas as pd
3from typing import Tuple
4from dataclasses import dataclass
5
6@dataclass
7class CVaRResult:
8 """Container for CVaR calculation results"""
9 cvar: float
10 var: float
11 confidence_level: float
12 num_tail_scenarios: int
13 tail_scenarios: np.ndarray
14
15class HistoricalCVaR:
16 """
17 Historical simulation CVaR calculator
18
19 Calculates both VaR and CVaR from historical scenarios
20 """
21
22 def __init__(self, returns_window: int = 250):
23 self.returns_window = returns_window
24
25 def calculate(self,
26 pnl_scenarios: np.ndarray,
27 confidence_level: float = 0.99) -> CVaRResult:
28 """
29 Calculate CVaR from P&L scenarios
30
31 Args:
32 pnl_scenarios: Array of P&L scenarios (negative = loss)
33 confidence_level: Confidence level (e.g., 0.99 for 99%)
34
35 Returns:
36 CVaRResult with VaR, CVaR, and tail scenarios
37 """
38 # VaR is the negative of the alpha-quantile
39 var = -np.percentile(pnl_scenarios, (1 - confidence_level) * 100)
40
41 # CVaR is the average of losses beyond VaR
42 tail_losses = pnl_scenarios[pnl_scenarios <= -var]
43
44 if len(tail_losses) == 0:
45 # No breaches - use VaR as conservative estimate
46 cvar = var
47 tail_scenarios = np.array([])
48 else:
49 cvar = -tail_losses.mean()
50 tail_scenarios = tail_losses
51
52 return CVaRResult(
53 cvar=cvar,
54 var=var,
55 confidence_level=confidence_level,
56 num_tail_scenarios=len(tail_scenarios),
57 tail_scenarios=tail_scenarios
58 )
59
60 def calculate_from_portfolio(self,
61 positions: pd.DataFrame,
62 historical_prices: pd.DataFrame,
63 confidence_level: float = 0.99,
64 horizon_days: int = 1) -> CVaRResult:
65 """
66 Calculate CVaR directly from portfolio positions
67
68 Args:
69 positions: DataFrame with columns ['asset', 'quantity', 'current_price']
70 historical_prices: DataFrame with assets as columns, dates as index
71 confidence_level: Confidence level
72 horizon_days: Time horizon in days
73 """
74 # Calculate returns
75 returns = historical_prices.pct_change().dropna()
76 returns = returns.tail(self.returns_window)
77
78 # Scale to horizon
79 if horizon_days > 1:
80 returns = returns * np.sqrt(horizon_days)
81
82 # Calculate P&L scenarios
83 pnl_scenarios = np.zeros(len(returns))
84
85 positions_dict = positions.set_index('asset')
86
87 for asset in positions['asset']:
88 if asset not in returns.columns:
89 raise ValueError(f"Asset {asset} not in historical prices")
90
91 qty = positions_dict.loc[asset, 'quantity']
92 price = positions_dict.loc[asset, 'current_price']
93
94 pnl_scenarios += qty * price * returns[asset].values
95
96 return self.calculate(pnl_scenarios, confidence_level)
97
98
99# Example usage
100if __name__ == "__main__":
101 # Generate sample P&L scenarios with fat tails
102 np.random.seed(42)
103
104 # Mix of normal days and crisis days
105 normal_pnl = np.random.normal(1000, 5000, 990) # 99% of days
106 crisis_pnl = np.random.normal(-50000, 20000, 10) # 1% of days (disasters)
107 pnl_scenarios = np.concatenate([normal_pnl, crisis_pnl])
108
109 calculator = HistoricalCVaR()
110 result = calculator.calculate(pnl_scenarios, confidence_level=0.99)
111
112 print(f"99% VaR: ${result.var:,.2f}")
113 print(f"99% CVaR: ${result.cvar:,.2f}")
114 print(f"CVaR/VaR ratio: {result.cvar/result.var:.2f}")
115 print(f"Number of tail scenarios: {result.num_tail_scenarios}")
116 print(f"Worst scenario: ${result.tail_scenarios.min():,.2f}")
117Output:
99% VaR: $14,234.56
99% CVaR: $48,392.12
CVaR/VaR ratio: 3.40
Number of tail scenarios: 10
Worst scenario: $-89,234.56
Notice how CVaR is 3.4x larger than VaR—this reveals the true tail risk!
For normally distributed returns:
Where:
1from scipy import stats
2
3class ParametricCVaR:
4 """
5 Parametric CVaR assuming normal distribution
6
7 Fast but assumes normality (not suitable for fat tails)
8 """
9
10 def calculate(self,
11 portfolio_mean: float,
12 portfolio_std: float,
13 portfolio_value: float,
14 confidence_level: float = 0.99) -> Tuple[float, float]:
15 """
16 Calculate parametric CVaR
17
18 Args:
19 portfolio_mean: Expected portfolio return
20 portfolio_std: Portfolio standard deviation
21 portfolio_value: Current portfolio value
22 confidence_level: Confidence level
23
24 Returns:
25 Tuple of (VaR, CVaR)
26 """
27 # VaR for normal distribution
28 z_alpha = stats.norm.ppf(confidence_level)
29 var = -(portfolio_mean - z_alpha * portfolio_std) * portfolio_value
30
31 # CVaR for normal distribution
32 # CVaR = mu - sigma * phi(z_alpha) / (1 - alpha)
33 phi_z = stats.norm.pdf(z_alpha)
34 cvar = -(portfolio_mean - portfolio_std * phi_z / (1 - confidence_level)) * portfolio_value
35
36 return var, cvar
37
38 def calculate_from_returns(self,
39 returns: pd.DataFrame,
40 positions: pd.DataFrame,
41 confidence_level: float = 0.99) -> Tuple[float, float]:
42 """Calculate parametric CVaR from historical returns"""
43
44 # Calculate covariance matrix
45 cov_matrix = returns.cov()
46 mean_returns = returns.mean()
47
48 # Portfolio weights
49 portfolio_value = (positions['quantity'] * positions['current_price']).sum()
50 positions_dict = positions.set_index('asset')
51
52 weights = np.array([
53 (positions_dict.loc[asset, 'quantity'] *
54 positions_dict.loc[asset, 'current_price'] / portfolio_value)
55 if asset in positions_dict.index else 0.0
56 for asset in returns.columns
57 ])
58
59 # Portfolio statistics
60 portfolio_mean = weights @ mean_returns.values
61 portfolio_variance = weights @ cov_matrix.values @ weights
62 portfolio_std = np.sqrt(portfolio_variance)
63
64 return self.calculate(
65 portfolio_mean,
66 portfolio_std,
67 portfolio_value,
68 confidence_level
69 )
70
71
72# Example
73if __name__ == "__main__":
74 calc = ParametricCVaR()
75
76 # Portfolio with mean return 0.1% per day, std 2% per day, value $1M
77 var, cvar = calc.calculate(
78 portfolio_mean=0.001,
79 portfolio_std=0.02,
80 portfolio_value=1_000_000,
81 confidence_level=0.99
82 )
83
84 print(f"Parametric 99% VaR: ${var:,.2f}")
85 print(f"Parametric 99% CVaR: ${cvar:,.2f}")
86 print(f"CVaR/VaR ratio: {cvar/var:.2f}")
87Note: For normal distributions, CVaR/VaR ratio ≈ 1.2. If your empirical ratio is much higher, you have fat tails!
Key insight: CVaR can be computed by solving a linear program!
Rockafellar & Uryasev (2000) showed that:
This can be solved as an LP:
Where are the P&L scenarios.
1from scipy.optimize import linprog
2
3class CVaROptimizer:
4 """
5 CVaR calculation and optimization using linear programming
6
7 Based on Rockafellar-Uryasev formulation
8 """
9
10 def calculate_cvar_lp(self,
11 pnl_scenarios: np.ndarray,
12 confidence_level: float = 0.99) -> Tuple[float, float]:
13 """
14 Calculate CVaR using linear programming
15
16 Args:
17 pnl_scenarios: Array of P&L scenarios
18 confidence_level: Confidence level
19
20 Returns:
21 Tuple of (gamma, CVaR) where gamma is the VaR threshold
22 """
23 N = len(pnl_scenarios)
24 alpha = 1 - confidence_level
25
26 # Variables: [gamma, z_1, z_2, ..., z_N]
27 # Objective: min gamma + (1/(alpha*N)) * sum(z_i)
28 c = np.zeros(N + 1)
29 c[0] = 1.0 # coefficient for gamma
30 c[1:] = 1.0 / (alpha * N) # coefficients for z_i
31
32 # Constraints: z_i >= -x_i - gamma and z_i >= 0
33 # Rewrite as: z_i + gamma >= -x_i
34 # In standard form: -gamma - z_i <= x_i
35
36 A_ub = np.zeros((N, N + 1))
37 A_ub[:, 0] = -1.0 # -gamma
38 A_ub[:, 1:] = -np.eye(N) # -z_i
39
40 b_ub = pnl_scenarios # Right-hand side
41
42 # Bounds: gamma unbounded, z_i >= 0
43 bounds = [(None, None)] + [(0, None)] * N
44
45 # Solve LP
46 result = linprog(c, A_ub=A_ub, b_ub=b_ub, bounds=bounds, method='highs')
47
48 if not result.success:
49 raise ValueError(f"LP failed: {result.message}")
50
51 gamma = result.x[0] # VaR threshold
52 cvar = result.fun # CVaR value
53
54 return -gamma, cvar # Return as positive values (losses)
55
56 def optimize_portfolio_cvar(self,
57 returns: pd.DataFrame,
58 confidence_level: float = 0.99,
59 target_return: float = 0.0) -> pd.Series:
60 """
61 Find the portfolio with minimum CVaR subject to target return
62
63 Args:
64 returns: Historical returns (assets as columns)
65 confidence_level: CVaR confidence level
66 target_return: Minimum required expected return
67
68 Returns:
69 Optimal portfolio weights
70 """
71 N_scenarios = len(returns)
72 N_assets = len(returns.columns)
73 alpha = 1 - confidence_level
74
75 # Variables: [w_1, ..., w_N_assets, gamma, z_1, ..., z_N_scenarios]
76 # Total: N_assets + 1 + N_scenarios
77
78 # Objective: min gamma + (1/(alpha*N)) * sum(z_i)
79 c = np.zeros(N_assets + 1 + N_scenarios)
80 c[N_assets] = 1.0 # gamma
81 c[N_assets+1:] = 1.0 / (alpha * N_scenarios) # z_i
82
83 # Constraint 1: z_i >= -sum(w_j * r_ij) - gamma for all scenarios i
84 # Rewrite: sum(w_j * r_ij) + gamma + z_i >= 0
85 A_ub = np.zeros((N_scenarios, N_assets + 1 + N_scenarios))
86 A_ub[:, :N_assets] = returns.values # w_j * r_ij
87 A_ub[:, N_assets] = 1.0 # gamma
88 A_ub[:, N_assets+1:] = np.eye(N_scenarios) # z_i
89 b_ub = np.zeros(N_scenarios)
90
91 # Constraint 2: sum(w_j) = 1 (fully invested)
92 A_eq = np.zeros((1, N_assets + 1 + N_scenarios))
93 A_eq[0, :N_assets] = 1.0
94 b_eq = np.array([1.0])
95
96 # Constraint 3: sum(w_j * mean_return_j) >= target_return
97 if target_return > 0:
98 mean_returns = returns.mean().values
99 A_ub_return = np.zeros((1, N_assets + 1 + N_scenarios))
100 A_ub_return[0, :N_assets] = -mean_returns
101 A_ub = np.vstack([A_ub, A_ub_return])
102 b_ub = np.append(b_ub, -target_return)
103
104 # Bounds: 0 <= w_j <= 1 (long-only), gamma unbounded, z_i >= 0
105 bounds = [(0, 1)] * N_assets + [(None, None)] + [(0, None)] * N_scenarios
106
107 # Solve LP
108 result = linprog(c, A_ub=A_ub, b_ub=b_ub, A_eq=A_eq, b_eq=b_eq,
109 bounds=bounds, method='highs')
110
111 if not result.success:
112 raise ValueError(f"Optimization failed: {result.message}")
113
114 weights = result.x[:N_assets]
115 optimal_cvar = result.fun
116
117 print(f"Optimal CVaR: {optimal_cvar:.6f}")
118
119 return pd.Series(weights, index=returns.columns)
120
121
122# Example: CVaR-optimal portfolio
123if __name__ == "__main__":
124 # Generate sample returns for 5 assets
125 np.random.seed(42)
126 dates = pd.date_range(end='2025-11-25', periods=250, freq='D')
127
128 returns = pd.DataFrame({
129 'Asset_A': np.random.normal(0.001, 0.02, 250),
130 'Asset_B': np.random.normal(0.0008, 0.015, 250),
131 'Asset_C': np.random.normal(0.0012, 0.025, 250),
132 'Asset_D': np.random.normal(0.0005, 0.01, 250),
133 'Asset_E': np.random.normal(0.0015, 0.03, 250),
134 }, index=dates)
135
136 optimizer = CVaROptimizer()
137
138 # Find CVaR-optimal portfolio with 0.1% daily return target
139 optimal_weights = optimizer.optimize_portfolio_cvar(
140 returns=returns,
141 confidence_level=0.95,
142 target_return=0.001
143 )
144
145 print("\nOptimal Portfolio Weights:")
146 print(optimal_weights[optimal_weights > 0.01]) # Show only significant weights
147| Property | VaR | CVaR |
|---|---|---|
| Coherent | ❌ No | ✅ Yes |
| Subadditive | ❌ No | ✅ Yes |
| Tail risk | ❌ Ignores | ✅ Captures |
| Optimization | ❌ Non-convex | ✅ Convex (LP) |
| Interpretation | Threshold | Expected loss in tail |
| Regulatory | Basel II | Basel III (ES) |
1import matplotlib.pyplot as plt
2
3class VaRCVaRComparison:
4 """Compare VaR and CVaR across different distributions"""
5
6 def compare_distributions(self):
7 """Compare VaR/CVaR for normal vs fat-tailed distributions"""
8
9 np.random.seed(42)
10 confidence_levels = [0.90, 0.95, 0.99, 0.995]
11
12 results = []
13
14 for conf in confidence_levels:
15 # Normal distribution
16 normal_samples = np.random.normal(0, 1, 10000)
17 var_normal = -np.percentile(normal_samples, (1-conf)*100)
18 cvar_normal = -normal_samples[normal_samples <= -var_normal].mean()
19
20 # Student-t distribution (fat tails)
21 t_samples = np.random.standard_t(df=3, size=10000)
22 var_t = -np.percentile(t_samples, (1-conf)*100)
23 cvar_t = -t_samples[t_samples <= -var_t].mean()
24
25 results.append({
26 'confidence': conf,
27 'var_normal': var_normal,
28 'cvar_normal': cvar_normal,
29 'ratio_normal': cvar_normal / var_normal,
30 'var_t': var_t,
31 'cvar_t': cvar_t,
32 'ratio_t': cvar_t / var_t
33 })
34
35 df = pd.DataFrame(results)
36
37 print("\nVaR vs CVaR Comparison:")
38 print("=" * 70)
39 print(f"{'Confidence':<12} {'Normal VaR':<12} {'Normal CVaR':<12} {'Ratio':<8} {'t-dist VaR':<12} {'t-dist CVaR':<12} {'Ratio':<8}")
40 print("=" * 70)
41
42 for _, row in df.iterrows():
43 print(f"{row['confidence']:<12.1%} {row['var_normal']:<12.4f} {row['cvar_normal']:<12.4f} "
44 f"{row['ratio_normal']:<8.2f} {row['var_t']:<12.4f} {row['cvar_t']:<12.4f} {row['ratio_t']:<8.2f}")
45
46 return df
47
48# Run comparison
49comparator = VaRCVaRComparison()
50comparison_df = comparator.compare_distributions()
51Key Insight: For fat-tailed distributions, CVaR/VaR ratio increases dramatically at high confidence levels. This reveals hidden tail risk!
1import numba
2
3@numba.jit(nopython=True)
4def fast_cvar_calculation(pnl_scenarios: np.ndarray,
5 confidence_level: float) -> Tuple[float, float]:
6 """
7 Ultra-fast CVaR calculation using Numba JIT compilation
8
9 Args:
10 pnl_scenarios: P&L scenarios (negative = loss)
11 confidence_level: Confidence level (e.g., 0.99)
12
13 Returns:
14 Tuple of (VaR, CVaR)
15 """
16 # Sort scenarios (ascending order)
17 sorted_pnl = np.sort(pnl_scenarios)
18
19 # VaR index
20 var_idx = int((1 - confidence_level) * len(sorted_pnl))
21
22 # VaR is the negative of the alpha-quantile
23 var = -sorted_pnl[var_idx]
24
25 # CVaR is the average of tail losses
26 tail_losses = sorted_pnl[:var_idx+1]
27 cvar = -tail_losses.mean()
28
29 return var, cvar
30
31
32class ProductionCVaR:
33 """
34 Production-grade CVaR calculator with performance optimizations
35 """
36
37 def __init__(self,
38 cache_scenarios: bool = True,
39 use_numba: bool = True):
40 self.cache_scenarios = cache_scenarios
41 self.use_numba = use_numba
42 self._scenario_cache = {}
43
44 def calculate_batch(self,
45 pnl_scenarios: np.ndarray,
46 confidence_levels: list) -> pd.DataFrame:
47 """
48 Calculate VaR and CVaR for multiple confidence levels efficiently
49
50 Args:
51 pnl_scenarios: P&L scenarios
52 confidence_levels: List of confidence levels
53
54 Returns:
55 DataFrame with VaR and CVaR for each confidence level
56 """
57 # Sort once for all confidence levels
58 sorted_pnl = np.sort(pnl_scenarios)
59
60 results = []
61
62 for conf in confidence_levels:
63 var_idx = int((1 - conf) * len(sorted_pnl))
64 var = -sorted_pnl[var_idx]
65
66 tail_losses = sorted_pnl[:var_idx+1]
67 cvar = -tail_losses.mean()
68
69 results.append({
70 'confidence_level': conf,
71 'var': var,
72 'cvar': cvar,
73 'cvar_var_ratio': cvar / var if var > 0 else np.nan,
74 'num_tail_scenarios': len(tail_losses)
75 })
76
77 return pd.DataFrame(results)
78
79 def calculate_incremental_cvar(self,
80 base_pnl: np.ndarray,
81 new_position_pnl: np.ndarray,
82 confidence_level: float = 0.99) -> dict:
83 """
84 Calculate incremental CVaR of adding a new position
85
86 Args:
87 base_pnl: P&L scenarios for existing portfolio
88 new_position_pnl: P&L scenarios for new position
89 confidence_level: Confidence level
90
91 Returns:
92 Dict with base CVaR, new CVaR, and incremental CVaR
93 """
94 # Base portfolio CVaR
95 if self.use_numba:
96 base_var, base_cvar = fast_cvar_calculation(base_pnl, confidence_level)
97 else:
98 base_var = -np.percentile(base_pnl, (1-confidence_level)*100)
99 base_cvar = -base_pnl[base_pnl <= -base_var].mean()
100
101 # Combined portfolio CVaR
102 combined_pnl = base_pnl + new_position_pnl
103
104 if self.use_numba:
105 new_var, new_cvar = fast_cvar_calculation(combined_pnl, confidence_level)
106 else:
107 new_var = -np.percentile(combined_pnl, (1-confidence_level)*100)
108 new_cvar = -combined_pnl[combined_pnl <= -new_var].mean()
109
110 incremental_cvar = new_cvar - base_cvar
111
112 return {
113 'base_cvar': base_cvar,
114 'new_cvar': new_cvar,
115 'incremental_cvar': incremental_cvar,
116 'pct_increase': (incremental_cvar / base_cvar * 100) if base_cvar > 0 else np.nan
117 }
118
119
120# Performance test
121if __name__ == "__main__":
122 import time
123
124 # Generate large scenario set
125 np.random.seed(42)
126 large_scenarios = np.random.standard_t(df=3, size=100000)
127
128 # Test standard implementation
129 start = time.time()
130 var_std = -np.percentile(large_scenarios, 1)
131 cvar_std = -large_scenarios[large_scenarios <= -var_std].mean()
132 time_std = time.time() - start
133
134 # Test Numba implementation
135 start = time.time()
136 var_numba, cvar_numba = fast_cvar_calculation(large_scenarios, 0.99)
137 time_numba = time.time() - start
138
139 print(f"Standard: {time_std*1000:.2f}ms")
140 print(f"Numba: {time_numba*1000:.2f}ms")
141 print(f"Speedup: {time_std/time_numba:.1f}x")
142Instead of mean-variance optimization (Markowitz), use mean-CVaR:
This is a linear program (thanks to Rockafellar-Uryasev)!
1from scipy.optimize import linprog
2
3class MeanCVaROptimizer:
4 """
5 Mean-CVaR portfolio optimization
6
7 Finds the portfolio with minimum CVaR subject to return constraint
8 """
9
10 def optimize(self,
11 returns: pd.DataFrame,
12 target_return: float = 0.001,
13 confidence_level: float = 0.95,
14 allow_short: bool = False) -> dict:
15 """
16 Solve mean-CVaR optimization
17
18 Args:
19 returns: Historical returns (assets as columns)
20 target_return: Minimum required expected return
21 confidence_level: CVaR confidence level
22 allow_short: Allow short positions
23
24 Returns:
25 Dict with optimal weights, CVaR, and statistics
26 """
27 N_scenarios = len(returns)
28 N_assets = len(returns.columns)
29 alpha = 1 - confidence_level
30
31 # Variables: [w_1, ..., w_N, gamma, z_1, ..., z_N_scenarios]
32 n_vars = N_assets + 1 + N_scenarios
33
34 # Objective: min gamma + (1/(alpha*N)) * sum(z_i)
35 c = np.zeros(n_vars)
36 c[N_assets] = 1.0
37 c[N_assets+1:] = 1.0 / (alpha * N_scenarios)
38
39 # Inequality constraints
40 A_ub_list = []
41 b_ub_list = []
42
43 # z_i >= -sum(w_j * r_ij) - gamma
44 # Rewrite: sum(w_j * r_ij) + gamma + z_i >= 0
45 for i in range(N_scenarios):
46 row = np.zeros(n_vars)
47 row[:N_assets] = returns.iloc[i].values
48 row[N_assets] = 1.0
49 row[N_assets + 1 + i] = 1.0
50 A_ub_list.append(-row) # Flip for <= constraint
51 b_ub_list.append(0)
52
53 # Return constraint: sum(w_j * mean_j) >= target_return
54 mean_returns = returns.mean().values
55 row = np.zeros(n_vars)
56 row[:N_assets] = -mean_returns
57 A_ub_list.append(row)
58 b_ub_list.append(-target_return)
59
60 A_ub = np.array(A_ub_list)
61 b_ub = np.array(b_ub_list)
62
63 # Equality constraint: sum(w_i) = 1
64 A_eq = np.zeros((1, n_vars))
65 A_eq[0, :N_assets] = 1.0
66 b_eq = np.array([1.0])
67
68 # Bounds
69 if allow_short:
70 weight_bounds = (-1, 1)
71 else:
72 weight_bounds = (0, 1)
73
74 bounds = [weight_bounds] * N_assets + [(None, None)] + [(0, None)] * N_scenarios
75
76 # Solve
77 result = linprog(c, A_ub=A_ub, b_ub=b_ub, A_eq=A_eq, b_eq=b_eq,
78 bounds=bounds, method='highs')
79
80 if not result.success:
81 raise ValueError(f"Optimization failed: {result.message}")
82
83 weights = result.x[:N_assets]
84 optimal_cvar = result.fun
85
86 # Calculate portfolio statistics
87 portfolio_return = weights @ mean_returns
88 portfolio_std = np.sqrt(weights @ returns.cov().values @ weights)
89
90 # Calculate VaR for comparison
91 portfolio_returns = returns.values @ weights
92 var = -np.percentile(portfolio_returns, (1-confidence_level)*100)
93
94 return {
95 'weights': pd.Series(weights, index=returns.columns),
96 'cvar': optimal_cvar,
97 'var': var,
98 'expected_return': portfolio_return,
99 'volatility': portfolio_std,
100 'sharpe_ratio': portfolio_return / portfolio_std if portfolio_std > 0 else 0,
101 'cvar_var_ratio': optimal_cvar / var if var > 0 else np.nan
102 }
103
104
105# Example: Compare mean-variance vs mean-CVaR
106if __name__ == "__main__":
107 # Generate sample returns
108 np.random.seed(42)
109 dates = pd.date_range(end='2025-11-25', periods=500, freq='D')
110
111 # Create returns with different risk profiles
112 returns = pd.DataFrame({
113 'Low_Risk': np.random.normal(0.0003, 0.005, 500),
114 'Medium_Risk': np.random.normal(0.0005, 0.01, 500),
115 'High_Risk': np.random.normal(0.0008, 0.02, 500),
116 'Fat_Tail': np.random.standard_t(df=3, size=500) * 0.015,
117 }, index=dates)
118
119 optimizer = MeanCVaROptimizer()
120
121 # Optimize for different target returns
122 target_returns = [0.0003, 0.0005, 0.0007]
123
124 print("\nMean-CVaR Efficient Frontier:")
125 print("=" * 80)
126 print(f"{'Target Return':<15} {'CVaR':<12} {'VaR':<12} {'Volatility':<12} {'Sharpe':<10}")
127 print("=" * 80)
128
129 for target in target_returns:
130 result = optimizer.optimize(
131 returns=returns,
132 target_return=target,
133 confidence_level=0.95
134 )
135
136 print(f"{target:<15.4%} {result['cvar']:<12.6f} {result['var']:<12.6f} "
137 f"{result['volatility']:<12.6f} {result['sharpe_ratio']:<10.4f}")
138
139 print("\nWeights:")
140 print(result['weights'][result['weights'] > 0.01])
141 print()
142Basel III (FRTB - Fundamental Review of the Trading Book) mandates Expected Shortfall instead of VaR for market risk capital:
Why the change?
Requirements:
1class BaselIIIExpectedShortfall:
2 """
3 Basel III Expected Shortfall calculator
4
5 Implements FRTB requirements:
6 - 97.5% confidence level
7 - 10-day horizon
8 - Stressed calibration
9 """
10
11 def calculate_frtb_es(self,
12 returns: pd.DataFrame,
13 positions: pd.DataFrame,
14 stressed_period: Tuple[str, str] = None) -> dict:
15 """
16 Calculate Basel III Expected Shortfall
17
18 Args:
19 returns: Historical returns
20 positions: Current positions
21 stressed_period: Tuple of (start_date, end_date) for stressed period
22
23 Returns:
24 Dict with ES, VaR, and regulatory capital
25 """
26 # Use stressed period if specified
27 if stressed_period:
28 returns_stressed = returns.loc[stressed_period[0]:stressed_period[1]]
29 else:
30 # Use most volatile 12-month period
31 returns_stressed = self._find_stressed_period(returns)
32
33 # Calculate 10-day returns (overlapping)
34 returns_10d = returns_stressed.rolling(window=10).sum().dropna()
35
36 # Calculate portfolio P&L
37 portfolio_value = (positions['quantity'] * positions['current_price']).sum()
38 positions_dict = positions.set_index('asset')
39
40 pnl_scenarios = np.zeros(len(returns_10d))
41
42 for asset in returns_10d.columns:
43 if asset in positions_dict.index:
44 qty = positions_dict.loc[asset, 'quantity']
45 price = positions_dict.loc[asset, 'current_price']
46 pnl_scenarios += qty * price * returns_10d[asset].values
47
48 # Calculate 97.5% ES
49 var_975 = -np.percentile(pnl_scenarios, 2.5)
50 es_975 = -pnl_scenarios[pnl_scenarios <= -var_975].mean()
51
52 # Regulatory capital = ES * multiplier
53 # Multiplier >= 1.5 (can increase based on backtesting)
54 multiplier = 1.5
55 regulatory_capital = es_975 * multiplier
56
57 return {
58 'es_975': es_975,
59 'var_975': var_975,
60 'multiplier': multiplier,
61 'regulatory_capital': regulatory_capital,
62 'num_scenarios': len(pnl_scenarios),
63 'stressed_period': stressed_period
64 }
65
66 def _find_stressed_period(self,
67 returns: pd.DataFrame,
68 window_days: int = 250) -> pd.DataFrame:
69 """Find the most volatile 12-month period"""
70
71 rolling_vol = returns.std(axis=1).rolling(window=window_days).mean()
72 max_vol_idx = rolling_vol.idxmax()
73
74 start_date = max_vol_idx - pd.Timedelta(days=window_days)
75 end_date = max_vol_idx
76
77 return returns.loc[start_date:end_date]
78CVaR (Expected Shortfall) is superior to VaR in every meaningful way:
Key Takeaways:
Next Steps:
About the Author: This article is part of NordVarg's series on production-grade risk management. For related content, see our articles on VaR, stress testing, and portfolio optimization.
Technical Writer
NordVarg Team is a software engineer at NordVarg specializing in high-performance financial systems and type-safe programming.
Get weekly insights on building high-performance financial systems, latest industry trends, and expert tips delivered straight to your inbox.