Prerequisites

Requires IR-03 (swaption pricing). No stochastic calculus background needed; the Wiener process is built from scratch.

By the end, you can justify stochasticity from observed option prices, write down the Vasicek SDE from scratch, and test a simulation engine against the two identities it must satisfy.

The problem with deterministic rates

Why a deterministic curve prices an at-the-money swaption at zero A payoff curve above a probability density, sharing one horizontal axis: the forward swap rate at expiry, from -0.5 to 9.5 percent. The payoff of the five-year into five-year at-the-money payer swaption is flat at zero until the strike of 4.358 percent, then rises with slope equal to the annuity 3.6398, and running off the top of the panel at 7.65 percent. The strike equals today’s forward, so the deterministic answer, marked by a solid dot sitting on the axis at 4.358 percent, is exactly zero. Below the axis, the distribution of the rate at expiry is shaded, centred on the same 4.358 percent with a standard deviation of 228 basis points. Dotted risers carry five equally spaced sample rates up to the payoff curve: the three at or below the strike all land on zero, the two above it land at 415 and 830 basis points. Averaging the payoff over the whole distribution gives 331 basis points, drawn as a crimson horizontal line above the axis, with a caliper measuring the gap between the two. The payoff of the average rate is zero; the average of the payoff is 331 basis points; the difference is the option. Payoff at expiry, bp of notional K = forward = 4.358% 0 400 800 331 bp every rate below K lands on zero 0 bp this gap = the price 0% 2% 4% 6% 8% Forward swap rate at expiry, in % Shaded: its distribution, sd = 228 bp payoff of the average rate 0 bp average of the payoff 331 bp The difference is the option.
Figure 1 · IR-04 A deterministic curve prices every at-the-money swaption at zero. Make the rate random and the same swaption is worth 331 bp of notional. That gap is the option. Read the two levels off the left edge: the dot is the payoff of the average, the crimson line is the average of the payoff. Source: forward swap rate 4.3581%, annuity 3.6398, xvafoundations on the Chapter 01 curve. The 102 bp normal vol is the illustrative 5Y x 5Y cell IR-03 labels as such; the rest follows in closed form.

Under the annuity measure $\mathbb{Q}^A$ of Chapter 03 the forward swap rate is a martingale, so $\mathbb{E}^{A}\bigl[S_{\alpha,\beta}(T_\alpha)\bigr] = S_{\alpha,\beta}(0)$, which at an at-the-money quote is the strike: plugging the mean in returns $\max(K - K,\,0) = 0$ for every cell on that surface. Convexity of $\max(\cdot,0)$ forces $\mathbb{E}^{A}\bigl[\max(S_{\alpha,\beta}(T_\alpha) - K,\,0)\bigr] > 0$, and no bootstrap produces the difference.

The key insight

A stochastic model is not trying to predict where rates will go. It is trying to characterise the distribution of where they might go, because that distribution is what the instrument is priced off.

The Wiener process

The simplest object that captures unpredictable, continuous movement is the Wiener process $W_t$. Its increments are independent across non-overlapping intervals and normally distributed:

Wiener process increment $$dW_t \sim \mathcal{N}(0,\, dt)$$

The variance equals $dt$, not $dt^2$: shocks grow as the square root of time. This is why daily vol scales to annual vol by $\sqrt{252}$, and why longer-dated options are more expensive.

From noise to a short rate model

A Wiener process is just noise. A rate model adds a drift, where the rate is pulled, and a diffusion, how much noise surrounds that pull:

Generic SDE for the short rate $$dr_t = \underbrace{\mu(r_t,\, t)}_{\text{drift}} \, dt + \underbrace{\sigma(r_t,\, t)}_{\text{diffusion}} \, dW_t$$

The choice of $\mu$ and $\sigma$ is the model. Mean reversion toward a long-run level $b$ at speed $a$, with constant volatility $\sigma$, gives the Vasicek model ([1], Section 3.2) under the risk-neutral measure $\mathbb{Q}$:

Vasicek SDE (under $\mathbb{Q}$) $$dr_t = a\,(b - r_t)\,dt + \sigma\,dW_t$$

Above $b$ the drift pulls down, below $b$ it pulls up: Ornstein-Uhlenbeck applied to rates. Both constants have a unit you can feel. At $a = 0.15$ the pull closes half the gap to $b$ in $\ln 2 / a = 4.62$ years, and the fan stops widening once that pull balances the noise, at $\sigma/\sqrt{2a} = 1.46\%$; five years reaches $\sigma\sqrt{(1 - e^{-2aT})/2a} = 1.29\%$.

