← quantChapter 11 · Counterparty credit risk, deep dive6 min read

CVA from first principles

Learn how Monte Carlo exposure, probability of default, and loss given default combine to calculate unilateral credit valuation adjustment.

10.3 handed you a CVA sensitivity vector and built a capital charge out of it. Where does the CVA number underneath that vector actually come from? An integral over a counterparty's expected exposure and default probability, simulated on a time grid and recorded once, the same way every other page in this Learn section has recorded a valuation.

The whole story

CVA is a sum over a time grid of expected positive exposure times a discount factor times the marginal default probability, loss-given-default applied throughout. A Hull-White short rate and a lognormal FX spot drive the mark-to-market at every date; a hazard curve, bootstrapped from real CDS quotes by bisection, supplies survival. One .greeks() sweep on the recorded tape returns the CVA number and its whole risk vector — IR delta and vega, CS01 by tenor bucket, FX delta, a recovery gradient — with no re-simulation. The expected-exposure profile is a real humped shape, peaking early and running off to zero as the netting set's trades mature. The central finding: a repo-wide search for where the bootstrapped hazard curve is actually used turns up exactly one call site, a printout — the Monte-Carlo integral's own hazard inputs come from a separate, hardcoded flat curve that never reacts to the counterparty's real CDS quotes at all.

Did you know?

Every quantity here — mark-to-market, exposure, the CVA integrand — is divided by MONEY_UNIT = 1,000,000 before it ever touches the tape. ExposureSimulation's own doc comment explains why: unscaled sums run past 1e10 in reporting-currency terms, "the difference between a stable and an unusable single-precision replay." Divide down to O(1) quantities, replay, then scale the price and gradient back up on the way out — the same trick that keeps this exact tape usable on cpu-jit's fp64 and a GPU backend's fp32 without two different implementations.

The CVA integral, in one line

CVA = LGD * sum_k  max(V(t_k) - C(t_k), 0) * D(t_k) * ( S(t_{k-1}) - S(t_k) )

Three ingredients, each already familiar from earlier chapters: V is a mark-to-market recorded the same way every option in this Learn section has been (here, two interest-rate swaps and an FX forward, summed); D is a discount factor off the same Hull-White path driving the trade values; S is the counterparty's survival probability, and its drop between two dates is the chance of default arriving exactly then.

Build a real hazard curve from CDS quotes

HazardCurve.bootstrap strips a piecewise-flat forward hazard from ascending CDS quotes, one segment at a time, by bisection:

double target = quote.parSpread();
for (int iteration = 0; iteration < 200; iteration++) {
  double mid = 0.5 * (lo + hi);
  hazards[segment] = mid;
  double modelSpread = parSpread(knots, hazards, segment + 1, lgd, discount);
  if (modelSpread > target) { hi = mid; } else { lo = mid; }
}

Fed counterparty A's real quotes — 90bp at 1y, 130bp at 3y, 150bp at 5y, 170bp at 10y — this gives real survival numbers: survival(1y) = 0.9851, survival(5y) = 0.8807, survival(10y) = 0.7473. The marginal default probability each step needs is built as an incremental hazard, S(t_{k-1}) · (1 - e^{-Δλ}), using a six-term Maclaurin series for 1 - e^{-x} instead of direct subtraction — Δλ is tiny at every step, and 1.0 - Math.exp(-x) for small x throws away almost every significant digit to cancellation before the series ever gets a chance to.

Simulate exposure, one sweep for the whole risk vector

A recorded Hull-White short rate and a lognormal FX spot drive every trade's mark-to-market at every grid date; one .greeks() run returns the CVA number and its complete gradient:

Nabla.TypedPricer<CvaMarket> pricer =
    (useFp64() ? model.fp64() : model.fp32()).greeks().on(engine).build();

