CVaR allocation#
In this notebook, we solve a CVaR portfolio optimization problem: minimizing expected losses in the worst-case scenarios rather than overall variance.
Unlike mean-variance optimization which penalizes all volatility equally, CVaR focuses on worst-case scenarios: the expected loss in the worst α% of outcomes. This makes it useful for risk-averse investors concerned about extreme market downturns.
This example is based on the formulation from PyPortfolioOpt and Rockafellar & Uryasev (2000).
Features used:
Varwith vector-valued and scalar defaultsInequality constraints (
constraint_geq_zero): CVaR auxiliary constraints, budget, no short-sellingEquality constraints (
constraint_eq_zero): budget constraintAugmented Lagrangian solver for constrained optimization
import jax
import jax.numpy as jnp
import jaxls
Historical stock data#
We use the same dataset as the Mean-variance allocation example: monthly stock prices from November 2000 to November 2001 for IBM, Walmart (WMT), and Southern Electric (SEHI).
stock_names = ["IBM", "WMT", "SEHI"]
# Monthly prices (13 months: Nov 2000 - Nov 2001)
prices = jnp.array(
[
[93.043, 51.826, 1.063],
[84.585, 52.823, 0.938],
[111.453, 56.477, 1.0],
[99.525, 49.805, 0.938],
[95.819, 50.287, 1.438],
[114.708, 51.521, 1.7],
[111.515, 51.531, 2.54],
[113.211, 48.664, 2.39],
[104.942, 55.744, 3.12],
[99.827, 47.916, 2.98],
[91.607, 49.438, 1.9],
[107.937, 51.336, 1.75],
[115.59, 55.081, 1.8],
]
)
# Monthly returns: (P[t+1] - P[t]) / P[t]
returns = jnp.diff(prices, axis=0) / prices[:-1]
num_scenarios, num_assets = returns.shape
print(
f"Returns shape: {returns.shape} ({num_scenarios} scenarios x {num_assets} assets)"
)
print(f"\nMonthly returns (%):\n{returns * 100}")
Returns shape: (12, 3) (12 scenarios x 3 assets)
Monthly returns (%):
[[-9.0904207e+00 1.9237481e+00 -1.1759167e+01]
[ 3.1764503e+01 6.9174395e+00 6.6098056e+00]
[-1.0702270e+01 -1.1813660e+01 -6.1999979e+00]
[-3.7236888e+00 9.6777117e-01 5.3304897e+01]
[ 1.9713209e+01 2.4539170e+00 1.8219755e+01]
[-2.7835901e+00 1.9406304e-02 4.9411755e+01]
[ 1.5208708e+00 -5.5636353e+00 -5.9055061e+00]
[-7.3040590e+00 1.4548737e+01 3.0543922e+01]
[-4.8741188e+00 -1.4042765e+01 -4.4871755e+00]
[-8.2342463e+00 3.1763906e+00 -3.6241615e+01]
[ 1.7826141e+01 3.8391497e+00 -7.8947358e+00]
[ 7.0902476e+00 7.2950811e+00 2.8571403e+00]]
CVaR vs variance#
Variance measures average deviation from the mean – it penalizes upside and downside equally.
CVaR (Conditional Value at Risk) measures the expected loss in the worst \(\alpha\%\) of scenarios. For \(\alpha = 0.05\) (95% confidence), CVaR answers: “What’s my average loss on the worst 5% of days?”
Key advantages of CVaR:
Focuses on tail risk (extreme losses) rather than general volatility
Coherent risk measure (subadditive, convex)
Does not assume normally distributed returns
More robust to outliers than variance
CVaR formulation#
The CVaR optimization uses the Rockafellar-Uryasev formulation:
where:
\(w\) = portfolio weights
\(r_t\) = returns in scenario \(t\)
\(\zeta\) = VaR threshold (auxiliary variable)
\(\alpha\) = tail probability (e.g., 0.05 for 95% CVaR)
\(T\) = number of scenarios
To handle the \(\max(\cdot, 0)\) term, we introduce slack variables \(u_t \geq 0\):
subject to:
\(u_t \geq -w^\top r_t - \zeta\) (loss exceeds VaR)
\(u_t \geq 0\) (slack non-negativity)
\(\sum_i w_i = 1\) (budget constraint)
\(w_i \geq 0\) (no short-selling)
# CVaR confidence level.
alpha = 0.05 # 95% CVaR (worst 5% of scenarios)
class WeightsVar(jaxls.Var[jax.Array], default_factory=lambda: jnp.ones(3) / 3):
"""Portfolio weights (3D vector)."""
class VaRVar(jaxls.Var[jax.Array], default_factory=lambda: jnp.zeros(1)):
"""Value-at-Risk threshold (scalar)."""
class SlackVar(jaxls.Var[jax.Array], default_factory=lambda: jnp.zeros(12)):
"""Slack variables for max(loss - VaR, 0) per scenario."""
weights_var = WeightsVar(id=0)
var_var = VaRVar(id=0)
slack_var = SlackVar(id=0)
@jaxls.Cost.factory
def cvar_objective(
vals: jaxls.VarValues,
var_v: VaRVar,
slack_v: SlackVar,
alpha: float,
num_scenarios: int,
) -> jax.Array:
"""CVaR objective: VaR + (1/alpha) * mean(slack).
Since this is the only cost term, the solver minimizes CVaR^2. For a
non-negative scalar, min(CVaR^2) has the same minimizer as min(CVaR).
"""
var_threshold = vals[var_v]
slack = vals[slack_v]
return var_threshold + jnp.sum(slack) / (alpha * num_scenarios)
@jaxls.Cost.factory(kind="constraint_geq_zero")
def slack_lower_bound(
vals: jaxls.VarValues,
weights_v: WeightsVar,
var_v: VaRVar,
slack_v: SlackVar,
scenario_returns: jax.Array,
) -> jax.Array:
"""Constraint: u_t >= -w'r_t - zeta (slack captures excess loss)."""
weights = vals[weights_v]
var_threshold = vals[var_v]
slack = vals[slack_v]
# Portfolio return for each scenario.
portfolio_returns = scenario_returns @ weights
# Loss = negative return.
losses = -portfolio_returns
# u_t >= loss_t - VaR.
return slack - (losses - var_threshold)
@jaxls.Cost.factory(kind="constraint_geq_zero")
def slack_nonneg(vals: jaxls.VarValues, slack_v: SlackVar) -> jax.Array:
"""Constraint: u_t >= 0."""
return vals[slack_v]
@jaxls.Cost.factory(kind="constraint_eq_zero")
def budget_constraint(vals: jaxls.VarValues, weights_v: WeightsVar) -> jax.Array:
"""Weights must sum to 1 (fully invested)."""
return jnp.sum(vals[weights_v]) - 1.0
@jaxls.Cost.factory(kind="constraint_geq_zero")
def no_short_constraint(vals: jaxls.VarValues, weights_v: WeightsVar) -> jax.Array:
"""No short-selling: weights >= 0."""
return vals[weights_v]
Solving#
costs = [
cvar_objective(var_var, slack_var, alpha, num_scenarios),
slack_lower_bound(weights_var, var_var, slack_var, returns),
slack_nonneg(slack_var),
budget_constraint(weights_var),
no_short_constraint(weights_var),
]
# Build the problem.
problem = jaxls.LeastSquaresProblem(costs, [weights_var, var_var, slack_var])
# Visualize the problem structure structure.
problem.show()
# Analyze and solve.
problem = problem.analyze()
solution = problem.solve(
verbose=True,
linear_solver="dense_cholesky",
termination=jaxls.TerminationConfig(cost_tolerance=1e-8),
)
INFO | Building optimization problem with 5 terms and 3 variables: 1 costs, 1 eq_zero, 0 leq_zero, 3 geq_zero
INFO | Vectorizing group with 1 costs, 2 variables each: cvar_objective
INFO | Vectorizing constraint group with 1 constraints (constraint_geq_zero), 3 variables each: augmented_slack_lower_bound
INFO | Vectorizing constraint group with 1 constraints (constraint_geq_zero), 1 variables each: augmented_slack_nonneg
INFO | Vectorizing constraint group with 1 constraints (constraint_eq_zero), 1 variables each: augmented_budget_constraint
INFO | Vectorizing constraint group with 1 constraints (constraint_geq_zero), 1 variables each: augmented_no_short_constraint
INFO | Variable elimination: eliminating SlackVar (12 of 16 tangent dims); reduced system is 4-dimensional
INFO | Augmented Lagrangian: initial snorm=1.3766e-01, csupn=1.3766e-01, max_rho=1.0000e+01, constraint_dim=28
INFO | step #0: cost=0.0000 lambd=0.0005
INFO | - cvar_objective(1): 0.00000 (avg 0.00000)
INFO | - augmented_slack_lower_bound(1): 0.39280 (avg 0.03273)
INFO | - augmented_slack_nonneg(1): 0.00000 (avg 0.00000)
INFO | - augmented_budget_constraint(1): 0.00000 (avg 0.00000)
INFO | - augmented_no_short_constraint(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=5.26e+00 cost_prev=0.3928 cost_new=0.1026
INFO | step #1: cost=0.0000 lambd=0.0003
INFO | - cvar_objective(1): 0.00000 (avg 0.00000)
INFO | - augmented_slack_lower_bound(1): 0.00000 (avg 0.00000)
INFO | - augmented_slack_nonneg(1): 0.10255 (avg 0.00855)
INFO | - augmented_budget_constraint(1): 0.00000 (avg 0.00000)
INFO | - augmented_no_short_constraint(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=1.39e+00 cost_prev=0.1026 cost_new=0.0456
INFO | step #2: cost=0.0000 lambd=0.0001
INFO | - cvar_objective(1): 0.00000 (avg 0.00000)
INFO | - augmented_slack_lower_bound(1): 0.00000 (avg 0.00000)
INFO | - augmented_slack_nonneg(1): 0.04562 (avg 0.00380)
INFO | - augmented_budget_constraint(1): 0.00000 (avg 0.00000)
INFO | - augmented_no_short_constraint(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=8.23e-01 cost_prev=0.0456 cost_new=0.0125
INFO | step #3: cost=0.0015 lambd=0.0001
INFO | - cvar_objective(1): 0.00149 (avg 0.00149)
INFO | - augmented_slack_lower_bound(1): 0.00207 (avg 0.00017)
INFO | - augmented_slack_nonneg(1): 0.00889 (avg 0.00074)
INFO | - augmented_budget_constraint(1): 0.00002 (avg 0.00002)
INFO | - augmented_no_short_constraint(1): 0.00000 (avg 0.00000)
INFO | step #4: cost=0.0015 lambd=0.0003
INFO | - cvar_objective(1): 0.00149 (avg 0.00149)
INFO | - augmented_slack_lower_bound(1): 0.00207 (avg 0.00017)
INFO | - augmented_slack_nonneg(1): 0.00889 (avg 0.00074)
INFO | - augmented_budget_constraint(1): 0.00002 (avg 0.00002)
INFO | - augmented_no_short_constraint(1): 0.00000 (avg 0.00000)
INFO | step #5: cost=0.0015 lambd=0.0020
INFO | - cvar_objective(1): 0.00149 (avg 0.00149)
INFO | - augmented_slack_lower_bound(1): 0.00207 (avg 0.00017)
INFO | - augmented_slack_nonneg(1): 0.00889 (avg 0.00074)
INFO | - augmented_budget_constraint(1): 0.00002 (avg 0.00002)
INFO | - augmented_no_short_constraint(1): 0.00000 (avg 0.00000)
INFO | step #6: cost=0.0015 lambd=0.0320
INFO | - cvar_objective(1): 0.00149 (avg 0.00149)
INFO | - augmented_slack_lower_bound(1): 0.00207 (avg 0.00017)
INFO | - augmented_slack_nonneg(1): 0.00889 (avg 0.00074)
INFO | - augmented_budget_constraint(1): 0.00002 (avg 0.00002)
INFO | - augmented_no_short_constraint(1): 0.00000 (avg 0.00000)
INFO | step #7: cost=0.0015 lambd=1.0240
INFO | - cvar_objective(1): 0.00149 (avg 0.00149)
INFO | - augmented_slack_lower_bound(1): 0.00207 (avg 0.00017)
INFO | - augmented_slack_nonneg(1): 0.00889 (avg 0.00074)
INFO | - augmented_budget_constraint(1): 0.00002 (avg 0.00002)
INFO | - augmented_no_short_constraint(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=3.05e-01 cost_prev=0.0125 cost_new=0.0047
INFO | step #8: cost=0.0004 lambd=0.5120
INFO | - cvar_objective(1): 0.00039 (avg 0.00039)
INFO | - augmented_slack_lower_bound(1): 0.00007 (avg 0.00001)
INFO | - augmented_slack_nonneg(1): 0.00424 (avg 0.00035)
INFO | - augmented_budget_constraint(1): 0.00000 (avg 0.00000)
INFO | - augmented_no_short_constraint(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=1.98e-01 cost_prev=0.0047 cost_new=0.0028
INFO | step #9: cost=0.0005 lambd=0.2560
INFO | - cvar_objective(1): 0.00046 (avg 0.00046)
INFO | - augmented_slack_lower_bound(1): 0.00019 (avg 0.00002)
INFO | - augmented_slack_nonneg(1): 0.00213 (avg 0.00018)
INFO | - augmented_budget_constraint(1): 0.00000 (avg 0.00000)
INFO | - augmented_no_short_constraint(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=9.67e-02 cost_prev=0.0028 cost_new=0.0025
INFO | step #10: cost=0.0006 lambd=0.1280
INFO | - cvar_objective(1): 0.00059 (avg 0.00059)
INFO | - augmented_slack_lower_bound(1): 0.00005 (avg 0.00000)
INFO | - augmented_slack_nonneg(1): 0.00181 (avg 0.00015)
INFO | - augmented_budget_constraint(1): 0.00000 (avg 0.00000)
INFO | - augmented_no_short_constraint(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=1.12e-03 cost_prev=0.0025 cost_new=0.0025
INFO | AL update: snorm=4.0475e-03, csupn=4.0475e-03, max_rho=4.0000e+01
INFO | step #11: cost=0.0006 lambd=0.0640
INFO | - cvar_objective(1): 0.00059 (avg 0.00059)
INFO | - augmented_slack_lower_bound(1): 0.00019 (avg 0.00002)
INFO | - augmented_slack_nonneg(1): 0.01135 (avg 0.00095)
INFO | - augmented_budget_constraint(1): 0.00000 (avg 0.00000)
INFO | - augmented_no_short_constraint(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=7.17e-01 cost_prev=0.0121 cost_new=0.0080
INFO | step #12: cost=0.0043 lambd=0.0320
INFO | - cvar_objective(1): 0.00433 (avg 0.00433)
INFO | - augmented_slack_lower_bound(1): 0.00033 (avg 0.00003)
INFO | - augmented_slack_nonneg(1): 0.00334 (avg 0.00028)
INFO | - augmented_budget_constraint(1): 0.00000 (avg 0.00000)
INFO | - augmented_no_short_constraint(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=5.34e-04 cost_prev=0.0080 cost_new=0.0080
INFO | AL update: snorm=3.5879e-03, csupn=3.5879e-03, max_rho=4.0000e+01
INFO | step #13: cost=0.0043 lambd=0.0160
INFO | - cvar_objective(1): 0.00433 (avg 0.00433)
INFO | - augmented_slack_lower_bound(1): 0.00103 (avg 0.00009)
INFO | - augmented_slack_nonneg(1): 0.00886 (avg 0.00074)
INFO | - augmented_budget_constraint(1): 0.00000 (avg 0.00000)
INFO | - augmented_no_short_constraint(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=4.21e-01 cost_prev=0.0142 cost_new=0.0131
INFO | step #14: cost=0.0073 lambd=0.0080
INFO | - cvar_objective(1): 0.00729 (avg 0.00729)
INFO | - augmented_slack_lower_bound(1): 0.00015 (avg 0.00001)
INFO | - augmented_slack_nonneg(1): 0.00562 (avg 0.00047)
INFO | - augmented_budget_constraint(1): 0.00000 (avg 0.00000)
INFO | - augmented_no_short_constraint(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=1.23e-03 cost_prev=0.0131 cost_new=0.0131
INFO | AL update: snorm=8.1804e-04, csupn=8.1804e-04, max_rho=4.0000e+01
INFO | step #15: cost=0.0073 lambd=0.0040
INFO | - cvar_objective(1): 0.00730 (avg 0.00730)
INFO | - augmented_slack_lower_bound(1): 0.00022 (avg 0.00002)
INFO | - augmented_slack_nonneg(1): 0.00850 (avg 0.00071)
INFO | - augmented_budget_constraint(1): 0.00000 (avg 0.00000)
INFO | - augmented_no_short_constraint(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=1.50e-01 cost_prev=0.0160 cost_new=0.0158
INFO | step #16: cost=0.0089 lambd=0.0020
INFO | - cvar_objective(1): 0.00885 (avg 0.00885)
INFO | - augmented_slack_lower_bound(1): 0.00017 (avg 0.00001)
INFO | - augmented_slack_nonneg(1): 0.00681 (avg 0.00057)
INFO | - augmented_budget_constraint(1): 0.00000 (avg 0.00000)
INFO | - augmented_no_short_constraint(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=4.41e-04 cost_prev=0.0158 cost_new=0.0158
INFO | AL update: snorm=3.6069e-04, csupn=3.6069e-04, max_rho=4.0000e+01
INFO | step #17: cost=0.0089 lambd=0.0010
INFO | - cvar_objective(1): 0.00885 (avg 0.00885)
INFO | - augmented_slack_lower_bound(1): 0.00020 (avg 0.00002)
INFO | - augmented_slack_nonneg(1): 0.00812 (avg 0.00068)
INFO | - augmented_budget_constraint(1): 0.00000 (avg 0.00000)
INFO | - augmented_no_short_constraint(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=6.63e-02 cost_prev=0.0172 cost_new=0.0171
INFO | step #18: cost=0.0096 lambd=0.0005
INFO | - cvar_objective(1): 0.00958 (avg 0.00958)
INFO | - augmented_slack_lower_bound(1): 0.00019 (avg 0.00002)
INFO | - augmented_slack_nonneg(1): 0.00737 (avg 0.00061)
INFO | - augmented_budget_constraint(1): 0.00000 (avg 0.00000)
INFO | - augmented_no_short_constraint(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=4.31e-04 cost_prev=0.0171 cost_new=0.0171
INFO | AL update: snorm=1.5833e-04, csupn=1.5833e-04, max_rho=4.0000e+01
INFO | step #19: cost=0.0096 lambd=0.0003
INFO | - cvar_objective(1): 0.00958 (avg 0.00958)
INFO | - augmented_slack_lower_bound(1): 0.00020 (avg 0.00002)
INFO | - augmented_slack_nonneg(1): 0.00796 (avg 0.00066)
INFO | - augmented_budget_constraint(1): 0.00000 (avg 0.00000)
INFO | - augmented_no_short_constraint(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=2.91e-02 cost_prev=0.0177 cost_new=0.0177
INFO | step #20: cost=0.0099 lambd=0.0001
INFO | - cvar_objective(1): 0.00991 (avg 0.00991)
INFO | - augmented_slack_lower_bound(1): 0.00019 (avg 0.00002)
INFO | - augmented_slack_nonneg(1): 0.00763 (avg 0.00064)
INFO | - augmented_budget_constraint(1): 0.00000 (avg 0.00000)
INFO | - augmented_no_short_constraint(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=4.35e-04 cost_prev=0.0177 cost_new=0.0177
INFO | AL update: snorm=6.9220e-05, csupn=6.9220e-05, max_rho=4.0000e+01
INFO | step #21: cost=0.0099 lambd=0.0001
INFO | - cvar_objective(1): 0.00991 (avg 0.00991)
INFO | - augmented_slack_lower_bound(1): 0.00020 (avg 0.00002)
INFO | - augmented_slack_nonneg(1): 0.00788 (avg 0.00066)
INFO | - augmented_budget_constraint(1): 0.00000 (avg 0.00000)
INFO | - augmented_no_short_constraint(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=1.27e-02 cost_prev=0.0180 cost_new=0.0180
INFO | step #22: cost=0.0101 lambd=0.0000
INFO | - cvar_objective(1): 0.01006 (avg 0.01006)
INFO | - augmented_slack_lower_bound(1): 0.00020 (avg 0.00002)
INFO | - augmented_slack_nonneg(1): 0.00774 (avg 0.00064)
INFO | - augmented_budget_constraint(1): 0.00000 (avg 0.00000)
INFO | - augmented_no_short_constraint(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=4.37e-04 cost_prev=0.0180 cost_new=0.0180
INFO | AL update: snorm=2.9921e-05, csupn=2.9921e-05, max_rho=4.0000e+01
INFO | step #23: cost=0.0101 lambd=0.0000
INFO | - cvar_objective(1): 0.01005 (avg 0.01005)
INFO | - augmented_slack_lower_bound(1): 0.00020 (avg 0.00002)
INFO | - augmented_slack_nonneg(1): 0.00785 (avg 0.00065)
INFO | - augmented_budget_constraint(1): 0.00000 (avg 0.00000)
INFO | - augmented_no_short_constraint(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=5.52e-03 cost_prev=0.0181 cost_new=0.0181
INFO | AL update: snorm=1.2883e-05, csupn=1.2883e-05, max_rho=4.0000e+01
INFO | step #24: cost=0.0101 lambd=0.0000
INFO | - cvar_objective(1): 0.01012 (avg 0.01012)
INFO | - augmented_slack_lower_bound(1): 0.00020 (avg 0.00002)
INFO | - augmented_slack_nonneg(1): 0.00783 (avg 0.00065)
INFO | - augmented_budget_constraint(1): 0.00000 (avg 0.00000)
INFO | - augmented_no_short_constraint(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=2.41e-03 cost_prev=0.0181 cost_new=0.0181
INFO | AL update: snorm=5.3916e-06, csupn=5.3916e-06, max_rho=4.0000e+01
INFO | step #25: cost=0.0101 lambd=0.0000
INFO | - cvar_objective(1): 0.01014 (avg 0.01014)
INFO | - augmented_slack_lower_bound(1): 0.00020 (avg 0.00002)
INFO | - augmented_slack_nonneg(1): 0.00782 (avg 0.00065)
INFO | - augmented_budget_constraint(1): 0.00000 (avg 0.00000)
INFO | - augmented_no_short_constraint(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=1.08e-03 cost_prev=0.0182 cost_new=0.0182
INFO | AL update: snorm=2.0723e-06, csupn=2.0723e-06, max_rho=1.6000e+02
INFO | step #26: cost=0.0102 lambd=0.0000
INFO | - cvar_objective(1): 0.01015 (avg 0.01015)
INFO | - augmented_slack_lower_bound(1): 0.00020 (avg 0.00002)
INFO | - augmented_slack_nonneg(1): 0.00782 (avg 0.00065)
INFO | - augmented_budget_constraint(1): 0.00000 (avg 0.00000)
INFO | - augmented_no_short_constraint(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=5.83e-04 cost_prev=0.0182 cost_new=0.0182
INFO | AL update: snorm=8.3803e-07, csupn=8.3803e-07, max_rho=1.6000e+02
INFO | step #27: cost=0.0102 lambd=0.0000
INFO | - cvar_objective(1): 0.01016 (avg 0.01016)
INFO | - augmented_slack_lower_bound(1): 0.00020 (avg 0.00002)
INFO | - augmented_slack_nonneg(1): 0.00782 (avg 0.00065)
INFO | - augmented_budget_constraint(1): 0.00000 (avg 0.00000)
INFO | - augmented_no_short_constraint(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=4.66e-04 cost_prev=0.0182 cost_new=0.0182
INFO | AL update: snorm=2.9107e-07, csupn=2.9107e-07, max_rho=6.4000e+02
INFO | step #28: cost=0.0102 lambd=0.0000
INFO | - cvar_objective(1): 0.01016 (avg 0.01016)
INFO | - augmented_slack_lower_bound(1): 0.00020 (avg 0.00002)
INFO | - augmented_slack_nonneg(1): 0.00782 (avg 0.00065)
INFO | - augmented_budget_constraint(1): 0.00000 (avg 0.00000)
INFO | - augmented_no_short_constraint(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=4.52e-04 cost_prev=0.0182 cost_new=0.0182
INFO | AL update: snorm=1.1921e-07, csupn=1.1921e-07, max_rho=2.5600e+03
INFO | step #29: cost=0.0102 lambd=0.0000
INFO | - cvar_objective(1): 0.01016 (avg 0.01016)
INFO | - augmented_slack_lower_bound(1): 0.00005 (avg 0.00000)
INFO | - augmented_slack_nonneg(1): 0.00782 (avg 0.00065)
INFO | - augmented_budget_constraint(1): 0.00000 (avg 0.00000)
INFO | - augmented_no_short_constraint(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=9.18e-04 cost_prev=0.0180 cost_new=0.0180
INFO | AL update: snorm=1.1921e-07, csupn=1.1921e-07, max_rho=1.0240e+04
INFO | step #30: cost=0.0102 lambd=0.0000
INFO | - cvar_objective(1): 0.01016 (avg 0.01016)
INFO | - augmented_slack_lower_bound(1): 0.00005 (avg 0.00000)
INFO | - augmented_slack_nonneg(1): 0.00195 (avg 0.00016)
INFO | - augmented_budget_constraint(1): 0.00000 (avg 0.00000)
INFO | - augmented_no_short_constraint(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=3.14e-03 cost_prev=0.0122 cost_new=0.0122
INFO | AL update: snorm=2.7315e-08, csupn=2.7315e-08, max_rho=1.0240e+04
INFO | step #31: cost=0.0102 lambd=0.0000
INFO | - cvar_objective(1): 0.01016 (avg 0.01016)
INFO | - augmented_slack_lower_bound(1): 0.00001 (avg 0.00000)
INFO | - augmented_slack_nonneg(1): 0.00195 (avg 0.00016)
INFO | - augmented_budget_constraint(1): 0.00000 (avg 0.00000)
INFO | - augmented_no_short_constraint(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=6.65e-04 cost_prev=0.0121 cost_new=0.0121
INFO | AL update: snorm=1.1921e-07, csupn=1.1921e-07, max_rho=4.0960e+04
INFO | step #32: cost=0.0102 lambd=0.0000
INFO | - cvar_objective(1): 0.01016 (avg 0.01016)
INFO | - augmented_slack_lower_bound(1): 0.00001 (avg 0.00000)
INFO | - augmented_slack_nonneg(1): 0.00049 (avg 0.00004)
INFO | - augmented_budget_constraint(1): 0.00000 (avg 0.00000)
INFO | - augmented_no_short_constraint(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=1.26e-02 cost_prev=0.0107 cost_new=0.0107
INFO | step #33: cost=0.0102 lambd=0.0000
INFO | - cvar_objective(1): 0.01016 (avg 0.01016)
INFO | - augmented_slack_lower_bound(1): 0.00001 (avg 0.00000)
INFO | - augmented_slack_nonneg(1): 0.00049 (avg 0.00004)
INFO | - augmented_budget_constraint(1): 0.00000 (avg 0.00000)
INFO | - augmented_no_short_constraint(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=3.14e-03 cost_prev=0.0107 cost_new=0.0107
INFO | AL update: snorm=8.0024e-09, csupn=7.8877e-09, max_rho=4.0960e+04
INFO | step #34: cost=0.0102 lambd=0.0000
INFO | - cvar_objective(1): 0.01016 (avg 0.01016)
INFO | - augmented_slack_lower_bound(1): 0.00001 (avg 0.00000)
INFO | - augmented_slack_nonneg(1): 0.00049 (avg 0.00004)
INFO | - augmented_budget_constraint(1): 0.00000 (avg 0.00000)
INFO | - augmented_no_short_constraint(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=2.15e-03 cost_prev=0.0107 cost_new=0.0107
INFO | AL update: snorm=5.9605e-08, csupn=5.9605e-08, max_rho=1.6384e+05
INFO | step #35: cost=0.0102 lambd=0.0000
INFO | - cvar_objective(1): 0.01016 (avg 0.01016)
INFO | - augmented_slack_lower_bound(1): 0.00000 (avg 0.00000)
INFO | - augmented_slack_nonneg(1): 0.00049 (avg 0.00004)
INFO | - augmented_budget_constraint(1): 0.00000 (avg 0.00000)
INFO | - augmented_no_short_constraint(1): 0.00000 (avg 0.00000)
INFO | accepted=True ATb_norm=2.40e-02 cost_prev=0.0107 cost_new=0.0107
INFO | AL update: snorm=7.6446e-09, csupn=7.6446e-09, max_rho=1.6384e+05
INFO | Terminated @ iteration #36: cost=0.0102 criteria=[1 0 0], term_deltas=0.0e+00,1.4e-02,1.5e-06 (solved in 0.1705 sec)
# Extract solution.
optimal_weights = solution[weights_var]
optimal_var = float(solution[var_var][0])
# Compute CVaR from the solution.
portfolio_returns = returns @ optimal_weights
losses = -portfolio_returns
# CVaR is the mean of losses exceeding VaR.
tail_losses = jnp.where(losses >= optimal_var, losses, 0.0)
cvar_value = optimal_var + jnp.sum(jnp.maximum(losses - optimal_var, 0)) / (
alpha * num_scenarios
)
print("\n=== CVaR-Optimal Portfolio ===")
print(f"\nAlpha (tail probability): {alpha:.0%}")
print("\nOptimal weights:")
for name, w in zip(stock_names, optimal_weights):
print(f" {name}: {float(w) * 100:.1f}%")
print(f"\nVaR (95%): {optimal_var * 100:.2f}% monthly loss")
print(
f"CVaR (95%): {float(cvar_value) * 100:.2f}% expected loss in worst {alpha:.0%} of scenarios"
)
=== CVaR-Optimal Portfolio ===
Alpha (tail probability): 5%
Optimal weights:
IBM: 29.9%
WMT: 45.1%
SEHI: 25.0%
VaR (95%): 10.08% monthly loss
CVaR (95%): 10.08% expected loss in worst 5% of scenarios
Comparison: CVaR vs mean-variance#
Let’s compare the CVaR-optimal portfolio with a minimum-variance portfolio.
# Compute covariance matrix for mean-variance comparison.
expected_returns = jnp.mean(returns, axis=0)
returns_centered = returns - expected_returns
covariance = (returns_centered.T @ returns_centered) / (returns.shape[0] - 1)
cov_chol = jnp.linalg.cholesky(covariance)
class MVWeightsVar(jaxls.Var[jax.Array], default_factory=lambda: jnp.ones(3) / 3):
"""Portfolio weights for mean-variance optimization."""
@jaxls.Cost.factory
def variance_cost(
vals: jaxls.VarValues, var: MVWeightsVar, cov_chol: jax.Array
) -> jax.Array:
"""Minimize portfolio variance: ||L.T @ w||^2 = w.T @ cov @ w."""
return cov_chol.T @ vals[var]
@jaxls.Cost.factory(kind="constraint_eq_zero")
def mv_budget_constraint(vals: jaxls.VarValues, var: MVWeightsVar) -> jax.Array:
"""Weights must sum to 1."""
return jnp.sum(vals[var]) - 1.0
@jaxls.Cost.factory(kind="constraint_geq_zero")
def mv_no_short_constraint(vals: jaxls.VarValues, var: MVWeightsVar) -> jax.Array:
"""No short-selling."""
return vals[var]
mv_weights_var = MVWeightsVar(id=0)
mv_costs = [
variance_cost(mv_weights_var, cov_chol),
mv_budget_constraint(mv_weights_var),
mv_no_short_constraint(mv_weights_var),
]
mv_problem = jaxls.LeastSquaresProblem(mv_costs, [mv_weights_var]).analyze()
mv_solution = mv_problem.solve(
verbose=False,
linear_solver="dense_cholesky",
termination=jaxls.TerminationConfig(cost_tolerance=1e-8),
)
mv_weights = mv_solution[mv_weights_var]
INFO | Building optimization problem with 3 terms and 1 variables: 1 costs, 1 eq_zero, 0 leq_zero, 1 geq_zero
INFO | Vectorizing constraint group with 1 constraints (constraint_eq_zero), 1 variables each: augmented_mv_budget_constraint
INFO | Vectorizing group with 1 costs, 1 variables each: variance_cost
INFO | Vectorizing constraint group with 1 constraints (constraint_geq_zero), 1 variables each: augmented_mv_no_short_constraint
==================================================
Metric CVaR-Opt Min-Var
==================================================
Expected Return (monthly) 2.98% 1.26%
Std Dev (monthly) 9.76% 7.71%
VaR 95% (monthly loss) 10.08% 12.33%
CVaR 95% (monthly loss) 10.08% 12.33%
==================================================
Key observations#
The CVaR-optimal portfolio differs from the minimum-variance portfolio:
Different risk focus: CVaR optimization targets tail risk, while minimum-variance treats all deviations equally.
Asset allocation: CVaR may allocate more to assets that have better worst-case behavior, even if they have higher overall variance.
Scenario-based: CVaR uses historical scenarios directly, making no normality assumptions about returns.
For risk-averse investors concerned about extreme losses (e.g., pension funds, insurance companies), CVaR optimization provides a more relevant risk measure than variance.
For more details on constrained optimization in jaxls, see jaxls.Cost
and jaxls.LeastSquaresProblem.