Fit an SVI volatility surface
Fit raw SVI to a smile, check it for butterfly arbitrage, and calibrate an arbitrage-free SSVI surface across maturities, in Rust and Python.
SVI ("stochastic volatility inspired") is a five-parameter formula for one maturity's implied total variance as a function of log-moneyness. SSVI extends it to a whole surface with three global parameters and the at-the-money total variance curve, with explicit conditions under which the surface is free of static arbitrage. This tutorial fits raw SVI to a smile, checks the fit for butterfly arbitrage, and calibrates an SSVI surface across four maturities.
What you'll build
A raw SVI slice fitted to a smile, a check that its implied density is non-negative, and an SSVI surface you can query for implied and local volatility at any strike and maturity.
Prerequisites
- Rust:
stochastic-rs, no features; the types live instochastic_rs::quant::vol_surface. Python:pip install stochastic-rs. - A smile as log-forward moneyness and total implied variance . The examples build theirs from a model, so the fit has a known right answer.
The parameterisations
Raw SVI writes one slice as
and SSVI writes the whole surface through the at-the-money total variance of each maturity:
A slice admits no butterfly arbitrage when its implied density is non-negative, which is Durrleman's condition with
Step 1 — fit raw SVI to one smile
The Rust example takes the smile of a Heston model one year out — nine strikes, Black implied volatilities from the model's own surface — fits raw SVI to it, and checks the fit at every strike.
// docs: tutorials/svi-volatility-surface
//! Fits raw SVI to one maturity of a Heston smile and checks the fit for butterfly arbitrage.
use stochastic_rs::quant::pricing::heston::HestonPricer;
use stochastic_rs::quant::vol_surface::ModelSurface;
#[test]
fn raw_svi_fits_a_heston_smile() {
// The smile to fit: Black implied vols of a Heston model at nine strikes, one year out.
let model = HestonPricer::new(0.04, -0.5, 2.0, 0.04, 0.3, None);
let strikes = (0..9).map(|i| 80.0 + 5.0 * i as f64).collect::<Vec<_>>();
let surface = model.vol_surface(100.0, 0.02, 0.0, &strikes, &[1.0]);
let smile = surface.smile_slice(0); // log-forward moneyness k and total variance w = iv^2 t
let svi = smile.fit_svi(None);
assert!(svi.is_admissible());
for (&k, &w) in smile.log_moneyness.iter().zip(&smile.total_variance) {
assert!((svi.total_variance(k) - w).abs() < 1e-5);
}
// Durrleman's g(k) stays non-negative on a wide grid: no butterfly arbitrage.
let grid = (-100..=100).map(|i| 0.01 * i as f64).collect::<Vec<_>>();
assert!(svi.is_butterfly_arb_free(&grid));
// Jump-wings view of the slice: half the ATM slope w'(0), negative for a downward skew.
assert!(svi.jump_wings(1.0).psi_t < 0.0);
}import stochastic_rs as srs
# A slice generated by known SVI parameters, so the fit has a right answer.
truth = srs.SviRawParams(0.02, 0.1, -0.4, 0.05, 0.2) # a, b, rho, m, sigma
ks = [-0.5 + 0.05 * i for i in range(21)]
fit = srs.SviCalibrator(ks, [truth.total_variance(k) for k in ks])
a, b, rho, m, sigma = fit.params()
print(f"a={a:.4f} b={b:.4f} rho={rho:.4f} m={m:.4f} sigma={sigma:.4f}")
# a=0.0200 b=0.1000 rho=-0.4000 m=0.0500 sigma=0.2000
print(srs.SviRawParams(a, b, rho, m, sigma).is_admissible()) # True
print(round(fit.implied_vol(0.0, 1.0), 4)) # 0.2064, at the money one year outfit_svi(None) starts from the library's own initial guess; pass
Some(params) to start from a previous fit. is_admissible() checks the
parameter constraints of raw SVI (, ,
and a non-negative minimum variance).
Step 2 — check the slice for butterfly arbitrage
A good least-squares fit can still imply a negative density somewhere in the
wings. is_butterfly_arb_free(&grid) evaluates Durrleman's on a grid
of log-moneyness and fails on the first negative value; the Rust example above
checks in steps of 0.01. durrleman_g(k) is public, so a
custom calibration can add it as a penalty. The same example reads the slice
in jump-wings form, whose is half the at-the-money slope :
negative for a downward skew.
Step 3 — calibrate an SSVI surface
SSVI fits three numbers — , , — jointly across every maturity, given each slice's at-the-money total variance . The example generates four slices from known parameters and recovers them.
// docs: tutorials/svi-volatility-surface
//! Calibrates the global SSVI parameters to four maturity slices and reads the surface.
use stochastic_rs::quant::vol_surface::SsviParams;
use stochastic_rs::quant::vol_surface::SsviSurface;
use stochastic_rs::quant::vol_surface::ssvi::SsviSlice;
use stochastic_rs::quant::vol_surface::ssvi::calibrate_ssvi;
#[test]
fn ssvi_recovers_its_parameters_and_is_arbitrage_free() {
// Slices generated by SSVI with rho = -0.4, eta = 0.6, gamma = 0.4 and theta_t = 0.04 t.
let truth = SsviParams::new(-0.4, 0.6, 0.4);
let maturities = vec![0.25, 0.5, 1.0, 2.0];
let ks = (0..21).map(|i| -0.5 + 0.05 * i as f64).collect::<Vec<_>>();
let slices = maturities
.iter()
.map(|&t| SsviSlice {
log_moneyness: ks.clone(),
total_variance: ks
.iter()
.map(|&k| truth.total_variance(k, 0.04 * t))
.collect(),
theta: 0.04 * t, // ATM total variance of the slice
})
.collect::<Vec<_>>();
let fit = calibrate_ssvi(&slices, None);
assert!((fit.rho + 0.4).abs() < 1e-6 && (fit.eta - 0.6).abs() < 1e-6);
assert!(fit.satisfies_no_butterfly_condition()); // eta (1 + |rho|) <= 2
let surface = SsviSurface::new(fit, slices.iter().map(|s| s.theta).collect(), maturities);
assert!(surface.is_calendar_spread_free(&ks));
assert!((surface.implied_vol(0.0, 0.75) - 0.2).abs() < 1e-12); // theta(t) is linear in t
assert!(surface.local_vol(0.0, 0.75).is_finite()); // Dupire, from the analytic derivatives
}import stochastic_rs as srs
truth = srs.SsviParams(-0.4, 0.6, 0.4) # rho, eta, gamma
ks = [-0.5 + 0.05 * i for i in range(21)]
# One (log_moneyness, total_variance, theta) triple per maturity, theta_t = 0.04 t.
slices = [(ks, [truth.total_variance(k, 0.04 * t) for k in ks], 0.04 * t)
for t in (0.25, 0.5, 1.0, 2.0)]
fit = srs.SsviCalibrator(slices)
print(tuple(round(x, 4) for x in fit.params())) # (-0.4, 0.6, 0.4)
print(srs.SsviParams(*fit.params()).satisfies_no_butterfly_condition()) # Truesatisfies_no_butterfly_condition() checks ,
and SsviSurface::is_calendar_spread_free checks that total variance does not
decrease with maturity at any strike of the grid. The surface answers
implied_vol(k, t) and local_vol(k, t) — Dupire's local volatility from the
surface's analytic derivatives — at any maturity between the fitted ones.
Step 4 — when one ρ is not enough
SSVI's single ties the skew of every maturity together. EssviSurface
(extended SSVI) calibrates a triple per maturity,
going forward in maturity inside the butterfly and calendar-spread bounds, and
interpolates linearly in between them, following
Corbetta et al. (2019). In Python: srs.EssviSurface.calibrate(maturities, slices)
with the same triples as above.
Result
A raw SVI slice that reproduces the smile to in total variance with a non-negative implied density on , and an SSVI surface that recovers its generating parameters, passes the butterfly and calendar checks, and prices implied and local volatility off one set of three numbers.
Where to go next
- eSSVI slices with arbitrage-free interpolation on the quant page
- Heston: simulate, price and calibrate — the model whose smile step 1 fits
- Choosing a calibrator for fitting a model rather than a parameterisation to the same quotes
References
- Gatheral, J. and Jacquier, A. (2014). Arbitrage-free SVI volatility surfaces. doi:10.1080/14697688.2013.819986
- Corbetta, J., Cohort, P., Laachir, I. and Martini, C. (2019). Robust calibration and arbitrage-free interpolation of SSVI slices. arXiv:1804.04924
Last updated on
Estimate the Hurst exponent
Simulate fractional Brownian motion with a known Hurst exponent and read it back with DFA, GPH, wavelet and Higuchi estimators, in Rust and Python.
GPU paths on a free Colab GPU
Build stochastic-rs with CUDA on a free Colab GPU and sample stochastic processes on the device from Python, checking each law against the CPU.