Every derivatives desk maintains code that maps (expiry, strike) pairs to implied volatilities. The pipeline is always the same: extract implied vols from option prices, fit parametric smile models per tenor, assemble a surface across tenors, check for arbitrage, and optionally extract local volatilities for Monte Carlo. In-house implementations of this pipeline are remarkably common, even though the core problem is well-understood.
volsurf is a Rust library that implements this pipeline. This post covers why it exists, how it works, where the math gets tricky, and how fast it runs.
The Problem
Volatility surface construction is fundamental derivatives infrastructure. Every options desk needs it, and the mathematical building blocks – SVI, SABR, SSVI, Breeden-Litzenberger, Dupire – are published and well-understood. Yet the path from papers to production code is longer than it looks.
The existing open-source landscape offers vol surface capabilities embedded inside larger frameworks or as scattered single-purpose packages. If you want the full pipeline – implied vol extraction, parametric smile fitting, cross-tenor interpolation, arbitrage detection – you typically either adopt a full pricing framework and accept its architectural decisions, or you assemble it yourself from pieces. Both are reasonable choices, but neither gives you a focused, standalone library you can drop into an existing system.
A few specific properties matter for this problem domain. Market data is ragged: the 3-month tenor might have 12 quoted strikes while the 2-year has 6. Surfaces should be immutable after construction – when data changes, rebuild rather than mutate. Concurrent access is essential for scenario pricing. And vol queries must be fast enough that the surface lookup is never the bottleneck in a pricing engine.
The Rust ecosystem has no coverage here. volsurf is a standalone library focused entirely on the surface construction problem: it is a surface library, not a pricing library. It builds and queries the surface; what you do with those vols – Black-Scholes, local vol Monte Carlo, PDE methods – is up to you.
Why Rust
The choice of language matters here because vol surface construction has specific architectural requirements that Rust handles naturally.
No global state. The evaluation date is a parameter to every function, not a singleton. Every surface type is Send + Sync by default. You can price on 16 threads against the same surface with no locks and no mutexes – just wrap it in Arc<dyn VolSurface>.
Immutable by default. When market data changes, you rebuild the surface. Rust’s ownership model makes immutability the path of least resistance, which eliminates a whole class of stale-state bugs in surface caching. No observer pattern, no cascading invalidation.
Ragged storage by design. Vec<Vec<f64>> is the natural representation for market data where tenors have different strike counts. No padding, no sentinel values, no rectangular matrix constraint.
Zero-overhead abstraction. After construction, a vol query evaluates a closed-form formula through Rust’s monomorphized trait dispatch. SVI evaluates in 4.7ns. There is nothing to allocate, nothing to look up – static dispatch means the compiler inlines the evaluation path with no indirection.
Type safety without ceremony. Output values are wrapped in newtypes – Vol, Variance, Strike, Tenor – so you cannot accidentally pass a variance where a vol is expected. Input parameters remain bare f64 for ergonomics, since parameter names are self-documenting. It is a deliberate trade-off: protect against silent misuse of computed results without making the API verbose.
What volsurf Does
The library implements a five-layer pipeline:
flowchart LR
A["Option Prices"] --> B["Implied Vol
(Layer 1)"]
B --> C["Smile
(Layer 2)"]
C --> D["Surface
(Layer 3)"]
D --> E["Local Vol
(Layer 5)"]
C -.-> F["Arbitrage
Detection
(Layer 4)"]
D -.-> F
The core interface is the VolSurface trait, which maps (expiry, strike) pairs across the full surface:
pub trait VolSurface: Send + Sync + Debug {
fn black_vol(&self, expiry: f64, strike: f64) -> Result<Vol>;
fn smile_at(&self, expiry: f64) -> Result<Box<dyn SmileSection>>;
fn diagnostics(&self) -> Result<SurfaceDiagnostics>;
// ...
}
The ergonomic entry point is SurfaceBuilder:
use volsurf::surface::{SurfaceBuilder, SmileModel, VolSurface};
let surface = SurfaceBuilder::new()
.spot(100.0)
.rate(0.05)
.model(SmileModel::Sabr { beta: 0.5 })
.add_tenor(0.25, &strikes, &vols_3m)
.add_tenor(0.50, &strikes, &vols_6m)
.add_tenor(1.00, &strikes, &vols_1y)
.build()?;
let vol = surface.black_vol(0.5, 100.0)?;
let diag = surface.diagnostics()?;
println!("Arbitrage-free: {}", diag.is_free);
The builder validates inputs, computes forward prices, calibrates a smile per tenor using the selected model, sorts by expiry, and assembles a PiecewiseSurface that interpolates linearly in total variance space between tenors.
Three smile models are available: SmileModel::Svi (default, needs 5+ strikes per tenor), SmileModel::CubicSpline (3+ strikes), and SmileModel::Sabr { beta } (4+ strikes, beta fixed by convention). For global parameterization, SsviSurface implements the Gatheral-Jacquier (2014) SSVI model – three global parameters (rho, eta, gamma) plus per-tenor ATM total variances – with a two-stage calibration method.
Arbitrage detection is built into every level. Per-tenor, SmileSection::is_arbitrage_free() checks risk-neutral density non-negativity. Per-surface, VolSurface::diagnostics() combines butterfly and calendar spread checks into a single SurfaceDiagnostics report.
Getting the Math Right
The implementation details that matter in practice are the ones that textbooks skip.
SABR: the ATM discontinuity. The Hagan (2002) closed-form approximation has a term $z / x(z)$ where $z$ is proportional to log-moneyness and $x(z)$ involves an inverse hyperbolic function. Many implementations use separate ATM and off-ATM branches, creating a discontinuity at $K = F$ that shows up as a kink in the smile. More importantly, this kink means the calibration optimizer’s objective function is not smooth, leading to slower convergence and occasional failure modes near ATM. volsurf uses a single unified code path with a Taylor expansion for $|z| < 10^{-6}$:
let z_ratio = if z.abs() < 1e-6 {
1.0 - 0.5 * rho * z + (2.0 - 3.0 * rho * rho) / 12.0 * z * z
} else {
let disc = (1.0 - 2.0 * rho * z + z * z).sqrt();
let xz = ((disc + z - rho) / (1.0 - rho)).ln();
z / xz
};
This handles ATM, near-ATM, and the CEV limit ($\nu \to 0$) without branching. The result is self-consistent to 12 significant digits across the full parameter space when round-tripping through vol -> price -> vol.
SABR calibration: constrained optimization without constraints. Beta is fixed by convention (0.5 for equities, 1.0 for lognormal, 0.0 for normal/rates). Alpha is solved analytically from ATM vol via Newton iteration on the ATM cubic.
The key trick is transforming constrained parameters into unconstrained space:
$$ \rho = \tanh(x), \quad \nu = \exp(y) $$
The optimizer sees unconstrained $\mathbb{R}^2$ while the parameters automatically stay in $(-1, 1)$ and $(0, \infty)$. A 15x15 grid search initializes the Nelder-Mead refinement.
SSVI: analytical calendar arbitrage. The SSVI model parameterizes total variance as:
$$ w(k, \theta) = \frac{\theta}{2}\left[1 + \rho \varphi k + \sqrt{(\varphi k + \rho)^2 + (1 - \rho^2)}\right] $$
where the mixing function follows a power law:
$$ \varphi(\theta) = \eta / \theta^\gamma $$
This formulation admits an analytical derivative $\partial w / \partial \theta$. For valid SSVI parameters satisfying $\eta(1 + |\rho|) \le 2$, this derivative is provably non-negative – meaning calendar arbitrage is impossible by construction. volsurf checks this analytically rather than scanning strike grids, which is both faster (200ns vs 4us) and exact.
Butterfly arbitrage: Breeden-Litzenberger. The risk-neutral probability density is the second derivative of call prices with respect to strike:
$$ q(K) = \frac{\partial^2 C}{\partial K^2} $$
This density must be non-negative everywhere to prevent butterfly arbitrage. For SVI, non-negativity is checked analytically via the Gatheral g-function. For SABR, the Hagan approximation breaks down in far wings – a known limitation of the formula. Density is scanned over log-moneyness $[-2, 2]$ and wing errors are excluded.
The test suite validates all of this with 540 tests. SABR vol is self-consistent to 12 significant digits across the full parameter space. SVI calibration reproduces the example surfaces from Gatheral (2014). Implied vol round-trips are accurate to $< 10^{-12}$, well within practical precision for any pricing application.
Property-based tests using proptest verify invariants like “SSVI total variance is always non-negative” and “calibrating from a model’s own output recovers the original parameters” across thousands of random inputs. Integration tests construct realistic multi-tenor surfaces from synthetic market data and verify the full pipeline: calibration, cross-tenor interpolation, arbitrage diagnostics, and smile extraction at intermediate tenors.
Benchmarks
All numbers from Criterion.rs on Apple Silicon.
| Operation | Time |
|---|---|
| SVI vol query | 4.7 ns |
| SABR vol query | 17 ns |
| SSVI surface vol query | 20 ns |
| SABR calibration (1 tenor) | 74 us |
| SVI calibration (1 tenor) | 108 us |
| SSVI calibration (3 tenors) | 266 us |
| Surface construction (5 tenors) | 381 us |
| Surface construction (20 tenors) | 2.6 ms |
xychart-beta
title "Vol Query Performance (nanoseconds)"
x-axis ["SVI", "SABR", "SSVI"]
y-axis "Time (ns)" 0 --> 25
bar [4.7, 17, 20]
A vol query is a closed-form formula evaluation – no allocation, no dispatch chain, no cache lookup. For SVI, that is a single evaluation of $w(k) = a + b(\rho(k - m) + \sqrt{(k - m)^2 + \sigma^2})$ followed by $\sigma_\text{BS} = \sqrt{w / T}$. At 4.7ns, you can evaluate roughly 200 million points per second on a single core.
xychart-beta
title "Calibration Time (microseconds)"
x-axis ["SABR", "SVI", "SSVI"]
y-axis "Time (us)" 0 --> 300
bar [74, 108, 266]
The construction numbers include calibration of every tenor. A 20-tenor surface with 30 strikes per tenor – a realistic production grid – builds in 2.6ms. At 74us per SABR calibration, you can calibrate a 20-tenor surface in ~1.5ms, fast enough for intraday recalibration on every data snapshot.
The design target was vol query < 100ns, SABR calibration < 1ms, and 20-tenor surface < 10ms. All targets are exceeded by at least 4x.
For context: at 2.6ms per surface rebuild, you could reconstruct a full 20-tenor surface 384 times per second. Most production vol surfaces update at most once per second. This means the library is fast enough to rebuild on every tick rather than maintaining incremental update machinery – a significant simplification of the overall system architecture.
Scope and Limitations
volsurf is v0.2 and has not yet been battle-tested in production. A few things to be aware of:
The Hagan SABR approximation is a closed-form expansion, not an exact solution. It is accurate for moderate strikes but breaks down in far wings. Wing density errors are detected and excluded from arbitrage reports, but if you need deep OTM accuracy, you may want to pair it with an alternative (Obloj correction, exact PDE-based SABR).
Several modules are stubbed for v0.3: NormalImpliedVol (Bachelier), DisplacedImpliedVol, EssviSurface (extended SSVI with calendar no-arb by construction), and DupireLocalVol. The forward price calculation uses a flat rate and ignores dividend yield. These are documented in the API and will be addressed in upcoming releases.
The library currently focuses on equity and FX vol surfaces. Interest rate surfaces (swaption cubes, caplet vols) have different conventions and are out of scope.
Try It
[dependencies]
volsurf = "0.2"
The simplest working example:
use volsurf::surface::{SurfaceBuilder, VolSurface};
let strikes = vec![80.0, 90.0, 95.0, 100.0, 105.0, 110.0, 120.0];
let vols = vec![0.28, 0.24, 0.22, 0.20, 0.22, 0.24, 0.28];
let surface = SurfaceBuilder::new()
.spot(100.0)
.rate(0.05)
.add_tenor(0.25, &strikes, &vols)
.add_tenor(1.00, &strikes, &vols)
.build()
.unwrap();
let vol = surface.black_vol(0.5, 100.0).unwrap();
println!("6-month ATM vol: {:.2}%", vol.0 * 100.0);
Optional features: logging adds tracing instrumentation in calibration paths, parallel enables rayon for parallel surface construction (coming in v0.3).
The library is Apache-2.0 licensed. The GitHub repo has five worked examples covering surface construction, SABR calibration, SSVI surfaces, implied vol extraction, and smile model comparison. API docs are on docs.rs.
Next up: v0.3 “Production Grade” will add the extended SSVI (eSSVI) surface with calendar no-arb by construction, Dupire local vol extraction, and parallel construction via rayon.