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:

  • Var with vector-valued and scalar defaults

  • Inequality constraints (constraint_geq_zero): CVaR auxiliary constraints, budget, no short-selling

  • Equality constraints (constraint_eq_zero): budget constraint

  • Augmented Lagrangian solver for constrained optimization

Hide code cell source

import sys
from loguru import logger

logger.remove()
logger.add(sys.stdout, format="<level>{level: <8}</level> | {message}");
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:

\[\text{CVaR}_\alpha = \min_{\zeta} \left[ \zeta + \frac{1}{\alpha T} \sum_{t=1}^T \max(-w^\top r_t - \zeta, 0) \right]\]

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\):

\[\min_{w, \zeta, u} \quad \zeta + \frac{1}{\alpha T} \sum_{t=1}^T u_t\]

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

Hide code cell source

import plotly.graph_objects as go
from plotly.subplots import make_subplots
from IPython.display import HTML


def compute_metrics(weights: jax.Array) -> dict:
    """Compute risk metrics for a portfolio."""
    port_returns = returns @ weights
    losses = -port_returns

    # Variance and std dev.
    variance = float(weights @ covariance @ weights)
    std_dev = float(jnp.sqrt(variance))

    # VaR (95%) - the 95th percentile of losses.
    sorted_losses = jnp.sort(losses)
    var_95 = float(sorted_losses[int(num_scenarios * (1 - alpha))])

    # CVaR (95%) - mean of losses exceeding VaR.
    cvar_95 = float(
        var_95 + jnp.sum(jnp.maximum(losses - var_95, 0)) / (alpha * num_scenarios)
    )

    # Expected return.
    exp_return = float(jnp.dot(weights, expected_returns))

    return {
        "std_dev": std_dev,
        "var_95": var_95,
        "cvar_95": cvar_95,
        "exp_return": exp_return,
    }


cvar_metrics = compute_metrics(optimal_weights)
mv_metrics = compute_metrics(mv_weights)

print("=" * 50)
print(f"{'Metric':<25} {'CVaR-Opt':>12} {'Min-Var':>12}")
print("=" * 50)
print(
    f"{'Expected Return (monthly)':<25} {cvar_metrics['exp_return'] * 100:>11.2f}% {mv_metrics['exp_return'] * 100:>11.2f}%"
)
print(
    f"{'Std Dev (monthly)':<25} {cvar_metrics['std_dev'] * 100:>11.2f}% {mv_metrics['std_dev'] * 100:>11.2f}%"
)
print(
    f"{'VaR 95% (monthly loss)':<25} {cvar_metrics['var_95'] * 100:>11.2f}% {mv_metrics['var_95'] * 100:>11.2f}%"
)
print(
    f"{'CVaR 95% (monthly loss)':<25} {cvar_metrics['cvar_95'] * 100:>11.2f}% {mv_metrics['cvar_95'] * 100:>11.2f}%"
)
print("=" * 50)
==================================================
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%
==================================================

Hide code cell source

colors = ["#2196F3", "#4CAF50", "#FF9800"]

fig = make_subplots(
    rows=1,
    cols=2,
    subplot_titles=("Portfolio Weights", "Return Distribution"),
    column_widths=[0.4, 0.6],
)

# Left plot: Weight comparison.
x_labels = stock_names
fig.add_trace(
    go.Bar(
        x=x_labels,
        y=optimal_weights * 100,
        name="CVaR-Optimal",
        marker_color="#E91E63",
    ),
    row=1,
    col=1,
)
fig.add_trace(
    go.Bar(
        x=x_labels,
        y=mv_weights * 100,
        name="Min-Variance",
        marker_color="#3F51B5",
    ),
    row=1,
    col=1,
)

# Right plot: Return distributions.
cvar_returns = returns @ optimal_weights
mv_returns = returns @ mv_weights

# Sort for visualization.
sorted_idx = jnp.argsort(cvar_returns)
scenario_labels = [f"Scenario {i + 1}" for i in range(num_scenarios)]

fig.add_trace(
    go.Bar(
        x=list(range(num_scenarios)),
        y=cvar_returns[sorted_idx] * 100,
        name="CVaR-Optimal Returns",
        marker_color="#E91E63",
        opacity=0.7,
    ),
    row=1,
    col=2,
)
fig.add_trace(
    go.Bar(
        x=list(range(num_scenarios)),
        y=mv_returns[sorted_idx] * 100,
        name="Min-Variance Returns",
        marker_color="#3F51B5",
        opacity=0.7,
    ),
    row=1,
    col=2,
)

# Add VaR threshold line.
fig.add_hline(
    y=-cvar_metrics["var_95"] * 100,
    line_dash="dash",
    line_color="#E91E63",
    annotation_text="CVaR VaR threshold",
    row=1,
    col=2,
)

fig.update_xaxes(title_text="Asset", row=1, col=1)
fig.update_yaxes(title_text="Weight (%)", row=1, col=1)
fig.update_xaxes(title_text="Scenario (sorted by return)", row=1, col=2)
fig.update_yaxes(title_text="Monthly Return (%)", row=1, col=2)

fig.update_layout(
    barmode="group",
    height=400,
    margin=dict(t=40, b=40, l=60, r=40),
    legend=dict(orientation="h", yanchor="bottom", y=-0.25, xanchor="center", x=0.5),
)
HTML(fig.to_html(full_html=False, include_plotlyjs="cdn"))

Key observations#

The CVaR-optimal portfolio differs from the minimum-variance portfolio:

  1. Different risk focus: CVaR optimization targets tail risk, while minimum-variance treats all deviations equally.

  2. Asset allocation: CVaR may allocate more to assets that have better worst-case behavior, even if they have higher overall variance.

  3. 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.