Real numbers, 200,000 paths, 24 steps, cpu-jit, netting set NS-CPTY-A (a 7-year payer swap, a 5-year receiver, an FX forward): the expected positive exposure profile peaks early, $8.385m at t=0.29y, and runs off toward zero as trades mature, $0.167m at t=6.71y — a real, humped shape, not asserted. CVA(A) = $0.410m, from one sweep in 0.849 s. The same sweep also gives IR delta ($2,185) and vega ($41,565), CS01 by tenor bucket ($1,338 / $1,133 / $119 short/mid/long), FX delta ($9,917), and a recovery gradient (-$683,652) — no second run for any of them.

Unlike 10.3's netting set, the long CS01 bucket here is genuinely nonzero. 10.3's book matured at 5 years, so its hazard curve's 7.5-year vertex never touched any real exposure. This book's 7-year payer swap keeps exposure alive past that point, and the long bucket, $119, proves it.

Did you know?

A repo-wide search for .curve() — the accessor onto the bootstrapped HazardCurve above — turns up exactly one call site in the entire nablatensor-cva module and its examples: the survival() printout in CvaShowcase.main()'s [1/8] section. The Monte-Carlo integral's own hazard inputs (CvaMarket::hazardShort/hazardMid/hazardLong) come from a completely separate object, CvaMarket.demo() — a hardcoded flat 150bp curve, unrelated to whichever counterparty's HazardCurve gets bootstrapped and printed alongside it. The bootstrap is real; it just never reaches the kernel that prices the trade.

Try it yourself

If the counterparty's own CDS quotes actually drove the CVA number, a credit deterioration — every quote widened by 100bp — should make CVA worse. Bootstrapped a second curve from 190/230/250/270bp quotes, attached it to the same counterparty, and reran ExposureSimulation against it. Real numbers: survival(5y) genuinely moves, 0.8807 to 0.8097 — the bootstrap reacted exactly as it should. CVA(A) does not move at all: $0.410m, unchanged to the last printed digit. Predicting otherwise, then checking, is what surfaces the gap the sidenote above names — no source edit needed, both HazardCurve.bootstrap and ExposureSimulation.run are already public.

▶️ Run it

The same netting set, live: build the Hull-White-plus-FX tape, read the whole CVA risk vector off one adjoint sweep, then bootstrap a real hazard curve and watch survival move while CVA doesn't — right here, in your browser through TeaVM, on a stand-in for the real ExposureSimulation rewritten around chapter 1.3's cell's own limitation, rec.input() directly instead of Nabla.Inputs <CvaMarket>'s reflection layer. HazardCurve.bootstrap runs completely unmodified. The browser cell uses 2,000 paths × 8 steps so the full showcase finishes in a browser; the Maven command below retains the 200,000 × 24 defaults:

Java · compile and run in this browser

Or run the real thing:

mvn -o -q install
mvn -o -q -pl nablatensor-examples exec:java \
  -Dexec.mainClass=com.nablatensor.examples.CvaShowcase

Defaults to cpu-jit, 200,000 paths, 24 steps, no GPU. This runs the full 8-stage showcase — netting sets through the three PRA methods — this page only walked stages [1/8]–[3/8]; 10.3 already covered the SA-CVA aggregation stages, and BA-CVA/the PRA methods are Chapter 11.3's.

⚠️ What this doesn't do

The hazard-curve gap above is worth stating plainly rather than papering over: CvaMarket.demo()'s flat 150bp curve means every counterparty in every showcase this Learn section has used — A's real term structure, B's flat 110bp curve alike — gets priced against the identical hazard level, regardless of which HazardCurve object sits on its CreditName. The printed [1/8] survival numbers are real and correctly bootstrapped; they just describe a curve the pricing engine never sees. Everything else stays scoped as before: one netting set, no wrong-way risk beyond the fixed recovery assumption, and SaCvaParameters/CvaMarket's own values are explicitly indicative, not calibrated to a real desk.

What's next

→ Deeper: CvaShowcase.java runs the full portfolio (two netting sets, one under a CSA) through SA-CVA, BA-CVA and the three PRA methods in one pass. → Next: Hedging CVA — the same CvaShowcase book, now with a CDS hedge on top, and a real case where the hedge makes capital worse, not better.


Questions or corrections? open an issue