# Fit an SVI volatility surface

URL: https://stochastic.rust-dd.com/docs/tutorials/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)$ and total implied variance
  $w = \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\Big(\rho\,(k - m) + \sqrt{(k - m)^2 + \sigma^2}\Big),
$$

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

$$
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) \ge 0$ with

$$
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.

```rust title="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);
}
```

```python
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 \ge 0$, $\lvert\rho\rvert < 1$,
$\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)$ on a grid
of log-moneyness and fails on the first negative value; the Rust example above
checks $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 $\psi_t$ is half the at-the-money slope $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 $\theta_t$. The
example generates four slices from known parameters and recovers them.

```rust title="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
}
```

```python
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 $\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^{-5}$ in total variance with
a non-negative implied density on $[-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

* [eSSVI slices with arbitrage-free interpolation](/docs/quant#essvi-slices-with-arbitrage-free-interpolation) on the quant page
* [Heston: simulate, price and calibrate](/docs/tutorials/heston) — the model whose smile step 1 fits
* [Choosing a calibrator](/docs/quant#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