The Vasicek bond price formula

Vasicek's advantage is a closed form for the bond price. Risk-neutral pricing gives

Risk-neutral pricing of the ZCB $$P(t,T) = \mathbb{E}^{\mathbb{Q}}_t\!\left[\exp\!\Bigl(-\!\int_t^T r_s\,ds\Bigr)\right]$$

Under Vasicek $r$ is Markov under $\mathbb{Q}$ with constant $(a, b, \sigma)$ and $r_T \mid r_t$ is Gaussian, so $\int_t^T r_s\,ds$ is a limit of sums of Gaussians and Gaussian too. The expectation of a lognormal is exponential-affine:

Vasicek zero-coupon bond price $$P(t,T) = A(t,T)\,\exp\!\bigl(-B(t,T)\,r_t\bigr)$$ $$B(t,T) = \frac{1 - e^{-a(T-t)}}{a}, \qquad A(t,T) = \exp\!\left[\left(b - \frac{\sigma^2}{2a^2}\right)\!\bigl(B(t,T) - (T - t)\bigr) - \frac{\sigma^2\,B(t,T)^2}{4a}\right]$$

$B(t,T) = \int_0^{T-t} e^{-au}\,du$ and $-\partial \ln P(t,T)/\partial r_t = B(t,T)$: the bond's sensitivity to today's short rate, accumulated over the remaining life and damped by the mean reversion (derivation in [1], Section 3.2.1). It also lets the martingale test below reprice analytically at any simulated $r_s$, with no second Monte Carlo nested inside the first.

At $a = 0.15$, $b = 4\%$, $\sigma = 0.80\%$, $r_0 = 4.33\%$:

$B(0,5) = \frac{1 - e^{-0.15 \times 5}}{0.15} = 3.5176$, and $\ln A(0,5) = \bigl(b - \frac{\sigma^2}{2a^2}\bigr)(B - T) - \frac{\sigma^2}{4a}B^2 = -0.0585$, giving $P(0,5) = e^{-0.0585} \times e^{-3.5176 \times 0.0433} = 0.8099$.

Monte Carlo pricing

Start at $r_0$, draw increments, accumulate $\exp(-\int_0^T r_s\,ds)$ along the path, and average the discounted payoff:

Monte Carlo price $$V_0 = \mathbb{E}^{\mathbb{Q}}\!\left[\exp\!\Bigl(-\!\int_0^T r_s\,ds\Bigr) \cdot h(r_T)\right] \approx \frac{1}{N} \sum_{i=1}^{N} e^{-\sum_k \frac{1}{2}\left(r_{t_k}^{(i)} + r_{t_{k+1}}^{(i)}\right) \Delta t} \cdot h\!\left(r_T^{(i)}\right)$$

The exponential factor is the path-wise stochastic discount factor, kept in integral form throughout to avoid confusion with the deterministic curve $P(0,T)$. $h(r_T)$ is the payoff at maturity.

The fan of futures the model considers possible
Over ten years the distribution widens from a point at 4.33% to a 5-95% span of 1.73%-6.42%, while the mean falls only to 4.07%: the spread is the story, not the drift.
Vasicek transition law at $r_0 = 4.33\%$ (the Chapter 01 SOFR 1W deposit), $a = 0.15$, $b = 4.00\%$, $\sigma = 0.80\%$. Optionality prices off the band, not off one forward. Source: xvafoundations, closed form, no Monte Carlo.
These parameters are illustrative

Vasicek with constant $a$, $b$, $\sigma$ does not fit the bootstrapped Chapter 01 curve. Hull-White (Chapter 05, draft) replaces the constant long-run level $b$ with a time-dependent one, $\theta(t)/a$, which forces today's bond prices to reprice exactly, and it is what the production engine and the XVA series simulate. Vasicek is here because you can write it down by hand.

The martingale test

Test 1: discount factor alone

Can the simulation reproduce today's $P(0,T)$ from the stochastic discount factor alone? Under $\mathbb{Q}$ the path-wise factor must average to the deterministic price:

Discount factor identity $$\mathbb{E}^{\mathbb{Q}}\!\left[\exp\!\Bigl(-\!\int_0^T r_s\,ds\Bigr)\right] = P(0,T)$$

