Quadrature scenarios for portfolio CVaR#
We compare deterministic quadrature with conditional Monte Carlo for one NIG portfolio. Both methods integrate the conditional normal exactly and differ only in their measure of the mixing variable \(Y\). The value is the Rockafellar–Uryasev objective; its derivation and quadrature error analysis are in Quadrature scenarios for mixture CVaR.
import jax
jax.config.update("jax_enable_x64", True)
import jax.numpy as jnp
import numpy as np
import pandas as pd
from scipy.integrate import quad
from scipy.stats import norminvgauss
from normix import NormalInverseGaussian
from normix.finance import CVaR, DiscreteMeasure, MeanRiskProblem
model = NormalInverseGaussian.from_classical(
mu=jnp.array([0.01, 0.03, -0.01]),
gamma=jnp.array([-0.2, 0.1, 0.05]),
sigma=jnp.array([[0.7, 0.1, 0.0], [0.1, 0.4, 0.05], [0.0, 0.05, 0.2]]),
mu_ig=1.0, lam=1.0,
)
w = jnp.array([0.4, 0.3, 0.3])
proj = model.project(w)
risk = CVaR(0.05)
Q = model.subordinator().quadrature()
print(f"nodes: {Q.Y.size}, probability sum: {float(Q.p.sum()):.15f}")
print(f"CVaR: {float(risk.value(proj)):.12f}")
nodes: 512, probability sum: 1.000000000000000
CVaR: 1.087113302837
An independent reference#
We use SciPy to integrate the NIG return density. For this subordinator, \(\mu_Y=\lambda=1\), so the NIG shape parameters are \(a=\sqrt{1+(\tilde\gamma/\tilde\sigma)^2}\) and \(b=\tilde\gamma/\tilde\sigma\), with scale \(\tilde\sigma\).
mu, gamma, sigma = map(float, (proj._mu_scalar, proj._gamma_scalar, proj._sigma_scalar))
b = gamma / sigma
reference_law = norminvgauss(np.hypot(1.0, b), b, loc=mu, scale=sigma)
x_alpha = reference_law.ppf(risk.alpha)
reference = -quad(lambda x: x * reference_law.pdf(x), -np.inf, x_alpha,
epsabs=1e-12, epsrel=1e-12)[0] / risk.alpha
rows = []
for n in (64, 128, 256, 512):
q = proj.subordinator().quadrature(n)
value = float(risk.value(proj, q))
rows.append({"method": "quadrature", "nodes": n, "CVaR": value,
"relative error": abs(value / reference - 1)})
Y = proj.subordinator().rvs(20_000, seed=0)
mc = float(risk.value(proj, Y))
rows.append({"method": "conditional MC", "nodes": Y.size, "CVaR": mc,
"relative error": abs(mc / reference - 1)})
print(f"independent reference: {reference:.12f}")
print(pd.DataFrame(rows).to_string(index=False))
np.testing.assert_allclose(risk.value(proj, Q), reference, rtol=1e-11)
independent reference: 1.087113302836
method nodes CVaR relative error
quadrature 64 1.087113 1.278610e-08
quadrature 128 1.087113 9.594547e-13
quadrature 256 1.087113 9.603429e-13
quadrature 512 1.087113 9.605650e-13
conditional MC 20000 1.091867 4.372920e-03
The MC error in this table belongs to one seed. The repository benchmark
reports RMSE over multiple seeds. An array still supplies equal-weight draws;
weighted observations use DiscreteMeasure.from_weights(Y, weights).
The same measure throughout optimization#
MeanRiskProblem retains one quadrature measure for the surface and frontier.
Expected returns use the original model’s subordinator mean. We compare the
optimized weights with the explicit MC path at the same target returns.
problem = MeanRiskProblem(model, risk)
targets = jnp.linspace(0.01, 0.05, 5)
frontier = problem.efficient_frontier(targets, gamma_bounds=(-0.5, 0.5))
mc_frontier = problem.efficient_frontier(targets, Y, gamma_bounds=(-0.5, 0.5))
comparison = pd.DataFrame({
"target": np.asarray(targets),
"quadrature CVaR": np.asarray(frontier.risk),
"MC CVaR": np.asarray(mc_frontier.risk),
"max weight difference": np.max(np.abs(frontier.weights - mc_frontier.weights), axis=1),
})
print(comparison.to_string(index=False))
np.testing.assert_allclose(frontier.weights.sum(axis=1), 1.0, atol=1e-12)
target quadrature CVaR MC CVaR max weight difference
0.01 0.824289 0.827389 0.000211
0.02 0.800278 0.803171 0.000205
0.03 0.782166 0.784879 0.000200
0.04 0.769902 0.772462 0.000197
0.05 0.763318 0.765752 0.000195
The objective and its derivatives share the same measure. First derivatives of the value agree with the analytic weight gradient:
gradient = jax.grad(lambda weights: risk.value_w(model, weights, Q))(w)
np.testing.assert_allclose(gradient, risk.gradient_w(model, w, Q), rtol=1e-11, atol=1e-12)
print("gradient:", np.asarray(gradient))
print("step resolution:", float(risk.step_resolution(proj, Q)))
gradient: [2.02911693 0.63809878 0.28012298]
step resolution: 51.94088925228036
Use risk.hessian_w for the Hessian. Applying autodiff twice to the value
would omit quantile motion because the root is frozen for the envelope derivative.
A narrow transition needs more nodes#
Reducing conditional variance makes the CDF transition narrower in \(\log Y\). The diagnostic compares its width with local node spacing; it is not an error bound. This example deliberately enters that regime.
sharp = proj.replace(gamma=jnp.array([-0.5]), L_Sigma=jnp.array([[0.003]]))
for n in (512, 2048, 8192):
q = sharp.subordinator().quadrature(n)
print(f"nodes={n:5d} R={float(risk.step_resolution(sharp, q)):.3f} "
f"CVaR={float(risk.value(sharp, q)):.10f} "
f"H_gamma_gamma={float(risk.hessian_scalar(sharp, q)[1, 1]):.8f}")
nodes= 512 R=0.180 CVaR=2.0983775622 H_gamma_gamma=0.00000001
nodes= 2048 R=0.738 CVaR=2.0984125499 H_gamma_gamma=0.00017842
nodes= 8192 R=2.931 CVaR=2.0984125500 H_gamma_gamma=0.00017859
The Hessian can change appreciably after the value appears stable. Inspect resolution and convergence before using such curvature in a transaction-cost QP or generalized ENB. Moment existence is a separate condition: an inverse-gamma law can have finite CVaR and infinite skew curvature at zero skew. See Probability measures for portfolio scenarios for the supported defaults and limits.