Rate volatility surfaces
This page lists what you need to build and read a rate volatility surface: its three constructors, their arguments, the read, and an example you can run. A rate surface holds implied vols on a grid of option expiries and underlying tenors, optionally with a strike axis, and swaptions, caps and floors, and bond options all read their vol from one.
A market can also hold a SABR smile in the same place, with with_swaption_vols or with_cap_vols:
vols.SabrVolSurface keeps the ATM vol, ρ and ν at each expiry and tenor node and implies α at each
read. SABR lists that model; this page covers the grid.
Quick example
This builds a normal-vol at-the-money grid with expiries of 1, 2 and 5 years and tenors of 2, 5 and 10 years, and reads it at a node, between nodes and beyond its edges. Then it reads a cap smile at absolute strikes, a swaption cube at a spread to the forward, and pins a swaption surface and a cap surface on one market.
use chrono::NaiveDate;
use helixrs::{Currency, MarketContext, RateVolSurface, StrikeAxis, VolConvention};
type Outcome<T> = Result<T, Box<dyn std::error::Error>>;
fn main() -> Outcome<()> {
let expiries = vec![1.0, 2.0, 5.0];
let tenors = vec![2.0, 5.0, 10.0];
// Normal (basis-point) vols, one row per expiry, in tenor order.
let rows = vec![
vec![0.0090, 0.0085, 0.0080],
vec![0.0095, 0.0090, 0.0084],
vec![0.0100, 0.0094, 0.0088],
];
let atm = RateVolSurface::new(expiries.clone(), tenors.clone(), &rows, VolConvention::Normal)?;
let view = atm.view::<f64>();
// At a node the read is the quote itself. The same numbers under a
// lognormal convention are a different surface: the convention picks the
// pricing formula.
let node = view.vol(2.0, 5.0);
let lognormal =
RateVolSurface::new(expiries.clone(), tenors.clone(), &rows, VolConvention::Lognormal)?;
println!("expiries {:?} tenors {:?}", atm.expiries(), atm.tenors());
println!("2Y x 5Y node {:6.2} bp", node * 10_000.0);
println!("convention {}", atm.convention().code());
println!("same grid, relabelled {}", lognormal.convention().code());
// Between nodes: linear in vol across tenor, then linear in total variance
// across expiry.
let between = view.vol(3.0, 7.0);
println!("3Y x 7Y {:6.2} bp", between * 10_000.0);
// Short of the first expiry, past the last, and outside the tenor axis, the
// read is the edge node's vol.
let probes = [(0.25, 5.0), (10.0, 5.0), (2.0, 1.0), (2.0, 30.0), (0.25, 30.0)];
let edges: Vec<f64> = probes.iter().map(|&(expiry, tenor)| view.vol(expiry, tenor)).collect();
for ((expiry, tenor), vol) in probes.iter().zip(&edges) {
println!("{expiry:5.2}Y x {tenor:4.1}Y {:6.2} bp", vol * 10_000.0);
}
// A cap smile: one index tenor, absolute strikes, one row per expiry. A
// strike is read linearly in vol, flat beyond both edges.
let strikes = vec![0.02, 0.03, 0.04];
let smile_rows = vec![vec![0.0110, 0.0100, 0.0105], vec![0.0105, 0.0098, 0.0102]];
let smile =
RateVolSurface::smile(vec![1.0, 3.0], 0.25, strikes.clone(), &smile_rows, VolConvention::Normal)?;
let smiled = smile.view::<f64>();
let mut smile_reads = Vec::new();
for strike in [0.01, 0.025, 0.035, 0.06] {
let vol = smiled.vol_at_strike(1.0, 0.25, strike)?;
smile_reads.push((strike, vol));
println!("1Y at {:4.2}% {:6.2} bp", strike * 100.0, vol * 10_000.0);
}
let at_2y = smiled.vol_at_strike(2.0, 0.25, 0.035)?;
println!("2Y at 3.50% {:6.2} bp", at_2y * 10_000.0);
// A swaption cube is quoted at spreads to the at-the-money forward. The
// surface records its strike axis, because the numbers alone can't tell a
// 1% strike from a 100 bp spread. Rust reads a spread with `vol_at_spread`
// and refuses an absolute-strike read on a spread axis.
let spreads = vec![-0.01, 0.0, 0.01];
let cube_tenors = vec![2.0, 10.0];
let cells = vec![
vec![vec![0.0100, 0.0090, 0.0096], vec![0.0092, 0.0084, 0.0089]],
vec![vec![0.0104, 0.0095, 0.0099], vec![0.0096, 0.0088, 0.0092]],
];
let cube = RateVolSurface::cube(
vec![1.0, 5.0],
cube_tenors.clone(),
spreads.clone(),
&cells,
VolConvention::Normal,
)?
.over_the_forward();
let cube_view = cube.view::<f64>();
let at_spread = cube_view.vol_at_spread(1.0, 6.0, 0.005)?;
let wrong_axis = if cube_view.vol_at_strike(1.0, 6.0, 0.035).is_err() { "refused" } else { "read" };
println!("strike axis {}", cube.strike_axis().code());
println!("1Y x 6Y at +50 bp {:6.2} bp", at_spread * 10_000.0);
println!("a read the axis can't take {wrong_axis}");
// A currency carries a swaption surface and a cap surface side by side;
// each product reads its own.
let usd = Currency::new("USD")?;
let reference = NaiveDate::from_ymd_opt(2026, 5, 21).expect("a real calendar date");
let market = MarketContext::as_of(reference)
.with_swaption_vols(usd, atm.clone())
.with_cap_vols(usd, smile.clone());
let swaption_read = market.swaption_vols(usd).expect("a pinned swaption surface").view::<f64>().vol(3.0, 7.0);
let cap_read = market.cap_vols(usd).expect("a pinned cap surface").view::<f64>().vol_at_strike(2.0, 0.25, 0.035)?;
println!("swaption surface, 3Y x 7Y {:6.2} bp", swaption_read * 10_000.0);
println!("cap surface, 2Y at 3.50% {:6.2} bp", cap_read * 10_000.0);
Ok(())
}import datetime as dt
import helixio as hlx
EXPIRIES = [1.0, 2.0, 5.0]
TENORS = [2.0, 5.0, 10.0]
# Normal (basis-point) vols, one row per expiry, in tenor order.
ROWS = [
[0.0090, 0.0085, 0.0080],
[0.0095, 0.0090, 0.0084],
[0.0100, 0.0094, 0.0088],
]
atm = hlx.vols.RateVolSurface(EXPIRIES, TENORS, ROWS, hlx.vols.VolConvention.Normal)
# At a node the read is the quote itself. The same numbers under a lognormal
# convention are a different surface: the convention picks the pricing formula.
node = atm.vol(2.0, 5.0)
lognormal = hlx.vols.RateVolSurface(EXPIRIES, TENORS, ROWS, "lognormal")
print(f"expiries {atm.expiries()} tenors {atm.tenors()}")
print(f"2Y x 5Y node {node * 10_000.0:6.2f} bp")
print(f"convention {atm.vol_convention()}")
print(f"same grid, relabelled {lognormal.vol_convention()}")
# Between nodes: linear in vol across tenor, then linear in total variance
# across expiry.
between = atm.vol(3.0, 7.0)
print(f"3Y x 7Y {between * 10_000.0:6.2f} bp")
# Short of the first expiry, past the last, and outside the tenor axis, the
# read is the edge node's vol.
PROBES = [(0.25, 5.0), (10.0, 5.0), (2.0, 1.0), (2.0, 30.0), (0.25, 30.0)]
edges = [atm.vol(expiry, tenor) for expiry, tenor in PROBES]
for (expiry, tenor), vol in zip(PROBES, edges):
print(f"{expiry:5.2f}Y x {tenor:4.1f}Y {vol * 10_000.0:6.2f} bp")
# A cap smile: one index tenor, absolute strikes, one row per expiry. A strike
# is read linearly in vol, flat beyond both edges.
STRIKES = [0.02, 0.03, 0.04]
SMILE_ROWS = [[0.0110, 0.0100, 0.0105], [0.0105, 0.0098, 0.0102]]
smile = hlx.vols.RateVolSurface.smile([1.0, 3.0], 0.25, STRIKES, SMILE_ROWS, "normal")
smile_reads = {strike: smile.vol(1.0, 0.25, strike) for strike in (0.01, 0.025, 0.035, 0.06)}
for strike, vol in smile_reads.items():
print(f"1Y at {strike * 100.0:4.2f}% {vol * 10_000.0:6.2f} bp")
at_2y = smile.vol(2.0, 0.25, 0.035)
print(f"2Y at 3.50% {at_2y * 10_000.0:6.2f} bp")
# A swaption cube is quoted at spreads to the at-the-money forward. The surface
# records its strike axis, because the numbers alone can't tell a 1% strike
# from a 100 bp spread. Python reads the one strike coordinate on the axis the
# surface carries, and refuses a read that names no strike at all.
SPREADS = [-0.01, 0.0, 0.01]
CUBE_TENORS = [2.0, 10.0]
CELLS = [
[[0.0100, 0.0090, 0.0096], [0.0092, 0.0084, 0.0089]],
[[0.0104, 0.0095, 0.0099], [0.0096, 0.0088, 0.0092]],
]
cube = hlx.vols.RateVolSurface.cube(
[1.0, 5.0], CUBE_TENORS, SPREADS, CELLS, "normal", hlx.vols.StrikeAxis.SpreadToAtm
)
at_spread = cube.vol(1.0, 6.0, 0.005)
try:
cube.vol(1.0, 6.0)
no_strike = "read"
except ValueError:
no_strike = "refused"
print(f"strike axis {cube.strike_axis()}")
print(f"1Y x 6Y at +50 bp {at_spread * 10_000.0:6.2f} bp")
print(f"a read the axis can't take {no_strike}")
# A currency carries a swaption surface and a cap surface side by side; each
# product reads its own.
market = (
hlx.MarketContext(dt.date(2026, 5, 21))
.with_swaption_vols("USD", atm)
.with_cap_vols("USD", smile)
)
swaption_read = market.swaption_vols("USD").vol(3.0, 7.0)
cap_read = market.cap_vols("USD").vol(2.0, 0.25, 0.035)
print(f"swaption surface, 3Y x 7Y {swaption_read * 10_000.0:6.2f} bp")
print(f"cap surface, 2Y at 3.50% {cap_read * 10_000.0:6.2f} bp")expiries [1.0, 2.0, 5.0] tenors [2.0, 5.0, 10.0]
2Y x 5Y node 90.00 bp
convention normal
same grid, relabelled lognormal
3Y x 7Y 89.84 bp
0.25Y x 5.0Y 85.00 bp
10.00Y x 5.0Y 94.00 bp
2.00Y x 1.0Y 95.00 bp
2.00Y x 30.0Y 84.00 bp
0.25Y x 30.0Y 80.00 bp
1Y at 1.00% 110.00 bp
1Y at 2.50% 105.00 bp
1Y at 3.50% 102.50 bp
1Y at 6.00% 105.00 bp
2Y at 3.50% 100.63 bp
strike axis spread_to_atm
1Y x 6Y at +50 bp 89.75 bp
a read the axis can't take refused
swaption surface, 3Y x 7Y 89.84 bp
cap surface, 2Y at 3.50% 100.63 bp
A node reads its own quote. Every probe outside the grid reads the nearest edge node. Each product on the market reads its own surface: the swaption surface at 3Y × 7Y, the cap surface at 3.50%.
API
| Argument | Type | Default | Meaning |
|---|---|---|---|
vols.RateVolSurface(expiries, tenors, vols) |
list[float] ×2, list[list[float]] |
required | an at-the-money grid, in years, one row of vols per expiry in tenor order; Rust RateVolSurface::new |
vol_convention |
VolConvention or str |
"normal" |
"normal" (basis points, Bachelier) or "lognormal" (percent of the rate, Black-76) |
day_count |
str or DayCountFraction |
"ACT/365.FIXED" |
RateVolSurface(...) only: the basis a reader counts years to an expiry on; smile and cube take none |
vols.RateVolSurface.smile(expiries, tenor_years, strikes, vols) |
lists, float |
required | one tenor with a strike axis, one row per expiry in strike order: a cap smile |
vols.RateVolSurface.cube(expiries, tenors, strikes, vols) |
lists | required | all three axes, vols[expiry][tenor][strike]: a swaption cube |
strike_axis |
StrikeAxis or str |
"absolute" |
for smile and cube: "absolute" rates or "spread_to_atm"; Rust over_the_forward() |
| Read | Returns | Meaning |
|---|---|---|
vol(expiry_years, tenor_years, strike=None) |
float |
the vol at a point; a smiled surface must be given a strike, read on the axis it carries |
expiries(), tenors(), strikes() |
lists | the grid's axes; strikes() is None without a strike axis |
vol_convention(), strike_axis(), day_count() |
str |
what the surface carries |
In Rust the reads sit on RateVolSurface::view::<f64>(): vol at the money, vol_at_strike on an
absolute axis and vol_at_spread on a spread axis. A market holds a currency's surfaces with
with_swaption_vols and with_cap_vols, and returns them with swaption_vols and cap_vols.
Defaults and limits
- The read: linear in vol across strikes and tenors, linear in total variance across expiries.
- Beyond the data: outside every edge the read is the edge node's vol. Nothing is extrapolated.
- The convention: the surface carries it, and each pricer takes its formula from it, so the same numbers under the other convention price differently.
- Strike axes: a read naming the wrong kind of coordinate is refused. In Python a smiled
surface read with no strike raises
ValueError; in Rust an absolute-strike read on a spread axis returns an error. - One of each per currency: a market holds one swaption surface and one cap surface per currency.
The ideas behind these rules are in how vol surfaces work.
See also
- Source:
crates/helixrs/src/marketdata/rate_vol.rs - Related pages:
- How vol surfaces work, the ideas behind the read.
- Swaptions, which read the at-the-money grid or the cube.
- Caps and floors, which read the cap smile.
- Bond options, which read the tenor axis at a duration.
- FX vol surfaces, for the same expiry rule on a delta smile.
From the 0.0 line at revision bf7f2e2. Every printed number is that revision's own output.