The identity does not care which short-rate model you feed it, so this runs on the engine that ships: xvafoundations.models.HullWhite1F on the Chapter 01 SOFR curve, every path starting at that curve's own $r(0) = 4.3282\%$.

Then plot the difference, not the two curves. On a bond-price axis running 97.9% down to 66.7% a 10bp error is a tenth of a pixel: an overlay cannot fail visibly, so it is not a test.

Term structure test: simulated stochastic discount factor minus $P(0,T)$, in standard errors
No error reaches one Monte Carlo standard error, and the signs are mixed: the largest is 0.53 at one year, which is what sampling noise around zero looks like.
In basis points the errors run between -0.67 and +0.96, but one standard error runs 0.10 at six months to 5.05 at ten years, so each error is shown against its own rather than against a flat tolerance. Source: xvafoundations.models.HullWhite1F.simulate, $a = 0.05$, $\sigma = 0.70\%$, seed 137, 20,000 paths, 200 steps to 10y, on the Chapter 01 SOFR curve.

Why the accumulator is not a trapezoid on the short rate

Exact simulation buys an unbiased short rate. It does not buy an unbiased money-market account on a finite grid. Write $r_t = x_t + \alpha(t)$: the deterministic $\alpha$ carries $f(0,t)$, which log-linear interpolation of $\ln P(0,T)$ leaves piecewise constant with a jump at every curve pillar. Trapezoid $r$ itself and each panel straddling a jump misses $\tfrac{1}{2}\Delta t$ times it, so the rule drops to first order, and the straddling-panel count is the pillar count, which does not shrink with $\Delta t$.

Bias of a raw trapezoid on the short rate, in bp, exact, no Monte Carlo
Maturity200 steps400800
0.5Y0.330.160.08
1Y1.670.840.41
2Y1.560.810.38
3Y1.520.740.35
5Y1.040.540.29
7Y0.900.390.18
10Y0.790.400.17

The two pillars inside the first year drop $f$ by 13.7bp and 56.2bp, and $0.025 \times 69.8 = 1.75$bp predicts the 1.74bp of accumulation lost, or the 1.67bp in the first column once multiplied by $P(0,1) = 0.9596$. Halving the step halves each column rather than quartering it: first order, and no path count touches it.

So the library does not accumulate that way. $\int_0^t f(0,u)\,du = -\ln P(0,t)$ holds by definition of the instantaneous forward, under every interpolation and every pillar set, so $\int_0^t \alpha$ is closed form, that identity plus a Hull-White convexity term, and only the smooth $x$ is quadratured. No panel straddles a pillar. On the same seven maturities at 200 steps the residual bias is at most 0.0003bp, and it falls by exactly 4.000 at each of four halvings: second order, measured. The trapezoid survives under the name money_market_account_trapezoid, where the model-risk sweep still measures it; there the switch took the worst bias from 11.39bp to 0.012bp.

Test 2: the tower property

The stronger check: at any intermediate $s$, the path-wise discount factor times the conditional bond price must average back to today's $P(0,T)$. That is the tower property of conditional expectation, applied to pricing:

Tower property (martingale condition) $$\mathbb{E}^{\mathbb{Q}}\!\left[\exp\!\Bigl(-\!\int_0^s r_u\,du\Bigr)\cdot P(s,T)\right] = P(0,T), \qquad \forall\, 0 \leq s \leq T$$

Here $P(s,T)$ is the Vasicek analytical price at the simulated $r_s$. On a path where rates rose you discount harder, but the bond you still hold is cheaper by the offsetting amount, so the product does not move: the two effects are the same effect seen twice.

Tower property: stochastic discount factor times $P(s,5)$ vs $P(0,5)$ at six monitoring dates
Six running averages, spread over 60 basis points at ten paths, close to within 4.6bp of each other and 6.3bp of P(0,5) = 0.809920 by three thousand paths.
Six dates $s = 0.5$ to $4.0$Y, all targeting $T = 5$Y, same Vasicek parameters, 200 exact steps; the closed-form $P(0,5) = 0.809920$ is the dashed crimson line. Provenance: not library output. Vasicek is a teaching model and does not ship in xvafoundations, so the run is simulated in your browser at seed 271; only the target line is library.

Python implementation

The teaching version of both tests: exact Vasicek step, no library dependency, 50,000 paths. Its Test 1 targets the Vasicek analytical $P(0,T)$, not the Chapter 01 curve, which constant-parameter Vasicek cannot reproduce; the chart above ran the same identity on HullWhite1F, which can.

