- IR-01, whose SOFR curve prices this swap, and XVA-00. You need mark-to-market; nothing else.
- IR-08 (draft) derives the repricer, but
xvafoundations.pricing.reprice_swap_pathsalready ships, so $V(t, \omega)$ is built here.
By the end, you can compute EE, EPE, PFE and EEPE from a simulated mark-to-market matrix.
What is exposure?
Take a derivative worth $V(t)$ to you, its mark-to-market at $t$: what closing it out would pay you, negative when you owe them. If your counterparty defaults, only one sign of $V(t)$ costs you anything.
CHART_DATA samplePaths, paths 12 and 22.
Above the corner you hold a claim on the estate, recover a fraction $R$, and their default costs you $(1-R)\,V(t)$. Below it your liability survives in full. Exposure on one path is the height of the crimson line; its mirror feeds DVA:
Expected Exposure and EPE
Expected Exposure is the risk-neutral expectation of that corner across paths:
Every profile here is one trade: a 10-year payer swap on 10,000,000 USD, annual ACT/360 fixed leg, struck at its own par rate of $4.1200\%$ off the IR-01 curve and repriced under Hull-White 1F, $N = 5{,}000$ paths, quarterly to 10.25 years. The parameters $a = 0.05$, $\sigma = 0.007$ are asserted: IR-05 and IR-06 are unwritten.
Expected Positive Exposure is the time-average of that profile:
CHART_DATA crossSection, 5,000 paths in 40 bins.
Effective EPE and regulatory capital
Under the Internal Model Method, a bank cannot cut capital because EE dips on the grid. Effective Expected Exposure is the running maximum:
Effective EPE averages EEE over the first year, or maturity if shorter (BCBS 279):
Potential Future Exposure
EE is a mean, and a mean is not conservative enough for limits: a counterparty who exceeds a credit limit 50% of the time is not well-managed. Potential Future Exposure is a high quantile instead:
CHART_DATA, seed 42. The grey textbook curve is illustrative.
The textbook argument says uncertainty grows as $\sigma\sqrt{t}$ while the annuity outstanding shrinks as $(T-t)$, so exposure peaks at $t = T/3$. That locates the dispersion of $V(t)$, which peaks at year 4. EE peaks a year later, because the remaining swap carries a rising risk-neutral mean.
Risk-neutral vs. real-world measure
CVA is computed under $\mathbb{Q}$; regulatory capital uses real-world exposure. Different numbers, different models, never to be conflated.
CVA uses $\mathbb{Q}$. It is a derivative price, so its EE profiles are calibrated to implied vols and CDS spreads. Put $\mathbb{P}$ in the CVA integral and you misprice the hedge.
Capital uses $\mathbb{P}$. Basel III requires stress-period historical calibration for the IMM exposure-at-default (BCBS 279, paragraph 32). Banks without IMM approval use SA-CCR instead: replacement cost plus a supervisory add-on, no simulation, far less sensitive to portfolio composition. That insensitivity is the price of not running a model.
PFE for internal limits is a desk choice on which practice is divided: many dealers run it under $\mathbb{P}$ to align with capital, others stay in $\mathbb{Q}$ for consistency with the pricing chain (Gregory 2020, ch. 8). The answers differ, so any limit framework should say which it uses.
Which is why large desks do not choose. They run two simulation engines in parallel: a risk-neutral one for CVA, DVA and FVA pricing, and a real-world one for IMM capital and credit-limit monitoring. Two calibrations, two sets of exposure profiles, one trade population. The code in this series is the risk-neutral engine.
Exposure profiles by product type
A bought European option runs the other way from the intuition that an option bleeds value as expiry approaches. Its value is never negative, so there is no positive part to take, and with no coupons leaving the trade $V(t)/B(t)$ is a martingale: EE$(t) = V(0)/P(0,t)$ under deterministic rates. Exposure rises to expiry, then cliffs to zero. That intuition holds the underlying still; an expectation does not. Sold, $V(t) \leq 0$ and EE is identically zero.
Computing exposure in code
This listing is the whole chain, market data to metric, and it produced every library curve on this page.
from datetime import date
import torch
from xvafoundations.calibration import Stripper
from xvafoundations.conventions import DayCount, year_fraction
from xvafoundations.curves import InterpolationMethod
from xvafoundations.data import sofr
from xvafoundations.instruments import (
build_instruments, initial_rates, pillar_maturities,
)
from xvafoundations.models import HullWhite1F
from xvafoundations.pricing import SwapSpec, reprice_swap_paths
from xvafoundations.xva.exposure import (
compute_ee, compute_ene, compute_eee, compute_eepe, compute_pfe,
)
torch.set_default_dtype(torch.float64)
# ── Market: the IR-01 SOFR curve, bootstrapped from its 14 instruments ──
specs, valuation_date = sofr.INSTRUMENTS, sofr.VALUATION_DATE
stripper = Stripper(
maturities=torch.tensor(pillar_maturities(specs, valuation_date)),
initial_rates=torch.tensor(initial_rates(specs)),
instrument_factory=lambda c: build_instruments(specs, valuation_date, c),
method=InterpolationMethod.LOGLINEAR,
)
stripper.calibrate()
curve = stripper.get_curve()
# ── Model: Hull-White 1F. a and sigma are ASSERTED, not calibrated;
# IR-05 and IR-06 are unwritten. Both sit in the range a USD desk sees.
model = HullWhite1F(a=0.05, sigma=0.007, curve=curve)
# ── Trade: 10Y payer swap, 10,000,000 USD, struck at its own par rate.
# Payment times are ACT/360 year fractions, so the first is 1.013889.
schedule = [
year_fraction(valuation_date,
date(valuation_date.year + 1 + k, 1, 15), DayCount.ACT360)
for k in range(10)
] # 1.013889, 2.027778, ..., 10.144444
par = curve.par_swap_rate(torch.tensor(0.0), torch.tensor(schedule)).item()
spec = SwapSpec(notional=1.0e7, fixed_rate=par,
fixed_payment_times=schedule, pay_fixed=True)
# ── Simulate the short rate, then reprice on every path at every date.
short_rates, times = model.simulate(5000, 41, 10.25, seed=42)
mtm = reprice_swap_paths(model, spec, short_rates, times) # (5000, 42)
ee = compute_ee(mtm) # EE(t_j): shape (42,)
ene = compute_ene(mtm) # ENE(t_j): shape (42,), positive
eee = compute_eee(ee) # EEE(t_j): running maximum
eepe = compute_eepe(ee, times, cap=1.0) # EEPE: scalar, one-year window
pfe = compute_pfe(mtm, alpha=0.975) # PFE(97.5%): shape (42,)
# EPE is the time-average of EE over the full horizon. There is no
# compute_epe: it is one line of the trapezoid rule compute_eepe
# already applies to EEE.
epe = torch.trapezoid(ee, times) / times[-1]
k, m = int(ee.argmax()), int(pfe.argmax())
print(f"par fixed rate {par * 100:.4f}%")
print(f"EPE [0, 10.25Y] {epe.item():>12,.2f}")
print(f"EEPE [0, 1Y] {eepe.item():>12,.2f}")
print(f"peak EE t={times[k].item():5.2f}Y {ee[k].item():>12,.2f}")
print(f"EEE freezes at {eee.max().item():>12,.2f}")
print(f"peak PFE t={times[m].item():5.2f}Y {pfe[m].item():>12,.2f}")
print(f"ENE at the EE peak {ene[k].item():>12,.2f}")
par fixed rate 4.1200%
EPE [0, 10.25Y] 238,137.54
EEPE [0, 1Y] 120,866.59
peak EE t= 5.00Y 347,746.36
EEE freezes at 347,746.36
peak PFE t= 5.00Y 1,459,983.78
ENE at the EE peak 220,767.43
ENE at the EE peak is $220{,}767.43$ against an EE of $347{,}746.36$. That $126{,}978.93$ USD gap is neither noise nor a directional view.
They take any (num_paths, num_times) MtM tensor, and they are pure PyTorch, so CS01 and IR delta come from backpropagating the chain: quotes → curve → HW paths → MtM → EE → CVA.
Negative exposure and DVA
ENE is the expectation of the negative part of $V(t)$:
NOTATION_CONVENTIONS.md defines ENE as a single negative number, the time-average of $\mathbb{E}^{\mathbb{Q}}[\min(V(t),0)]$. This series and compute_ene use the time-indexed, sign-flipped form above, so ENE$(t)$ stands to DVA as EE$(t)$ stands to CVA, and is plotted positive. A chart whose sign contradicts its own code is worse than a stated deviation.
ENE is to DVA what EE is to CVA, with your own survival curve in place of theirs (Chapter 03).
compute_ene returns it.
That gap is not about how often $V(t)$ is positive: at year 5 the swap is in your favour on $59.06\%$ of paths, a near coin flip, while EE runs $58\%$ above ENE. Since $\max(V,0) - \max(-V,0) = V$,
They separate to the extent that the remaining trade is expected to be worth something. It is tempting to object that an at-market swap has $V(0) = 0$ with $V(t)/B(t)$ a $\mathbb{Q}$-martingale. It is not. The martingale is the gains process, which adds back the coupons already paid:
That is falsifiable. The four net coupons settled by year 5 are worth $-88{,}445.43$ USD at time 0, so $\mathbb{E}^{\mathbb{Q}}[V(5)/B(5)]$ should be $+88{,}445.43$; the simulation returns $83{,}229$ against a Monte Carlo standard error of $8{,}237$. Nor is this a directional view on rates: the fixed rate is one blend of the whole forward strip, so early net coupons have negative time-0 value and late ones positive.
The difference is $\mathbb{E}^{\mathbb{Q}}[V(t)]$ exactly, climbing to $140{,}085$ USD by year 6. A receiver swap is the mirror image.
References
- Gregory, J. (2020). The xVA Challenge: Counterparty Risk, Funding, Collateral, Capital and Initial Margin, 4th ed. Wiley.
- Crépey, S. (2015). Bilateral counterparty risk under funding constraints, Part I and II. Mathematical Finance, 25(1).
- Brigo, D., Morini, M. & Pallavicini, A. (2013). Counterparty Credit Risk, Collateral and Funding. Wiley.
- Basel Committee on Banking Supervision (2014). BCBS 279: The standardised approach for measuring counterparty credit risk exposures.
- Pykhtin, M. & Zhu, S. (2007). A guide to modelling counterparty credit risk. GARP Risk Review, July/August.
XVA-02 extends this to a netting set with collateral.