Is the implied volatility surface arbitrage-free?#
Topics
SVI · SSVI · Durrleman condition · risk-neutral density · butterfly arbitrage · calendar spread
When we calibrate an implied volatility surface from market quotes, we get a smooth, good-looking surface. But does it make sense mathematically? Concretely: could a trader build a portfolio of vanilla options from this surface and lock in a guaranteed profit at zero cost? If yes, the surface is not arbitrage-free, it is internally inconsistent, regardless of how well it fits the data.
This note works through two classical notions of arbitrage on a vol surface, derives the
conditions for each, and shows how to check them on a surface calibrated with hestonpy.
Setup: total implied variance#
Let \(F_T = S_0 e^{r T}\) denote the forward price at maturity \(T\) and spot \(S_0\), and let \(k = \ln(K / F_T)\) denote the log-moneyness. Instead of working with implied volatility \(\sigma_{imp}(k,T)\) directly, it is more convenient to work with the total implied variance:
This removes the trivial \(\sqrt{T}\) scaling and makes the arbitrage conditions cleaner.
Two types of arbitrage#
Calendar spread arbitrage#
A calendar spread is a position long one call at maturity \(T_2\) and short one call at maturity \(T_1 < T_2\), at the same strike. Under any risk-neutral measure, holding an option to \(T_2\) is worth at least as much as holding it to \(T_1\) (early exercise aside), so the forward calendar spread price must be non-negative.
Translating this into the language of total variance, The total variance must be non-decreasing in maturity at every strike. Intuitively: more time means more uncertainty, so a wider distribution of terminal prices. Thus, one can show that a vol surface is free of calendar spread arbitrage if and only if, for every log-moneyness \(k\):
Butterfly arbitrage and the Durrleman condition#
A butterfly centered at strike \(K_0\) is long calls at \(K_0 - \delta\) and \(K_0 + \delta\) and short two calls at \(K_0\). Its payoff is always non-negative (it’s a tent function), so its price must be non-negative too. A violation implies a negative risk-neutral density, the model assigns negative probability to some region of the spot, which is absurd.
For a single maturity slice \(w(\cdot) = w(\cdot, T)\), define Durrleman’s function:
where primes denote derivatives with respect to \(k\). One can show that the risk-neutral density of the log-moneyness \(k\) satisfies:
Since \(e^{-d_2^2/2} / \sqrt{2\pi w} > 0\) always, the density is non-negative if and only if \(g(k) \geq 0\). Therefore:
Conditions for SVI#
The raw SVI parameterisation (Gatheral, 2004) reads:
hestonpy calibrates this with the constraint \(a + b\sigma\sqrt{1-\rho^2} \geq 0\), which
ensures the minimum total variance is non-negative. But is this sufficient for no
butterfly arbitrage?
No, in general. The constraint is necessary (negative total variance immediately implies \(g < 0\)) but not sufficient. The full butterfly-free characterisation for SVI requires checking \(g(k) \geq 0\) everywhere. However, in practice a well-calibrated SVI slice almost never violates it. The dangerous regime is when the wings are too steep (\(b\) large, \(\sigma\) small).
Necessary conditions for SVI (no butterfly)
\(b \geq 0\)
\(|\rho| < 1\)
\(a + b\sigma\sqrt{1-\rho^2} \geq 0\)
These are enforced automatically by the StochasticVolatilityInspired.calibration() method.
Checking the Durrleman condition numerically#
For an SVI slice, \(g(k)\) can be evaluated analytically by computing \(w'\) and \(w''\):
Note that \(w'' > 0\) always (SVI is convex in \(k\)), which is a good sign. We plug these into \(g(k)\) and check the sign over a grid of log-moneyness values.
import numpy as np
from hestonpy.models.calibration.svi import StochasticVolatilityInspired
# --- Calibrate SVI on a single smile ---
T = 0.5
svi = StochasticVolatilityInspired(time_to_maturity=T)
# (assume strikes, market_ivs, forward are available)
params, model_ivs = svi.calibration(strikes, market_ivs, forward)
a, b, rho, m, sigma = params["a"], params["b"], params["rho"], params["m"], params["sigma"]
# --- Durrleman condition on a fine grid ---
k_grid = np.linspace(-0.5, 0.5, 500)
sqrt_term = np.sqrt((k_grid - m)**2 + sigma**2)
w = a + b * (rho * (k_grid - m) + sqrt_term)
w_p = b * (rho + (k_grid - m) / sqrt_term)
w_pp = b * sigma**2 / sqrt_term**3
g = (1 - k_grid * w_p / (2 * w))**2 \
- (w_p**2 / 4) * (1/w + 0.25) \
+ w_pp / 2
is_butterfly_free = np.all(g >= 0)
print(f"Butterfly-free: {is_butterfly_free}")
print(f"Min g(k) = {g.min():.6f}")
Conditions for SSVI#
The SSVI parameterisation (Gatheral & Jacquier, 2014) writes the total variance surface as:
where \(\theta_T = \sigma_{ATM}(T)^2 \cdot T\) is the ATM total variance and \(\varphi_T \geq 0\) is the wing (curvature) parameter.
The strength of SSVI is that Gatheral & Jacquier derived analytical no-arbitrage conditions:
No-arbitrage theorem for SSVI (Gatheral & Jacquier, 2014)
No butterfly arbitrage iff, for every maturity \(T\):
No calendar spread arbitrage iff, for every pair of maturities \(T_1 < T_2\) and every \(k\):
In practice, since \(\theta_T = \sigma_{ATM}^2\cdot T\) is set directly from market ATM vols, calendar-spread arbitrage reduces to checking that the total ATM variance \(T \mapsto \theta_T\) is non-decreasing — which the market almost always satisfies.
In hestonpy, the calibration of each slice already enforces \(\varphi_T \leq 2/\theta_T\),
which implies \(\theta_T \varphi_T \leq 2\). Combined with \((1 + |\rho|) \leq 2\), this gives
\(\theta_T \varphi_T (1 + |\rho_T|) \leq 4\) — exactly the no-butterfly condition.
Key insight
The bound constraint phi <= 2/theta_T in the calibrate_single_maturity optimisation
is not just a numerical trick — it is a direct encoding of the no-butterfly condition
(up to the \(\rho\) factor, which is bounded by \(2\)).
Joint calibration across the whole surface#
calibrate_single_maturity (and calibrate_surface, which just loops over it) still fits
\(\rho_T\) and \(\varphi_T\) independently for each maturity. Nothing ties the slices
together beyond each \(\theta_T\) being read off the market — so calendar-spread arbitrage is
only avoided if the input data happens to be well-behaved, not by construction.
SurfaceStochasticVolatilityInspired.calibrate_joint (used by
VolatilitySurface.ssvi_smooth) instead fits a single \(\rho\) and a single wing
function shared by every maturity, using Gatheral’s power-law form:
The free parameters become \((\rho, \eta, \gamma)\) instead of \((\rho_T, \varphi_T)\) per maturity. To enforce the Gatheral–Jacquier sufficient condition \(\eta(1+|\rho|) \leq 2\) (together with \(\gamma \in (0, \tfrac12]\)) by construction rather than as a post-hoc check, the optimiser is reparameterised over \((\rho, u, \gamma)\) with \(u \in (0, 1]\) and
which turns the nonlinear constraint into a plain box constraint — trivially satisfiable by any bounded optimiser, whatever \(u\) ends up being.
from hestonpy.models.calibration.volatilitySurface import VolatilitySurface
# surface = VolatilitySurface([smile_T1, smile_T2, smile_T3, ...])
joint_params, calibrated_ivs = surface.ssvi_smooth()
print(joint_params["rho"], joint_params["eta"], joint_params["gamma"])
print("Sufficient no-arbitrage check:",
joint_params["eta"] * (1 + abs(joint_params["rho"])), "<= 2")
\(\theta_T\) is still read directly off each maturity’s ATM implied volatility, not fitted — only the wing shape is shared across \(T\).
Checking analytically with hestonpy#
import numpy as np
from hestonpy.models.calibration.ssvi import SurfaceStochasticVolatilityInspired
maturities = np.array([0.25, 0.5, 1.0, 2.0])
ssvi = SurfaceStochasticVolatilityInspired(maturities=maturities)
# (assume strikes, iv_surface, forwards are available)
params = ssvi.calibrate_surface(strikes, iv_surface, forwards)
print("Maturity | theta | phi | rho | theta*phi*(1+|rho|) | OK?")
print("-" * 70)
for T in maturities:
theta = ssvi.theta[T]
rho = params[T]["rho"]
phi = params[T]["phi"]
cond = theta * phi * (1 + abs(rho))
ok = "✓" if cond <= 4 else "✗ ARBITRAGE"
print(f" T={T:.2f} | {theta:.4f} | {phi:.4f} | {rho:+.4f} | {cond:.4f} | {ok}")
Calendar spread check#
# Check that total ATM variance is non-decreasing in T
thetas = np.array([ssvi.theta[T] for T in maturities])
diffs = np.diff(thetas)
if np.all(diffs >= 0):
print("✓ No calendar spread arbitrage (theta is non-decreasing)")
else:
bad = np.where(diffs < 0)[0]
for i in bad:
print(f"✗ Calendar spread arbitrage between T={maturities[i]:.2f} and T={maturities[i+1]:.2f}")
Discussion#
SVI |
SSVI |
|
|---|---|---|
Butterfly-free |
Check \(g(k) \geq 0\) numerically |
Analytical: \(\theta\varphi(1+\vert\rho\vert)\leq 4\) |
Calendar-spread-free |
Must compare slices |
\(\theta_T\) non-decreasing |
Enforced in |
Necessary conditions only |
✓ via bound on \(\varphi\) |
The practical takeaway: SSVI is strictly safer than slice-by-slice SVI for building a full surface, because its no-arbitrage conditions can be encoded as simple box constraints in the optimiser. SVI is more flexible on a single smile but offers no cross-maturity guarantee.
For a production calibration pipeline, one should:
Use the jointly-calibrated SSVI (
VolatilitySurface.ssvi_smooth, power-law \(\varphi(\theta) = \eta/(\theta^\gamma(1+\theta)^{1-\gamma})\)) for the full surface — see Joint calibration across the whole surface above.svi_smoothremains available for a single, independently-fitted smile.After calibration, run a quick check: compute \(g(k)\) on a fine grid for each slice.
Verify that \(T \mapsto \theta_T\) is non-decreasing.
If any check fails, tighten the optimisation constraints or review the input data for outlier quotes.
Recovering the risk-neutral density (Breeden-Litzenberger)#
A smoothed, arbitrage-free smile is not just an end in itself — it is also what makes it possible to recover the market’s implied probability distribution of the underlying at maturity \(T\). Breeden & Litzenberger (1978) show that the forward risk-neutral density is the second strike-derivative of the call price:
Applying this directly to raw market quotes does not work: strikes are sparse and unevenly spaced, and quotes are noisy, so a numerical second derivative amplifies that noise into something unusable — and, per the discussion above, a curve with \(g(k) < 0\) somewhere would even hand back a negative density there. The fix is the same one used throughout this note: differentiate a smoothed, arbitrage-checked price curve instead of the raw quotes.
hestonpy builds that curve from a fine, uniformly-spaced strike grid (VolatilitySmile.forward_density),
using either a per-slice SVI fit or — preferably, since it is what ties every maturity
together — the surface’s own joint SSVI fit:
# Single maturity: uses raw SVI (its own independent fit) if no ssvi_params given
density = smile.forward_density()
print(density.total_mass, density.has_negative_density)
density.plot()
# Whole surface: uses the joint SSVI fit (see above), one density per maturity
densities = surface.forward_densities()
surface.plot_forward_density_heatmap() # + median / mean / Q1-Q3 overlay
ForwardDensity.total_mass (should be close to 1) and has_negative_density are cheap
sanity checks: a negative density is exactly the strike-space manifestation of the
butterfly arbitrage discussed above (\(g(k) < 0\)), so seeing has_negative_density = True
means the smoothing itself introduced an arbitrage the underlying SVI/SSVI fit should not
have allowed.
References#
Gatheral, J. (2004). A parsimonious arbitrage-free implied volatility parameterization with application to the valuation of volatility derivatives. Presentation at Global Derivatives & Risk Management, Madrid.
Gatheral, J. & Jacquier, A. (2014). Arbitrage-free SVI volatility surfaces. Quantitative Finance, 14(1), 59–71.
Durrleman, V. (2005). From implied to spot volatilities. PhD thesis, Princeton University.
Lee, R. (2004). The moment formula for implied volatility at extreme strikes. Mathematical Finance, 14(3), 469–480.