Python · PyTorch
import math

import torch

torch.set_default_dtype(torch.float64)


class VasicekModel:
    """Vasicek short rate under Q:  dr_t = a (b - r_t) dt + sigma dW_t."""

    def __init__(self, r0=0.0433, a=0.15, b=0.04, sigma=0.008):
        self.r0, self.a, self.b, self.sigma = r0, a, b, sigma

    def bond_price(self, r_t, t, T):
        """Analytical Vasicek ZCB price P(t, T) given r_t."""
        tau = T - t
        B = (1.0 - math.exp(-self.a * tau)) / self.a
        ln_A = ((self.b - self.sigma ** 2 / (2 * self.a ** 2)) * (B - tau)
                - self.sigma ** 2 * B ** 2 / (4 * self.a))
        return torch.exp(ln_A - B * torch.as_tensor(r_t))

    def simulate(self, T, n_steps, n_paths, seed=137):
        """Exact Vasicek step. Returns (n_paths, n_steps + 1) short rates and
        the trapezoidal integral of r accumulated along each path, matching
        xvafoundations.pricing.money_market_account_trapezoid."""
        torch.manual_seed(seed)
        dt = T / n_steps
        decay = math.exp(-self.a * dt)
        shift = self.b * (1.0 - decay)
        vol = self.sigma * math.sqrt(
            (1.0 - math.exp(-2.0 * self.a * dt)) / (2.0 * self.a))

        rates = torch.zeros(n_paths, n_steps + 1)
        rates[:, 0] = self.r0
        for i in range(n_steps):
            rates[:, i + 1] = rates[:, i] * decay + shift + vol * torch.randn(n_paths)

        integrals = torch.zeros_like(rates)
        integrals[:, 1:] = torch.cumsum(
            0.5 * (rates[:, :-1] + rates[:, 1:]) * dt, dim=1)
        return rates, integrals


model = VasicekModel()
T_max, n_steps = 10.0, 400
rates, integrals = model.simulate(T=T_max, n_steps=n_steps, n_paths=50_000)

# Test 1: E^Q[exp(-int_0^T r ds)] = P(0,T).  Constant-parameter Vasicek has a
# smooth drift, so the trapezoid stays second order here and the residual at
# 400 steps is Monte Carlo noise, not bias.  Point the same rule at a
# bootstrapped curve and it drops to first order, because f(0,t) jumps at every
# pillar: that is the table above.  The library avoids it by integrating alpha
# in closed form and quadraturing only x.
for T in [0.5, 1.0, 2.0, 3.0, 5.0, 7.0, 10.0]:
    k = round(T * n_steps / T_max)
    sim = torch.exp(-integrals[:, k]).mean()
    err_bp = (sim - model.bond_price(model.r0, 0.0, T)).item() * 1e4
    print(f"T={T:4.1f}Y  sim={sim:.6f}  err={err_bp:+.2f}bp")

# Test 2: E^Q[exp(-int_0^s r du) * P(s,5)] = P(0,5)
P05 = model.bond_price(model.r0, 0.0, 5.0)
for s in [0.5, 1.0, 1.5, 2.0, 3.0, 4.0]:
    k = round(s * n_steps / T_max)
    avg = (torch.exp(-integrals[:, k]) * model.bond_price(rates[:, k], s, 5.0)).mean()
    print(f"s={s:4.1f}Y  E[disc*P]={avg:.6f}  err={(avg - P05).item() * 1e4:+.2f}bp")

Why this matters for XVA

Every exposure number in the XVA series averages over paths this machinery produces, so understating rate volatility understates exposure and therefore CVA. XVA-01 turns the paths into an exposure profile; XVA-03 derives the CVA integral from it.

The moral

The discipline comes from the SDE: it constrains which futures are consistent with market prices. The averaging is Monte Carlo. Together they give a price that cannot be arbitraged, provided the model is calibrated to instruments you can observe.

References

  1. D. Brigo and F. Mercurio, Interest Rate Models: Theory and Practice, 2nd edition, Springer, 2006. Sections 3.2 (the model) and 3.2.1 (the analytical bond price).
  2. S. E. Shreve, Stochastic Calculus for Finance II: Continuous-Time Models, Springer, 2004. Chapters 3-5: Brownian motion, SDEs, change of measure.
  3. O. A. Vasicek, "An Equilibrium Characterization of the Term Structure," Journal of Financial Economics, 5(2), 1977. The original paper.