Introduction
When pricing Constant Maturity Swap (CMS) products, one of the biggest sources of error is not the interest-rate curve — it’s the swaption volatility surface, and in particular its smile.
In this post, we walk through how to build a robust SABR swaption volatility cube in QuantLib (C++), starting from intuitive market inputs and ending with a volatility object suitable for CMS pricing. We stop just before pricing CMS coupons themselves. The goal here is to understand and construct the volatility infrastructure correctly, the next post will be about pricing.
Why CMS Pricing needs more than just ATM Vol
A CMS coupon depends on the future level of a swap rate. That future rate is not deterministic, it has a distribution, and that distribution depends on:
- the level of swaption volatility,
- how volatility varies with strike (the smile),
- how volatility varies across expiries and tenors.
An ATM-only volatility surface assumes the distribution is symmetric and simple. In reality, swaption markets exhibit skew and curvature, which materially affect CMS convexity.
That’s why CMS pricing requires a volatility cube, not just a surface.
The Swaption Vol Cube
A swaption volatility cube extends the usual 2D surface by adding strike dependence. Conceptually, the cube has three axes:
- Option expiry (e.g. 1Y, 5Y, 10Y) – the expiry axis,
- Swap tenor (e.g. 2Y, 10Y, 30Y) – the tenor axis, and
- Strike (expressed as offsets from ATM) – the strike axis.
Each slice through the cube at a fixed expiry and tenor is a volatility smile.
Setup
For our setup we will construct a new struct called CMSParams. Here are all the things we will need:
// Common Data for pricing a CMS
RelinkableHandle<YieldTermStructure> termStructure;
ext::shared_ptr<IborIndex> iborIndex;
Handle<SwaptionVolatilityStructure> atmVol;
Handle<SwaptionVolatilityStructure> SabrVolCube1;
Handle<SwaptionVolatilityStructure> SabrVolCube2;
std::vector<GFunctionFactory::YieldCurveModel> yieldCurveModels;
std::vector<ext::shared_ptr<CmsCouponPricer>> numericalPricers;
std::vector<ext::shared_ptr<CmsCouponPricer>> analyticalPricers;
The constructor for this struct will calculate the following dates:
// Calendars & Dates
Calendar calendar = TARGET();
Date referenceDate = calendar.adjust(Date::todaysDate());
Settings::instance().evaluationDate() = referenceDate;
termStructure.linkTo(flatRate(referenceDate, 0.05, Actual365Fixed()));
As you can see, we use a single flat curve for both forward and discounting. This keeps the focus on volatility and convexity rather than any multi-curve effects.
Building the ATM Swaption Volatility Matrix
Our SABR cube will be anchored to an ATM swaption surface. This surface must use the same swap conventions as the underlying swap indices (e.g. ISDA fixing conventions for EUR).
// ATM Volatility Structure
std::vector<Period> atmOptionTenors = { 1 * Months,
6 * Months,
1 * Years,
5 * Years,
10 * Years,
30 * Years };
std::vector<Period> atmSwapTenors = { 1 * Years,
5 * Years,
10 * Years,
30 * Years };
Matrix m(atmOptionTenors.size(), atmSwapTenors.size());
m[0][0] = 0.1300; m[0][1] = 0.1560; m[0][2] = 0.1390; m[0][3] = 0.1220;
m[1][0] = 0.1440; m[1][1] = 0.1580; m[1][2] = 0.1460; m[1][3] = 0.1260;
m[2][0] = 0.1600; m[2][1] = 0.1590; m[2][2] = 0.1470; m[2][3] = 0.1290;
m[3][0] = 0.1640; m[3][1] = 0.1470; m[3][2] = 0.1370; m[3][3] = 0.1220;
m[4][0] = 0.1400; m[4][1] = 0.1300; m[4][2] = 0.1250; m[4][3] = 0.1100;
m[5][0] = 0.1130; m[5][1] = 0.1090; m[5][2] = 0.1070; m[5][3] = 0.0930;
atmVol = Handle<SwaptionVolatilityStructure>(
ext::shared_ptr<SwaptionVolatilityStructure>(new
SwaptionVolatilityMatrix(calendar,
Following,
atmOptionTenors,
atmSwapTenors,
m,
Actual365Fixed())));
Why the Fixed-Leg Day-Count Matters
For EUR ISDA swaps, the fixed leg uses 30/360 (Bond Basis). If this does not match the swap index conventions, the ATM surface and smile calibration will be internally inconsistent.
Smile Data: Strike Spreads & Vol Spreads
Market swaption smiles are quoted as volatility spreads relative to ATM, at a small set of strikes. We input these directly now:
std::vector<Period> optionTenors = { {1, Years}, {10, Years}, {30, Years} };
std::vector<Period> swapTenors = { {2, Years}, {10, Years}, {30, Years} };
std::vector<Spread> strikeSpreads = { -0.020, -0.005, 0.000, 0.005, 0.020 };
Each (expiry, tenor) pair has a row of vol spreads corresponding to these strikes. These are stored as Handle<Quote> objects so they can be updated dynamically.
Vol Spread Market Data
Market swaption smiles are typically quoted as volatility spreads relative to ATM, for a small grid of:
- option expiries,
- swap tenors,
- strike offsets (e.g. ±50 bp, ±200 bp).
QuantLib expects these smile quotes in a very specific format:
a matrix of volatility spreads, indexed by (option tenor, swap tenor) pairs, with one row per pair and one column per strike offset.
Defining the Grid Size
Given:
std::vector<Period> optionTenors;
std::vector<Period> swapTenors;
std::vector<Spread> strikeSpreads;
the number of smile nodes is:
Size nRows = optionTenors.size() * swapTenors.size();
Size nCols = strikeSpreads.size();
Each row corresponds to one (expiry, tenor) smile, and each column corresponds to one strike spread.
Here is the market data I am using:
volSpreadsMatrix[0][0] = 0.0599;
volSpreadsMatrix[0][1] = 0.0049;
volSpreadsMatrix[0][2] = 0.0000;
volSpreadsMatrix[0][3] = -0.0001;
volSpreadsMatrix[0][4] = 0.0127;
volSpreadsMatrix[1][0] = 0.0729;
volSpreadsMatrix[1][1] = 0.0086;
volSpreadsMatrix[1][2] = 0.0000;
volSpreadsMatrix[1][3] = -0.0024;
volSpreadsMatrix[1][4] = 0.0098;
volSpreadsMatrix[2][0] = 0.0738;
volSpreadsMatrix[2][1] = 0.0102;
volSpreadsMatrix[2][2] = 0.0000;
volSpreadsMatrix[2][3] = -0.0039;
volSpreadsMatrix[2][4] = 0.0065;
volSpreadsMatrix[3][0] = 0.0465;
volSpreadsMatrix[3][1] = 0.0063;
volSpreadsMatrix[3][2] = 0.0000;
volSpreadsMatrix[3][3] = -0.0032;
volSpreadsMatrix[3][4] = -0.0010;
volSpreadsMatrix[4][0] = 0.0558;
volSpreadsMatrix[4][1] = 0.0084;
volSpreadsMatrix[4][2] = 0.0000;
volSpreadsMatrix[4][3] = -0.0050;
volSpreadsMatrix[4][4] = -0.0057;
volSpreadsMatrix[5][0] = 0.0576;
volSpreadsMatrix[5][1] = 0.0083;
volSpreadsMatrix[5][2] = 0.0000;
volSpreadsMatrix[5][3] = -0.0043;
volSpreadsMatrix[5][4] = -0.0014;
volSpreadsMatrix[6][0] = 0.0437;
volSpreadsMatrix[6][1] = 0.0059;
volSpreadsMatrix[6][2] = 0.0000;
volSpreadsMatrix[6][3] = -0.0030;
volSpreadsMatrix[6][4] = -0.0006;
volSpreadsMatrix[7][0] = 0.0533;
volSpreadsMatrix[7][1] = 0.0078;
volSpreadsMatrix[7][2] = 0.0000;
volSpreadsMatrix[7][3] = -0.0045;
volSpreadsMatrix[7][4] = -0.0046;
volSpreadsMatrix[8][0] = 0.0545;
volSpreadsMatrix[8][1] = 0.0079;
volSpreadsMatrix[8][2] = 0.0000;
volSpreadsMatrix[8][3] = -0.0042;
volSpreadsMatrix[8][4] = -0.0020;
Converting Raw Numbers into Live Market Quotes
QuantLib expects volatility spreads to be supplied as handles to quotes, not raw numbers. This allows the cube to:
- recalibrate dynamically,
- respond to quote updates,
- integrate cleanly with observers.
We therefore convert the matrix into a vector of vectors of Handle<Quote>:
std::vector<std::vector<Handle<Quote> > > volSpreads(nRows);
for (Size i = 0; i < nRows; ++i) {
volSpreads[i] = std::vector<Handle<Quote> >(nCols);
for (Size j = 0; j < nCols; ++j) {
volSpreads[i][j] = Handle<Quote>(ext::shared_ptr<Quote>(
new SimpleQuote(volSpreadsMatrix[i][j])));
}
}
At this point, we have a fully dynamic representation of the swaption smile surface.
Swap Indices for Smile Anchoring
The volatility cube must be built using swap indices that match the ATM surface conventions. For EUR CMS pricing, this means ISDA Fix swaps.
iborIndex = ext::shared_ptr<IborIndex>(new Euribor6M(termStructure));
ext::shared_ptr<SwapIndex> swapIndexBase(new
EuriborSwapIsdaFixA(10 * Years, termStructure));
ext::shared_ptr<SwapIndex> shortSwapIndexBase(new
EuriborSwapIsdaFixA(2 * Years, termStructure));
These indices are used to:
- compute ATM strikes,
- convert strike spreads into absolute strikes,
- build swap annuities consistently with the ATM surface.
Build the Interpolated Smile Cube
Before calibrating a full SABR cube, we will build an interpolated volatility cube directly from the smile spreads:
bool vegaWeightedSmileFit = false;
SabrVolCube2 = Handle<SwaptionVolatilityStructure>(
ext::make_shared<InterpolatedSwaptionVolatilityCube>(atmVol,
optionTenors,
swapTenors,
strikeSpreads,
volSpreads,
swapIndexBase,
shortSwapIndexBase,
vegaWeightedSmileFit));
SabrVolCube2->enableExtrapolation();
SABR Parameter Guesses and Constraints
QuantLib calibrates one SABR model per (expiry, tenor). We must supply:
- initial parameter guesses,
- a mask indicating which parameters are fixed.
So let’s go ahead an build this:
// Set up the initial guesses and calibration constraints
// for the SABR smile calibration:
//
// Initial guesses should be reasonable, stable defaults for EUR swaptions:
//
// Note that I am fixing \beta = 0.5 which is very common in practice.
// * Stabilises the smile
// * Prevents extreme wing behaviour
// * Makes CMS convexity much more robust
std::vector<std::vector<Handle<Quote>>> guess(nRows);
for (Size i = 0; i < nRows; ++i) {
guess[i] = std::vector<Handle<Quote> >(4);
guess[i][0] =
Handle<Quote>(ext::shared_ptr<Quote>(new SimpleQuote(0.2)));
guess[i][1] =
Handle<Quote>(ext::shared_ptr<Quote>(new SimpleQuote(0.5)));
guess[i][2] =
Handle<Quote>(ext::shared_ptr<Quote>(new SimpleQuote(0.4)));
guess[i][3] =
Handle<Quote>(ext::shared_ptr<Quote>(new SimpleQuote(0.0)));
}
Now we switch from an interpolated smile cube to an explicitly SABR-calibrated cube, and the extra flags here control how SABR is fit and what the ATM surface represents. Note that the actual numerical calibration happens lazily and on demand in QuantLib, not at construction time.
The SabrSwaptionVolatilityCube constructor does the following:
- Stores the ATM surface
- Stores the Smile Vol Spreads
- Stores the Swap Indices
- Stores the SABR Parameter Guesses
- Stores which parameters are fixed
But it does not immediately run the calibration.
std::vector<bool> isParameterFixed(4, false);
isParameterFixed[1] = true;
bool isAtmCalibrated = false;
SabrVolCube1 = Handle<SwaptionVolatilityStructure>(
ext::make_shared<SabrSwaptionVolatilityCube>(atmVol,
optionTenors,
swapTenors,
strikeSpreads,
volSpreads,
swapIndexBase,
shortSwapIndexBase,
vegaWeightedSmileFit,
guess,
isParameterFixed,
isAtmCalibrated));
SabrVolCube1->enableExtrapolation();
Conclusion
Key Design Choices
Key design choices:
- ATM-anchored (ATM vols are preserved exactly),
- β fixed at 0.5 for stability,
- no vega weighting for deterministic behaviour.
The result is a smooth, arbitrage-aware volatility cube suitable for CMS pricing.
At this point, we have built:
- a deterministic yield-curve setup,
- an ISDA-consistent ATM swaption surface,
- a structured grid of smile volatility spreads,
- an interpolated smile cube,
- a fully calibrated SABR volatility cube.
This is all the volatility infrastructure required for CMS pricing.
In the next post, we’ll use this cube to:
- construct CMS coupons,
- compare different CMS convexity approximations,
- study the impact of smile and yield-curve dynamics.