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");
}
Vector ThomasSolver::solve() const {
const size_t N = _central_diagonal.size();
// Working copies — forward sweep modifies them
Vector c_prime(N - 1);
Vector R_prime(N);
// Forward sweep: eliminate lower diagonal
c_prime[0] = _upper_diagonal[0] / _central_diagonal[0];
R_prime[0] = _right_hand_side[0] / _central_diagonal[0];
for (size_t j = 1; j < N; ++j) {
double denom = _central_diagonal[j]
- (j > 0 ? _lower_diagonal[j - 1] * c_prime[j - 1] : 0.0);
if (j < N - 1)
c_prime[j] = _upper_diagonal[j] / denom;
R_prime[j] = (_right_hand_side[j]
- _lower_diagonal[j - 1] * R_prime[j - 1]) / denom;
}
// Back substitution
Vector x(N);
x[N - 1] = R_prime[N - 1];
for (int j = static_cast<int>(N) - 2; j >= 0; --j)
x[j] = R_prime[j] - c_prime[j] * x[j + 1];
return x;
}
ImpliedVolatilitySurface.h
#pragma once
#include <vector>
using Vector = std::vector<double>;
using Matrix = std::vector<Vector>;
class ImpliedVolatilitySurface {
public:
// maturities: strictly increasing, positive (years from today)
// strikes: non-decreasing, non-negative
// market_implied_vols: matrix[i][j] = sigma*(T_i, K_j), all positive
// risk_free_rate: continuously compounded, annualised
ImpliedVolatilitySurface(const Vector& maturities,
const Vector& strikes,
const Matrix& market_implied_vols,
double risk_free_rate);
// Interpolated/extrapolated sigma*(maturity, strike)
double implied_volatility(double maturity, double strike) const;
double risk_free_rate() const { return _risk_free_rate; }
private:
bool ordered_vectors() const;
bool market_dimensions() const;
bool vols_positivity() const;
// Per-maturity cubic spline at a fixed maturity index
double smile_implied_volatility(size_t maturity_index, double strike) const;
// Populates _alpha, _beta, _gamma coefficient matrices
void evaluate_cubic_spline_coefficients();
// Cubic spline coefficients [maturity_index][strike_interval_index]
Matrix _alpha_coefficients;
Matrix _beta_coefficients;
Matrix _gamma_coefficients;
Vector _delta_strikes; // delta_strikes[j] = K_{j+1} - K_j
Vector _maturities;
Vector _strikes;
Matrix _market_implied_volatilities;
double _risk_free_rate;
};
ImpliedVolatilitySurface.cpp (key methods)
#include "ImpliedVolatilitySurface.h"
#include "ThomasSolver.h"
#include <algorithm>
#include <cmath>
#include <stdexcept>
ImpliedVolatilitySurface::ImpliedVolatilitySurface(
const Vector& maturities,
const Vector& strikes,
const Matrix& market_implied_vols,
double risk_free_rate)
: _maturities(maturities),
_strikes(strikes),
_market_implied_volatilities(market_implied_vols),
_risk_free_rate(risk_free_rate)
{
if (!ordered_vectors())
throw std::invalid_argument("Maturities and strikes must be strictly ordered and positive");
if (!market_dimensions())
throw std::invalid_argument("market_implied_vols dimensions must be (M x N)");
if (!vols_positivity())
throw std::invalid_argument("All implied vols must be positive");
for (size_t j = 0; j < _strikes.size() - 1; ++j)
_delta_strikes.push_back(_strikes[j + 1] - _strikes[j]);
evaluate_cubic_spline_coefficients();
}
double ImpliedVolatilitySurface::implied_volatility(double maturity, double strike) const {
const size_t M = _maturities.size();
// Flat extrapolation before first maturity (forward-moneyness adjusted)
if (maturity <= _maturities[0]) {
double K0 = strike * std::exp(_risk_free_rate * (_maturities[0] - maturity));
return smile_implied_volatility(0, K0);
}
// Flat extrapolation after last maturity
if (maturity >= _maturities[M - 1]) {
double KM = strike * std::exp(_risk_free_rate * (_maturities[M - 1] - maturity));
return smile_implied_volatility(M - 1, KM);
}
// Find the maturity bracket [T_i, T_{i+1}]
size_t i = 0;
while (maturity >= _maturities[i]) ++i;
--i; // now T_i <= maturity < T_{i+1}
// Forward-moneyness adjusted strikes at T_i and T_{i+1}
double K_left = strike * std::exp(_risk_free_rate * (_maturities[i] - maturity));
double K_right = strike * std::exp(_risk_free_rate * (_maturities[i + 1] - maturity));
// Total variances at bracket endpoints
double sigma_l = smile_implied_volatility(i, K_left);
double sigma_r = smile_implied_volatility(i + 1, K_right);
double w_left = sigma_l * sigma_l * _maturities[i];
double w_right = sigma_r * sigma_r * _maturities[i + 1];
// Linear interpolation in total variance
double alpha = (maturity - _maturities[i]) / (_maturities[i + 1] - _maturities[i]);
double w = w_left + alpha * (w_right - w_left);
return std::sqrt(w / maturity);
}
double ImpliedVolatilitySurface::smile_implied_volatility(
size_t mat_idx, double strike) const
{
const size_t N = _strikes.size();
// Left extrapolation: linear with slope = spline derivative at K_1
if (strike <= _strikes[0]) {
double deriv = _gamma_coefficients[mat_idx][0];
return _market_implied_volatilities[mat_idx][0]
+ deriv * (strike - _strikes[0]);
}
// Right extrapolation: linear with slope = spline derivative at K_N
if (strike >= _strikes[N - 1]) {
size_t last = N - 2;
double dK = _delta_strikes[last];
double deriv = 3.0 * _alpha_coefficients[mat_idx][last] * dK * dK
+ 2.0 * _beta_coefficients[mat_idx][last] * dK
+ _gamma_coefficients[mat_idx][last];
return _market_implied_volatilities[mat_idx][N - 1]
+ deriv * (strike - _strikes[N - 1]);
}
// Cubic spline interpolation: find the interval K_j <= strike < K_{j+1}
size_t j = 0;
while (strike >= _strikes[j]) ++j;
--j;
double dx = strike - _strikes[j];
double alpha = _alpha_coefficients[mat_idx][j];
double beta = _beta_coefficients[mat_idx][j];
double gamma = _gamma_coefficients[mat_idx][j];
return alpha * dx * dx * dx
+ beta * dx * dx
+ gamma * dx
+ _market_implied_volatilities[mat_idx][j];
}
void ImpliedVolatilitySurface::evaluate_cubic_spline_coefficients() {
const size_t M = _maturities.size();
const size_t N = _strikes.size();
for (size_t i = 0; i < M; ++i) {
const Vector& Y = _market_implied_volatilities[i];
// Build the tridiagonal system for interior beta values
// System size: (N-2) x (N-2), for j = 1, ..., N-2
Vector lower, central, upper, rhs;
for (size_t j = 0; j < N - 3; ++j) {
lower.push_back(_delta_strikes[j + 1]);
upper.push_back(_delta_strikes[j + 1]);
}
for (size_t j = 0; j < N - 2; ++j) {
central.push_back(2.0 * (_delta_strikes[j] + _delta_strikes[j + 1]));
rhs.push_back(3.0 * ((Y[j + 2] - Y[j + 1]) / _delta_strikes[j + 1]
- (Y[j + 1] - Y[j]) / _delta_strikes[j]));
}
ThomasSolver solver(lower, central, upper, rhs);
Vector interior_beta = solver.solve();
// Full beta vector: beta_1 = 0 (natural BC), interior, beta_N = 0
Vector beta(N, 0.0);
for (size_t j = 1; j < N - 1; ++j)
beta[j] = interior_beta[j - 1];
_beta_coefficients.push_back(beta);
// Alpha and gamma from beta
Vector alpha(N - 1), gamma(N - 1);
for (size_t j = 0; j < N - 1; ++j) {
alpha[j] = (beta[j + 1] - beta[j]) / (3.0 * _delta_strikes[j]);
gamma[j] = (Y[j + 1] - Y[j]) / _delta_strikes[j]
- alpha[j] * _delta_strikes[j] * _delta_strikes[j]
- beta[j] * _delta_strikes[j];
}
_alpha_coefficients.push_back(alpha);
_gamma_coefficients.push_back(gamma);
}
}
Validation
Exact Recovery at Nodes
The interpolated surface must reproduce input vols exactly:
sigma*(T_i, K_j) == market_implied_vols[i][j] for all i, j
Arbitrage-Free Check: Calendar Spread
For any and :
This is enforced automatically by linear interpolation in total variance.
Benchmark: Flat Surface
If the input surface is constant for all , then:
implied_volatility(T, K) == sigma_0for any- Local vol everywhere (pure Black-Scholes)
- No numerical issues in Thomas solver (diagonal dominant, uniform spacing)
Numerical Example
Using the market surface below (equity index ATM smile, 3 maturities × 5 strikes):
| 0.25 | 0.28 | 0.24 | 0.20 | 0.21 | 0.23 |
| 0.50 | 0.27 | 0.23 | 0.20 | 0.21 | 0.22 |
| 1.00 | 0.26 | 0.22 | 0.20 | 0.21 | 0.21 |
At the query point , the total-variance interpolation gives a vol between the 0.25 and 0.50 maturity smiles at the forward-adjusted strike.
Limitations
Arbitrage at smile boundary. Natural spline conditions () can produce negative second derivatives in the interior, which implies a negative butterfly spread — a static arbitrage. Arbitrage-free splines (e.g., Fengler 2009) add monotonicity and convexity constraints but require quadratic programming.
Forward-moneyness assumes constant . The forward adjustment uses a flat risk-free rate. A term structure of rates requires replacing with the relevant forward rate for each interval.
Thomas solver is not parallelised. The per-maturity systems are independent and trivially parallelisable with std::for_each and std::execution::par. For large surfaces (100+ maturities × 500+ strikes), this matters.
The Dupire formula amplifies noise. Taking second derivatives of an interpolated surface amplifies any fitting error. For noisy market data, regularisation (e.g., Tikhonov) or SVI parametrisation of the input surface is required before computing local vols.
Interview Angle
Junior (L1): Why interpolate in total variance rather than in implied volatility directly?
Implied volatility is not a linear quantity in time. If you have vol = 20% at 6 months and vol = 22% at 12 months, a naïve linear interpolation gives 21% at 9 months — but this implies total variance , which is less than the 6-month total variance . Wait — that's fine. But in general, the no-arbitrage condition is , which is automatically satisfied when interpolating linearly but not when interpolating linearly.
Senior (L2): Describe how the Thomas algorithm works and why it is .
A general dense system requires via LU decomposition. The tridiagonal structure means that Gaussian elimination only touches three diagonals — the forward sweep processes each row in constant time, and the back substitution does likewise. Total work: multiplications and additions. The spline system is always diagonally dominant (), so no pivoting is needed and the algorithm is numerically stable.
Researcher (L3): The Dupire formula uses implied vol derivatives computed by bump-reval. What are the accuracy requirements on the step sizes and , and how do you balance truncation error against floating-point cancellation?
Central difference stencils give truncation error. Floating-point cancellation begins to dominate when . The optimal step for a central first derivative is . For a second derivative: . In practice, these are set per-surface based on the smile curvature; typical values are and years. A more robust approach uses complex-step differentiation (), which eliminates cancellation entirely but requires complex arithmetic throughout the surface evaluation.