Risk: Scenarios, Shocks, and VaR¶
VALAX provides scenario generation, multi-curve shocks, and Value-at-Risk computation — all built on jax.vmap for massively parallel portfolio repricing.
Why Multi-Curve?¶
The pre-2008 world: one curve does everything¶
Before the financial crisis, the industry used a single yield curve for both discounting (computing present values) and forecasting (projecting future floating rates). This worked because the spread between LIBOR and OIS (the overnight indexed swap rate, a proxy for the risk-free rate) was negligible — typically 1-2 basis points.
Under this single-curve framework, the discount factor to time \(t\) and the forward rate between \(t_1\) and \(t_2\) are derived from the same set of zero rates:
One curve, built from deposits, FRAs, and swaps against the same index. Simple.
The crisis: LIBOR-OIS blew out¶
In 2007-2008, the LIBOR-OIS spread widened from 2bp to over 350bp. This was the market pricing in bank credit risk — LIBOR is an unsecured interbank lending rate, and banks were suddenly risky counterparties. The spread revealed that LIBOR is not risk-free; it embeds a credit and liquidity premium that varies by tenor.
This meant:
- 3-month LIBOR and 6-month LIBOR are different risk factors. A bank borrowing for 6 months faces more credit risk than one borrowing for 3 months. The two rates don't collapse to a single curve.
- Discounting should use a risk-free (or near-risk-free) rate. Under CSA (Credit Support Annex) agreements, collateralized derivatives are funded at the overnight rate. Discounting at LIBOR overstates the present value.
- Forward projection should use the index-specific curve. A swap paying 3M LIBOR should project forwards from a 3M LIBOR curve, not from the OIS curve.
The post-crisis standard: multi-curve¶
The modern framework uses separate curves for separate purposes:
| Curve | Built from | Used for |
|---|---|---|
| OIS discount curve | Overnight index swaps (SOFR, ESTR, SONIA) | Discounting all collateralized cashflows |
| SOFR forward curve | SOFR swaps, futures | Projecting SOFR floating rates |
| Term rate curves (if still used) | Term SOFR, Euribor swaps | Projecting term fixings |
| Basis curves | Basis swaps (1M vs 3M, SOFR vs FF) | Expressing the spread between indices |
| Cross-currency basis | XCCY basis swaps | FX-adjusted discounting for foreign-currency legs |
A single interest rate swap now requires at least two curves: one to project the floating leg's cashflows (the forward curve for the relevant index) and one to discount all cashflows to present value (the OIS curve tied to the collateral agreement).
Why this matters for risk¶
Each curve responds differently to market shocks. A parallel shift in OIS rates does not equally affect SOFR forwards — the basis can widen or narrow independently. Proper risk measurement requires:
- Independent shocks per curve. A steepener on the OIS curve may coincide with a flattener on the SOFR curve. Single-curve VaR misses this.
- Basis risk. The spread between curves is itself a risk factor. Hedging a SOFR swap with OIS instruments leaves residual basis risk that single-curve models cannot capture.
- Correct hedge ratios. Differentiating price with respect to OIS pillar rates gives the OIS DV01; differentiating with respect to SOFR pillar rates gives the SOFR DV01. These are different numbers, and you need both to construct the right hedge.
Where VALAX stands today¶
VALAX currently provides a single DiscountCurve with full pillar-level shocks — you can bump each zero rate independently (parallel shift, steepener, butterfly, key-rate bumps). This is sufficient for:
- Single-index portfolios (e.g., pure equity options using one risk-free rate)
- Learning and prototyping rate risk workflows
- Stress testing a single yield curve
The architecture is designed so that adding multi-curve support is a natural extension: MarketData can hold multiple DiscountCurve instances (one for discounting, one per forward index), and MarketScenario can carry separate rate_shocks vectors for each curve. The bump_curve_zero_rates function already works on any DiscountCurve — it just needs to be applied to each curve independently.
Roadmap
Full multi-curve bootstrapping (simultaneous OIS + SOFR from market instruments) is a Tier 1 priority. See the Roadmap for details.
Market Data Container¶
MarketData bundles all market state into a single pytree:
from valax.market import MarketData
from valax.curves import DiscountCurve
from valax.dates import ymd_to_ordinal
import jax.numpy as jnp
ref = ymd_to_ordinal(2026, 1, 1)
pillars = jnp.array([
ymd_to_ordinal(2026, 1, 1),
ymd_to_ordinal(2027, 1, 1),
ymd_to_ordinal(2028, 1, 1),
ymd_to_ordinal(2030, 1, 1),
])
dfs = jnp.exp(-0.05 * (pillars - ref).astype(jnp.float64) / 365.0)
base = MarketData(
spots=jnp.array([100.0, 50.0]),
vols=jnp.array([0.20, 0.35]),
dividends=jnp.array([0.02, 0.0]),
discount_curve=DiscountCurve(
pillar_dates=pillars,
discount_factors=dfs,
reference_date=ref,
),
)
Because MarketData is a pytree with differentiable leaves (spots, vols, dividends, and the curve's discount factors), jax.grad through any pricing function that consumes it gives sensitivities to every market input in one backward pass.
Scenarios¶
A MarketScenario represents additive changes to risk factors — not absolute levels:
from valax.market import MarketScenario
scenario = MarketScenario(
spot_shocks=jnp.array([5.0, -2.0]), # spots move +5, -2
vol_shocks=jnp.array([0.02, -0.01]), # vols bump +2%, -1%
rate_shocks=jnp.array([0.01, 0.01, 0.01, 0.01]), # +100bp parallel
dividend_shocks=jnp.array([0.0, 0.0]),
)
For spot shocks, set multiplicative=True to interpret them as returns: new_spot = old_spot * (1 + shock).
Scenario Generation¶
Three methods for producing scenario sets:
Parametric (Monte Carlo) — correlated samples from a multivariate distribution:
from valax.risk import parametric_scenarios
key = jax.random.PRNGKey(0)
cov = ... # (n_factors x n_factors) covariance matrix
scenarios = parametric_scenarios(
key, cov, n_scenarios=10_000,
n_assets=2, n_pillars=4,
distribution="normal", # or "t" for fat tails
)
Column ordering in the covariance matrix: [spots, vols, rates, dividends].
Historical simulation — replay observed risk factor changes:
from valax.risk import historical_scenarios
# returns: (n_days, n_factors) of daily changes
scenarios = historical_scenarios(returns, n_assets=2, n_pillars=4)
Stress scenarios — deterministic named shocks:
from valax.risk import stress_scenario, steepener, butterfly, flattener
from valax.market import stack_scenarios
parallel_up = stress_scenario(2, 4, parallel_rate_shift=0.01)
steep = steepener(2, 4, short_bump=-0.005, long_bump=0.015)
fly = butterfly(2, 4, wing_bump=0.01, belly_bump=-0.005)
crash = stress_scenario(2, 4, spot_shock=-20.0, vol_shock=0.15)
stress_set = stack_scenarios([parallel_up, steep, fly, crash])
Curve Shocks¶
The core operation converts zero-rate bumps into discount factor adjustments:
This is exact: bumping the continuously-compounded zero rate at pillar \(i\) by \(\Delta r_i\) multiplies the discount factor by \(e^{-\Delta r_i \cdot t_i}\).
from valax.risk import bump_curve_zero_rates, parallel_shift, key_rate_bump
# Arbitrary per-pillar bumps
bumped = bump_curve_zero_rates(curve, jnp.array([0.0, 0.005, 0.01, 0.015]))
# Parallel shift: all pillars by +50bp
bumped = parallel_shift(curve, jnp.array(0.005))
# Key-rate bump: only pillar 2 by +25bp
bumped = key_rate_bump(curve, pillar_index=2, bump=jnp.array(0.0025))
The bumped curve is a new DiscountCurve with the same pillar dates — a drop-in replacement. Its log-linear interpolation produces smooth shocked rates between pillars.
Structured shocks are just specific patterns of per-pillar bumps:
| Shock | Rate profile across pillars |
|---|---|
| Parallel shift | Uniform: [dr, dr, ..., dr] |
| Steepener | Linear: short end down, long end up |
| Flattener | Linear: short end up, long end down |
| Butterfly | Quadratic: wings up, belly down (or vice versa) |
| Key-rate | Zero everywhere except one pillar |
Differentiability
bump_curve_zero_rates is fully differentiable. jax.grad through a pricing function that uses a bumped curve gives the sensitivity of the price to each bump magnitude — this is key-rate DV01 by construction.
Applying Scenarios¶
apply_scenario applies all shocks at once and returns a new MarketData:
from valax.risk import apply_scenario
shocked_market = apply_scenario(base, scenario)
# shocked_market.spots, .vols, .dividends are bumped
# shocked_market.discount_curve has bumped zero rates
Pricing Functions for Risk¶
The risk engine accepts any pricing function with the signature:
Each instrument receives a per-instrument MarketData with scalar spots, vols, dividends and the full shared discount curve. This means both equity and rates products work through the same risk framework:
# Equity: extract spot/vol/rate from market
def bs_pricer(option, market):
from valax.risk.var import _extract_short_rate
rate = _extract_short_rate(market.discount_curve)
return black_scholes_price(option, market.spots, market.vols, rate, market.dividends)
# Rates: use the full curve directly
def swap_pricer(swap, market):
return swap_price(swap, market.discount_curve)
For existing equity-style functions with signature (instrument, spot, vol, rate, dividend) -> price, use the adapter:
from valax.risk import wrap_equity_pricing_fn
market_fn = wrap_equity_pricing_fn(black_scholes_price)
Full-Revaluation VaR¶
The end-to-end VaR workflow: generate scenarios, reprice the portfolio under each one via jax.vmap, compute risk measures on the P&L vector.
from valax.risk import portfolio_pnl, value_at_risk, expected_shortfall
# Reprice portfolio under all scenarios (vmapped)
pnl = portfolio_pnl(my_pricing_fn, instruments, base, scenarios)
# Risk measures
var_99 = value_at_risk(pnl, confidence=0.99)
es_99 = expected_shortfall(pnl, confidence=0.99)
portfolio_pnl uses jax.vmap over the scenario axis — each iteration applies one scenario, reprices via jax.vmap over instruments, and returns a scalar P&L. The base MarketData is closed over (constant); only the scenario varies.
This means 10,000 scenarios x 100 instruments = 1,000,000 repricings compile down to a single JIT-compiled, vectorized computation — no Python loops.
Parametric VaR (Delta-Normal)¶
For fast VaR without repricing, parametric_var uses autodiff to compute portfolio sensitivities (delta vector) and the covariance matrix to estimate portfolio variance:
where \(\boldsymbol{\delta}\) is the gradient of portfolio value w.r.t. all risk factors and \(\Sigma\) is the covariance matrix.
from valax.risk import parametric_var
pvar_99 = parametric_var(my_pricing_fn, instruments, base, cov, confidence=0.99)
This is a first-order (linear) approximation — fast and accurate for well-hedged portfolios. For highly convex positions (e.g., short gamma), use full-revaluation VaR instead.
Internally, the sensitivity to curve pillars is converted from DF-space to zero-rate-space: \(\frac{\partial P}{\partial r_i} = \frac{\partial P}{\partial DF_i} \cdot (-t_i \cdot DF_i)\), matching the covariance matrix's column ordering.
P&L Attribution¶
pnl_attribution decomposes a scenario's P&L into risk factor contributions using a second-order Taylor expansion with autodiff-computed sensitivities:
from valax.risk import pnl_attribution
attr = pnl_attribution(my_pricing_fn, instruments, base, scenario)
print(f"Delta (spot): {attr['delta_spot']:.2f}")
print(f"Vega: {attr['delta_vol']:.2f}")
print(f"Rho (rates): {attr['delta_rate']:.2f}")
print(f"Gamma (spot): {attr['gamma_spot']:.2f}")
print(f"Total approx: {attr['total_second_order']:.2f}")
print(f"Actual P&L: {attr['actual']:.2f}")
print(f"Unexplained: {attr['unexplained']:.2f}")
The returned dict contains:
| Key | Description |
|---|---|
delta_spot |
P&L from spot moves (first order) |
delta_vol |
P&L from vol moves (vega) |
delta_rate |
P&L from rate moves (rho / DV01) |
delta_div |
P&L from dividend moves |
gamma_spot |
P&L from spot convexity (second order) |
total_first_order |
Sum of all delta terms |
total_second_order |
First order + gamma |
actual |
True P&L from full repricing |
unexplained |
actual - total_second_order |
All sensitivities are computed via jax.grad and jax.hessian — no finite differences.
Risk Factors: What Gets Shocked¶
What is a risk factor?¶
A risk factor is any market observable that, when it moves, changes the value of a position. VaR answers the question "how much could we lose?" by shocking these factors according to their historical or modelled joint distribution.
The choice of risk factors determines the fidelity of the VaR number. Too few factors and you miss real risks (basis risk, smile risk, cross-gamma). Too many and the covariance matrix becomes singular or unstable.
Industry-standard factor counts¶
A real bank VaR system tracks thousands to tens of thousands of risk factors. The exact number depends on the desk and portfolio:
| Factor type | Typical count | Details |
|---|---|---|
| IR curve pillars (per curve) | 15-20 | Standard tenors: ON, 1W, 1M, 2M, 3M, 6M, 9M, 1Y, 2Y, 3Y, 5Y, 7Y, 10Y, 15Y, 20Y, 30Y |
| IR curves (per currency) | 2-5 | OIS discount, SOFR/ESTR forward, basis, cross-currency basis |
| Currencies covered | 10-30 | G10 + major EM |
| IR subtotal | ~1,000-2,000 | 20 pillars x 3 curves x 20 currencies |
| Equity spots | 100-5,000+ | Individual names or sector indices |
| Equity vol surfaces | 500-10,000+ | 5-10 strikes x 5-8 expiries per underlier |
| FX spots | 20-30 | Against USD or EUR |
| FX vol surfaces | ~500 | 5 deltas x 8 expiries x 12 major pairs |
| Credit spreads | 500-5,000 | By issuer x tenor bucket |
| Commodities | 100-500 | Forward curves per commodity |
| Inflation | 50-100 | Breakeven curves per currency |
Rates-only desk: ~1,000-3,000 factors. Multi-asset bank-wide VaR: 5,000-50,000 factors. JP Morgan's firm-wide RiskMetrics system famously tracked 10,000+ factors.
Factor reduction: why you don't use raw factors¶
Nobody runs VaR on 10,000 raw factors with a full covariance matrix — it would be rank-deficient (you rarely have 10,000 independent daily observations). Industry uses dimensionality reduction:
- PCA on yield curves. The first 3 principal components (level, slope, curvature) explain ~95-99% of yield curve variance. Instead of shocking 20 pillar rates independently, shock 3-5 PC scores and reconstruct pillar moves. This is both more stable and more interpretable.
- Factor models for equities. Decompose returns into systematic factors (market, sector, style) plus idiosyncratic. Shock the systematic factors jointly, add independent idiosyncratic noise. This is the Barra/Axioma approach.
- Filtered historical simulation. Weight recent observations more heavily (EWMA), or fit a GARCH model to each factor and standardize returns before drawing scenarios. This captures volatility clustering without inflating the factor count.
Current VALAX factor structure¶
VALAX currently uses a flat factor vector with fixed ordering:
Total factors = 3 * n_assets + n_pillars. The covariance matrix columns follow this ordering. This is sufficient for prototyping and single-desk risk, but has known limitations:
| Limitation | Impact | Future direction |
|---|---|---|
| No factor metadata | Covariance matrix columns are unlabelled — error-prone for large factor sets | Named factor registry |
| Scalar vol per asset | Can't shock the vol surface (ATM vs wings, short vs long expiry) | Vol surface risk factors |
| Single rate curve | Can't capture basis risk between OIS and SOFR | Multi-curve MarketData with per-curve shocks |
| No PCA / factor reduction | Full covariance matrix is impractical beyond ~500 factors | PCA yield curve factors, equity factor models |
Practical guidance
For a small equity portfolio (10-50 names, one rate curve with 5-10 pillars), the current framework gives reasonable VaR numbers with ~100-200 factors. For anything larger, factor reduction (PCA on curves, systematic equity factors) is essential — and is a natural fit for JAX, since PCA is just jnp.linalg.svd.
Sensitivity Ladders and Waterfall P&L¶
The basic pnl_attribution function (shown above) gives a scalar decomposition: one number for delta-spot, one for delta-vol, one for gamma. Sensitivity ladders extend this to a fully bucketed, multi-rung P&L decomposition with all second-order cross terms — vanna, volga, rate convexity, and cross spot×rate / vol×rate effects.
For the mathematical foundations, see Models & Theory § 7.4.
Computing a Ladder¶
A SensitivityLadder holds bucketed first- and second-order sensitivities for every risk factor in the portfolio:
from valax.risk import compute_ladder
ladder = compute_ladder(pricing_fn, instruments, base_market)
# First-order (delta) ladders — one value per bucket
ladder.delta_spot # shape (n_assets,) — equity deltas
ladder.delta_vol # shape (n_assets,) — vega ladder
ladder.delta_rate # shape (n_pillars,) — DV01 / rho ladder
ladder.delta_div # shape (n_assets,) — dividend sensitivity
# Second-order (gamma) ladders — diagonals
ladder.gamma_spot # shape (n_assets,) — spot gammas
ladder.gamma_rate # shape (n_pillars,) — rate convexity
ladder.vanna # shape (n_assets,) — ∂²V/∂S∂σ
ladder.volga # shape (n_assets,) — ∂²V/∂σ²
# Cross blocks
ladder.cross_spot_rate # shape (n_assets, n_pillars) — ∂²V/∂S∂r
ladder.cross_vol_rate # shape (n_assets, n_pillars) — ∂²V/∂σ∂r
All sensitivities are computed via jax.grad (first order) and jax.hessian (second order) — no bump-and-reprice. Rate sensitivities are in zero-rate space (not DF space), matching the convention used by pnl_attribution and parametric_var.
Waterfall P&L Decomposition¶
Given a precomputed ladder and a scenario, the waterfall breaks down the P&L into 10 rungs:
from valax.risk import waterfall_pnl, waterfall_pnl_report
# Fast: arithmetic only, no repricing
wf = waterfall_pnl(ladder, scenario, base_market)
# Full: includes actual repricing and unexplained
wf = waterfall_pnl_report(pricing_fn, instruments, base_market, scenario, ladder=ladder)
The WaterfallPnL pytree contains every rung:
# First-order rungs
wf.delta_spot # Rung 1: Σ δ_S · ΔS
wf.delta_vol # Rung 2: Σ ν · Δσ (vega P&L)
wf.delta_rate # Rung 3: Σ ρ · Δr (DV01 P&L)
wf.delta_div # Rung 4: Σ δ_q · Δq
# Second-order rungs
wf.gamma_spot # Rung 5: ½Σ γ · ΔS²
wf.gamma_rate # Rung 6: ½Σ γ_r · Δr² (rate convexity)
wf.vanna_pnl # Rung 7: Σ vanna · ΔS·Δσ
wf.volga_pnl # Rung 8: ½Σ volga · Δσ²
# Cross rungs
wf.cross_spot_rate_pnl # Rung 9: Σ ∂²V/∂S∂r · ΔS·Δr
wf.cross_vol_rate_pnl # Rung 10: Σ ∂²V/∂σ∂r · Δσ·Δr
# Aggregates
wf.total_first_order # Rungs 1-4 summed
wf.total_second_order # Rungs 5-10 summed
wf.predicted # All rungs summed
wf.actual # Full-repricing P&L (from waterfall_pnl_report)
wf.unexplained # actual - predicted
Ladder vs. pnl_attribution¶
The existing pnl_attribution computes a scalar decomposition with spot gamma only. The ladder adds:
Existing (pnl_attribution) |
New (waterfall_pnl_report) |
|---|---|
| Scalar delta_spot | Bucketed delta per asset |
| Scalar delta_vol | Bucketed vega per asset |
| Scalar delta_rate | Bucketed DV01 per pillar |
| Spot gamma only | Spot gamma + rate gamma + vanna + volga |
| No cross terms | Full cross-gamma (spot×rate, vol×rate) |
| 5 output fields | 15 output fields with full waterfall |
For small moves, both give similar results. For large moves (crash scenarios, rate shocks ≥ 100bp, vol moves ≥ 5pts), the ladder's second-order terms significantly reduce the unexplained residual.
Reusing Ladders Across Scenarios¶
Computing the ladder is the expensive step (requires Hessian evaluation). Once computed, the waterfall for each scenario is just cheap array arithmetic:
# Compute once
ladder = compute_ladder(pricing_fn, instruments, base_market)
# Decompose many scenarios (near-instant per scenario)
for scenario in scenarios:
wf = waterfall_pnl(ladder, scenario, base_market)
print(f"Predicted: {wf.predicted:.2f}, Gamma: {wf.gamma_spot:.2f}")
This pattern is natural for end-of-day P&L explain: compute the ladder at the close, then attribute P&L to each risk factor's daily move.