← learnModule 8 · Credit5 min read

CDO tranches: two ways to price the same loss

A synthetic CDO tranche gets priced by two different models of the same one-factor Gaussian copula — a fast recursion with no gradient, and a recorded Monte-Carlo whose one adjoint sweep is the only way to get correlation delta.

A synthetic CDO slices a pool of credit names into tranches — equity absorbs the first losses, senior absorbs the last — and every tranche's value depends on the same thing: how correlated those names' defaults are with each other. Pricing a tranche's expected loss has a fast, exact recipe. But a trading desk doesn't just want the price — it wants to know how much that price moves if the market's view of correlation shifts, and the fast recipe has no gradient to give. So the same portfolio gets modeled twice.

The whole story

The Andersen-Sidenius-Basu recursion convolves conditionally-independent per-name defaults into a portfolio loss distribution via 96-node Gauss-Hermite integration over the systemic factor — fast, deterministic, no tape. The same one-factor copula recorded as a Monte-Carlo, using a smooth logistic approximation of the normal CDF built from tape-safe ops, gives one adjoint sweep both a price and a correlation delta together. The real run: on a 100-name pool, equity tranche expected loss falls from 64% to 42% as correlation rises from 15% to 45%, while the senior tranche's rises from 0.02% to 0.44% — opposite directions from the same recursion — and the recorded Monte-Carlo agrees with it to 0.57%, its adjoint correlation delta matching an independent central bump to four printed decimals.

Did you know?

7.5 found that the reference-grade normal CDF genuinely can't be recorded — HullWhiteCalibration's own doc comment named a root-find and N(x) together as the two reasons that model stays off the tape. This module resolves the second half of that: SpecialFn.normCdf's own doc comment gives a smooth logistic approximation, 1/(1+e^{-(1.5976x+0.070566x^3)}), accurate to about 1.4×10⁻⁴ — built entirely from exp/mul/add, so it is tape-safe, and it's exactly what CopulaMonteCarlo uses to turn a latent normal into a default probability inside a recorded tape. The same doc comment is explicit about the trade: "for a reference-grade value use com.nablatensor.quant.BlackScholes.N — that one is not tape-safe." Two different normal CDFs, for two different jobs.

The recursion: fast, exact, no tape

OneFactorGaussianCopula starts from one identity: conditional on the systemic factor M, every name's default becomes independent, with probability Φ((Φ⁻¹(PD) − √ρ·M) / √(1−ρ)). PortfolioLossDistribution convolves that conditional probability into a loss distribution one name at a time — the Andersen-Sidenius-Basu recursion — then integrates over M with Gauss-Hermite quadrature:

for (int g = 0; g < nodes; g++) {
  double m = Math.sqrt(2.0) * x[g];
  double p = OneFactorGaussianCopula.conditionalDefaultProbability(pd, rho, m);
  double[] cond = binomialPmf(names, p);
  // accumulate cond into the unconditional pmf, weighted by w[g]
}

CdoTranche.expectedLossFraction then reads a tranche's expected loss straight off that distribution — one weighted sum over an integer default grid, deterministic, no scenario count, no seed anywhere.

Did you know?

Those Gauss-Hermite nodes aren't a lookup table. gaussHermite builds the three-term recurrence for Hermite polynomials into a symmetric tridiagonal matrix and diagonalizes it with tqli — a full QL eigensolver with implicit shifts, read almost line-for-line out of Numerical Recipes — every single time PortfolioLossDistribution.homogeneous is called. The 96 quadrature nodes this page's numbers depend on are the eigenvalues of a 96×96 matrix, computed fresh, not cached and not hardcoded.

The same copula, recorded

CopulaMonteCarlo.trancheLoss builds the identical one-factor copula as a tape instead: one shared systemic draw, one idiosyncratic draw per name, and a smoothed comparison standing in for the hard default indicator —

ADouble xi = sqrtRho.mul(m).add(sqrtComp.mul(rec.randn()));
ADouble u = SpecialFn.normCdf(rec, xi);
defaults = defaults.add(Smooth.lt(rec, u, pd, width));

Smooth.lt is the same kink-smoothing move 3.2 used on a barrier option's exercise boundary, here softening "did this name default" instead of "did the spot cross the barrier." rho and pd are both named Nabla.Inputs<CopulaMarket> reads, so .greeks() on the built pricer hands back both a correlation delta and a default-probability sensitivity from one reverse sweep — the recursion above can't give either, because nothing about a Golub-Welsch eigendecomposition and a discrete convolution is differentiable in the way an adjoint sweep needs.

The real run

CdoTrancheShowcase prices five tranches on a 100-name pool (PD=5%, LGD=60%, 1y) at three correlations:

trancheρ=0.15ρ=0.30ρ=0.45
0–3% (equity)0.64070.51750.4158
3–7%0.20190.20150.1864
7–10%0.05810.09310.1065
10–15%0.01590.04390.0633
15–100% (senior)0.00020.00170.0044

Equity falls and senior rises as correlation climbs — higher correlation means defaults cluster, so the likely outcomes are "almost nobody defaults" or "everybody does," which is bad for the tranche that only needs a few defaults to be wiped out and good for the one that needs almost all of them. The recorded copula Monte-Carlo on the equity tranche (ρ=0.30, 1,000,000 paths) gives protection-leg PV +0.015305, correlation delta d(PV)/d(ρ) = −0.021545 (negative, as the table above already shows it should be), and d(PV)/d(PD) = +0.175847. Wrote and ran a standalone probe (javac+java against the built classes, same approach as every prior module) to check both claims from the deeper doc directly rather than trust them: the Monte-Carlo expected loss differs from the recursion's by 0.57% (the doc's own pinned test allows 6%), and the adjoint correlation delta matches an independent central bump (h=1×10⁻³) to four printed decimals (the doc allows 5%) — both comfortably inside their claimed bounds, measured rather than assumed.

Try it yourself

Drop the pool from 100 names to 20 and rerun the correlation table. Predict whether the equity-down/senior-up pattern still holds before you check. (It does — equity falls 0.5347 → 0.4452 → 0.3650 and senior rises 0.0006 → 0.0025 → 0.0052 across the same three correlations, the same direction as the 100-name table, just with a coarser loss grid: LGD ÷ names = 0.6 ÷ 20 = 0.0300, so one single name defaulting already uses up the entire 0–3% equity tranche, landing exactly on its detachment point rather than partway through it.)

▶️ Run it

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

The recursion runs on the host, no engine string at all; the recorded Monte-Carlo defaults to cpu-jit, the tranche's tape being small enough (one shared systemic node plus a handful of ops per name) that nothing here approaches 7.6's classfile ceiling.

⚠️ What this doesn't do

PortfolioLossDistribution.homogeneous assumes every name has the same notional and the same loss given default — real portfolios don't, and the class doc calls the heterogeneous case "a bucketed extension of the same recursion" that this build doesn't implement. The copula itself is one-factor: every name reacts to the same systemic shock, which is a simplification real desks refine with sector or regional factors. And this page never touches base-correlation bootstrapping from quoted index tranches — the doc's own "Deferred" section names it directly as future work, not a gap hidden by omission.

What's next

→ Deeper: Synthetic CDO tranches and correlation delta has the full class table (CreditCurve, CopulaMarket) and the pinned test tolerances this page's numbers are checked against. → Next: Jump-diffusion: the Greek the smoothing breaks — Module 8.2 moves from a static default/no-default outcome to a payoff that jumps in continuous time.


Questions or corrections? open an issue