Setup
The Problem
A vanilla option's Black-Scholes price is a strictly increasing function of volatility. Given a market price, there exists a unique such that . This is the implied volatility.
If the Black-Scholes model were correct, would be the same for every strike and maturity . In practice, it is not. The surface of market-implied volatilities exhibits:
- A smile or skew along the strike axis (more pronounced for equity index options)
- A term structure along the maturity axis (implied vol changes with expiry)
For a Monte Carlo pricer to match all market prices simultaneously, it needs to read from this surface at arbitrary during path simulation. This is the Dupire local volatility model, and the ImpliedVolatilitySurface class is its data structure.
What this module builds:
- A
ThomasSolverfor the tridiagonal system arising from natural cubic spline interpolation. - An
ImpliedVolatilitySurfacethat interpolates across strikes (cubic spline, per maturity) and across maturities (linear in total variance). - The numerical differentiation formulae that feed the Dupire formula.
Conventions:
- Volatilities are annualised, in decimal (0.20 = 20%).
- Maturities are in years from today.
- The risk-free rate is continuously compounded and used for forward-moneyness adjustments.
- No dividends.
Theory
1. Cubic Spline Interpolation
Given strike nodes with corresponding implied vols (at a fixed maturity ), we want a function that:
- Passes exactly through each node:
- Is a cubic polynomial on each interval
- Has continuous first and second derivatives everywhere
Let . On interval , write:
The continuity of the second derivative at each interior knot yields the system of equations:
with natural spline boundary conditions (zero second derivative at the endpoints).
This is a tridiagonal linear system for . Once the are known:
2. The Thomas Algorithm
The tridiagonal system has the form:
The Thomas algorithm solves this in operations (versus for Gaussian elimination on a general matrix):
Forward sweep — eliminate the lower diagonal:
Back substitution:
The algorithm is numerically stable for diagonally dominant matrices (which the cubic spline system is, since the off-diagonal elements equal and the diagonal equals ).
3. Interpolation Along the Maturity Axis
For a query with , we cannot simply interpolate implied vols — linear interpolation in vol introduces calendar spread arbitrage. The no-arbitrage condition requires total variance to be non-decreasing in .
Forward-moneyness adjustment: At maturity , the strike equivalent to at time is: This aligns the forward moneyness across maturities, where is the forward price.
Linear interpolation in total variance:
4. Dupire Local Volatility
Given the implied volatility surface , the Dupire (1994) formula extracts the local volatility such that the model: prices all vanilla options consistently:
where is log-moneyness and .
In the implementation, the partial derivatives are computed by finite differences on the surface using central stencils:
The DupireLocalVolatilityModel wraps an ImpliedVolatilitySurface and computes these derivatives on the fly during path simulation.
Implementation
ThomasSolver.h
#pragma once
#include <vector>
using Vector = std::vector<double>;
class ThomasSolver {
public:
// Solves the tridiagonal system A x = rhs where:
// lower_diag = [a_2, ..., a_N] (size N-1)
// central_diag = [b_1, ..., b_N] (size N)
// upper_diag = [c_1, ..., c_{N-1}] (size N-1)
// rhs = [R_1, ..., R_N] (size N)
ThomasSolver(const Vector& lower_diag,
const Vector& central_diag,
const Vector& upper_diag,
const Vector& rhs);
// Returns solution vector [x_1, ..., x_N]
Vector solve() const;
private:
Vector _lower_diagonal;
Vector _central_diagonal;
Vector _upper_diagonal;
Vector _right_hand_side;
};
ThomasSolver.cpp
#include "ThomasSolver.h"
#include <stdexcept>
ThomasSolver::ThomasSolver(const Vector& lower,
const Vector& central,
const Vector& upper,
const Vector& rhs)
: _lower_diagonal(lower),
_central_diagonal(central),
_upper_diagonal(upper),
_right_hand_side(rhs)
{
const size_t N = central.size();
if (lower.size() != N - 1 || upper.size() != N - 1 || rhs.size() != N)
throw std::invalid_argument("ThomasSolver: inconsistent vector sizes");
}