stochastic-rs
Tutorials

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 in stochastic_rs::quant::vol_surface. Python: pip install stochastic-rs.
  • A smile as log-forward moneyness k=ln⁡(K/F)k = \ln(K/F) and total implied variance w=σimp2Tw = \sigma_{\mathrm{imp}}^2 T. The examples build theirs from a model, so the fit has a known right answer.

The parameterisations

Raw SVI writes one slice as

w(k)=a+b(ρ (k−m)+(k−m)2+σ2),w(k) = a + b\Big(\rho\,(k - m) + \sqrt{(k - m)^2 + \sigma^2}\Big),

and SSVI writes the whole surface through the at-the-money total variance θt\theta_t of each maturity:

w(k,θt)=θt2(1+ρ φ(θt) k+(φ(θt) k+ρ)2+1−ρ2),φ(θ)=ηθγ(1+θ)1−γ.w(k, \theta_t) = \frac{\theta_t}{2}\Big(1 + \rho\,\varphi(\theta_t)\,k + \sqrt{\big(\varphi(\theta_t)\,k + \rho\big)^2 + 1 - \rho^2}\Big), \qquad \varphi(\theta) = \frac{\eta}{\theta^{\gamma}(1 + \theta)^{1 - \gamma}}.

A slice admits no butterfly arbitrage when its implied density is non-negative, which is Durrleman's condition g(k)≥0g(k) \ge 0 with

g(k)=(1−k w′(k)2 w(k))2−w′(k)24(1w(k)+14)+w′′(k)2.g(k) = \Big(1 - \frac{k\,w'(k)}{2\,w(k)}\Big)^2 - \frac{w'(k)^2}{4}\Big(\frac{1}{w(k)} + \frac14\Big) + \frac{w''(k)}{2}.

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.

tests/doctest_tutorials_svi_raw.rs
// 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 out

fit_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 (b≥0b \ge 0, ∣ρ∣<1\lvert\rho\rvert < 1, σ>0\sigma > 0 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 g(k)g(k) on a grid of log-moneyness and fails on the first negative value; the Rust example above checks k∈[−1,1]k \in [-1, 1] 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 ψt\psi_t is half the at-the-money slope w′(0)w'(0): negative for a downward skew.

Step 3 — calibrate an SSVI surface

SSVI fits three numbers — ρ\rho, η\eta, γ\gamma — jointly across every maturity, given each slice's at-the-money total variance θt\theta_t. The example generates four slices from known parameters and recovers them.

tests/doctest_tutorials_svi_ssvi.rs
// 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())  # True

satisfies_no_butterfly_condition() checks η(1+∣ρ∣)≤2\eta(1 + \lvert\rho\rvert) \le 2, 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 ρ\rho ties the skew of every maturity together. EssviSurface (extended SSVI) calibrates a (θ,ρ,ψ)(\theta, \rho, \psi) triple per maturity, going forward in maturity inside the butterfly and calendar-spread bounds, and interpolates linearly in (θ,ψ,ρψ)(\theta, \psi, \rho\psi) 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 10−510^{-5} in total variance with a non-negative implied density on [−1,1][-1, 1], 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

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
Edit on GitHub

Last updated on

On this page