Skip to content

Volatility Surface

This tutorial covers the full workflow for building and calibrating an implied volatility surface: fetching option quotes from Deribit, inspecting the surface inputs, and calibrating the Heston and Heston-jump-diffusion models.

Fetching Data from Deribit

The Deribit client exposes a high-level volatility_surface_loader method that fetches all option quotes for a given asset and assembles them into a VolSurfaceLoader:

import asyncio
from quantflow.data.deribit import Deribit

async def load():
    async with Deribit() as cli:
        loader = await cli.volatility_surface_loader("btc")
    return loader

loader = asyncio.run(load())

Key parameters of volatility_surface_loader:

Parameter Default Description
asset required Underlying asset, e.g. "btc", "eth", "sol"
inverse True Inverse options (settled in the underlying)
use_perp False Derive spot from the perpetual contract
exclude_open_interest 0 Drop strikes with open interest below this threshold

Building the Surface

The loader holds the raw market data. Call surface() to construct a VolSurface:

surface = loader.surface()

Then run bs() to populate implied volatilities via Black-Scholes inversion:

surface.bs()

bs() solves for the implied volatility that matches each bid and ask price and marks each option as converged or not.

Removing Outliers

Raw option quotes often contain illiquid or stale prices that produce unrealistic implied volatilities. disable_outliers() removes them in two passes per maturity.

surface.disable_outliers()

Inspecting Surface Inputs

The examples below use a saved snapshot of a real ETH surface. The workflow is identical for a live surface fetched from Deribit.

import json

import pandas as pd

from docs.examples._utils import FIXTURES
from quantflow.options.inputs import OptionInput
from quantflow.options.surface import VolSurface, VolSurfaceInputs, surface_from_inputs

# Load a saved volatility surface snapshot from JSON
with open(FIXTURES / "volsurface_btc.json") as fp:
    surface_inputs = VolSurfaceInputs(**json.load(fp))

# Build the VolSurface from the inputs and calculate implied volatilities
surface: VolSurface = surface_from_inputs(surface_inputs)
surface.bs()
surface.disable_outliers()

# Print the term structure (forward prices and implied rates per maturity)
print(surface.term_structure().to_string(index=False))

# Display the surface inputs for converged options only
inputs = surface.inputs(converged=True)
option_inputs = [i for i in inputs.inputs if isinstance(i, OptionInput)]
df = pd.DataFrame([i.model_dump() for i in option_inputs])
print("\n\n10 Converged option inputs")
print(
    df[["maturity", "strike", "option_type", "bid", "ask", "iv_bid", "iv_ask"]]
    .head(10)
    .to_string(index=False)
)

term_structure() shows forward prices and the interest rate implied by the forward-spot basis for each maturity. The option inputs table lists the bid/ask prices together with the corresponding implied volatilities for each strike:

                 maturity      ttm  forward bid_ask_spread   basis rate_percent fwd_spread_pct open_interest  volume
2026-04-28 08:00:00+00:00 0.002635 77767.75          159.5   -8.00     -3.90357         0.2051             0       0
2026-04-29 08:00:00+00:00 0.005375  77757.5            115  -18.25     -4.36617         0.1479             0       0
2026-04-30 08:00:00+00:00 0.008115  77765.5          112.0  -10.25     -1.62420         0.1440             0       0
2026-05-01 08:00:00+00:00 0.010854 77731.25            2.5  -44.50     -5.27275         0.0032      30198620 5038760
2026-05-08 08:00:00+00:00 0.030032 77756.25            2.5  -19.50     -0.83494         0.0032       2718240 2864240
2026-05-15 08:00:00+00:00 0.049210 77781.75          160.5    6.00      0.15676         0.2063             0       0
2026-05-29 08:00:00+00:00 0.087567 77813.75            2.5   38.00      0.55782         0.0032      67350560 9387130
2026-06-26 08:00:00+00:00 0.164279 77921.25            2.5  145.50      1.13771         0.0032     588089150 6932390
2026-07-31 08:00:00+00:00 0.260169 78098.75          173.5  323.00      1.59295         0.2222             0       0
2026-09-25 08:00:00+00:00 0.413594 78408.75            2.5  633.00      1.95985         0.0032     276427820 7071210
2026-12-25 08:00:00+00:00 0.662909  79087.5           10.0 1311.75      2.52299         0.0126     121192440 2532540
2027-03-26 08:00:00+00:00 0.912224 79801.25            2.5 2025.50      2.81833         0.0031      15964110 3373920


10 Converged option inputs
                 maturity strike option_type    bid    ask    iv_bid    iv_ask
2026-04-28 08:00:00+00:00  73000         put 0.0001 0.0002 0.5375622 0.5919214
2026-04-28 08:00:00+00:00  75000         put 0.0004 0.0006 0.4188391 0.4554825
2026-04-28 08:00:00+00:00  75500         put 0.0006 0.0008 0.3889021 0.4165291
2026-04-28 08:00:00+00:00  76000         put  0.001 0.0012 0.3668268 0.3868547
2026-04-28 08:00:00+00:00  76500         put 0.0016 0.0019 0.3392663 0.3616924
2026-04-28 08:00:00+00:00  77000         put 0.0028 0.0032 0.3235727 0.3467574
2026-04-28 08:00:00+00:00  77500         put 0.0048 0.0055 0.3117853 0.3467488
2026-04-28 08:00:00+00:00  78000        call  0.005 0.0055 0.3111888 0.3359649
2026-04-28 08:00:00+00:00  78500        call 0.0032 0.0035  0.335916 0.3526992
2026-04-28 08:00:00+00:00  79000        call 0.0017 0.0021 0.3353014 0.3637628

Serialising and Restoring

inputs() serialises the surface to a VolSurfaceInputs object — a list of SpotInput, ForwardInput, and OptionInput records — that can be stored or transmitted as JSON and later reconstructed via surface_from_inputs:

from quantflow.options.surface import surface_from_inputs

inputs = surface.inputs(converged=True)   # VolSurface -> VolSurfaceInputs
surface2 = surface_from_inputs(inputs)    # VolSurfaceInputs -> VolSurface

Calibrating the Heston Model

HestonCalibration fits the five Heston parameters (\(v_0\), \(\theta\), \(\kappa\), \(\sigma\), \(\rho\)) to the implied volatility surface using a two-stage optimisation:

  1. L-BFGS-B minimises the scalar cost function (sum of squared weighted price residuals) to reach a good basin of attraction.
  2. Trust-region reflective (least_squares with method="trf") refines the solution on the residual vector with tight tolerances and enforces parameter bounds.

Residuals are computed as weight * (model_call_price - mid_call_price) where mid_call_price is the average of the bid and ask call prices.

The weight is \(\min(e^{w \cdot m^2}, w_\text{max})\) controlled by moneyness_weight (the coefficient \(w\)) and max_cost_weight (the cap \(w_\text{max}\)), with \(m = \log(K/F)/\sqrt{T}\) the standardised moneyness. The quadratic exponent matches the gaussian shape of \(1/\nu\) (inverse vega), so a positive moneyness_weight puts wing residuals on the same footing as ATM ones. The cap prevents a single deep-wing option from dominating the loss.

A penalty for violating the Feller condition (\(2\kappa\theta \geq \sigma^2\)) is added during stage 1 to keep the variance process well-behaved.

import json

from docs.examples._utils import FIXTURES, assets_path, print_model
from quantflow.options.calibration import HestonCalibration
from quantflow.options.pricer import OptionPricer, OptionPricingMethod
from quantflow.options.surface import VolSurfaceInputs, surface_from_inputs
from quantflow.sp.heston import Heston

# Load a saved volatility surface snapshot and build the surface
with open(FIXTURES / "volsurface_btc.json") as fp:
    surface = surface_from_inputs(VolSurfaceInputs(**json.load(fp)))

surface.bs()
surface.disable_outliers()

# Create a Heston pricer with initial parameters
pricer = OptionPricer(
    model=Heston.create(vol=0.5, kappa=1, sigma=0.8, rho=0),
    method=OptionPricingMethod.COS,
)

# Set up the calibration, dropping the first (very short) maturity
calibration: HestonCalibration[Heston] = HestonCalibration(
    pricer=pricer,
    vol_surface=surface,
    moneyness_weight=0.3,
)

result = calibration.fit()
print(result.message)
print_model(calibration.model)

# Plot the calibrated smile for all maturities and save as PNG
fig = calibration.plot_maturities(max_moneyness=1.5, support=101)
fig.update_layout(title="Heston Calibrated Smiles")
fig.write_image(assets_path("heston_calibrated_smile.png"), width=1200)

Output

ftol termination condition is satisfied.

{
  "variance_process": {
    "rate": 0.14729321220667665,
    "kappa": 3.2556665583533113,
    "sigma": 1.7136147475328747,
    "theta": 0.3338421583604363,
    "sample_algo": "implicit"
  },
  "rho": -0.46579733707611387
}

Calibration Options

The moneyness_weight parameter up-weights far-from-the-money options via \(e^{w \cdot m^2}\) where \(m = \log(K/F)/\sqrt{T}\) is the standardised moneyness. The result is capped at max_cost_weight (default 10) so a single deep-wing option cannot dominate the loss.

Plotting the Calibrated Smile

Use plot_maturities() to produce a Plotly figure overlaying market bid/ask implied vols against the model smile for all maturities at once:

fig = calibration.plot_maturities(max_moneyness_ttm=1.5, support=101)
fig.write_image("heston_calibrated_smile.png", width=1200)

The x axis is moneyness.

Heston calibrated smile

Model Limitations at Short Maturities

Inspecting the calibrated smiles across all maturities reveals a systematic pattern: the Heston model fits long-dated options reasonably well but struggles with short-term maturities, where the market smile is steeper than the model can reproduce.

This is a fundamental structural limitation, not a numerical issue. The Heston model generates an implied volatility smile through two mechanisms: the correlation \(\rho\) between spot and variance (which creates skew) and the volatility-of-variance \(\sigma\) (which inflates the wings). Both effects accumulate diffusively over time. For a maturity \(T\), the smile roughly scales as \(\sigma \sqrt{T}\), so as \(T \to 0\) the distribution collapses toward a Gaussian and the smile flattens.

More precisely, the Heston characteristic function at short maturities satisfies:

\[\begin{equation} \log \phi(u, T) \approx i u \mu T - \tfrac{1}{2} u^2 v_0 T + O(T^2) \end{equation}\]

which is the characteristic function of a Gaussian with variance \(v_0 T\). The higher cumulants that produce skew and excess kurtosis are all \(O(T^2)\) or smaller, so they vanish faster than the Gaussian term as \(T \to 0\).

In practice this means the Heston model essentially reduces to Black-Scholes for near-expiry options. The market, however, exhibits pronounced short-term skew driven by jump risk and the market microstructure of short-dated hedging demand. A diffusion-only model cannot reproduce this behaviour regardless of how its parameters are tuned.

The natural extension is to add a jump component to the dynamics, which contributes a term of order \(O(T)\) to the cumulants and restores the short-term smile. This is the motivation for the Heston jump-diffusion model described in the next section.

Calibrating the Heston Jump-Diffusion Model

HestonJCalibration extends the Heston calibration with a compound Poisson jump component via the HestonJ model. Jumps are drawn from a DoubleExponential distribution, which captures asymmetric jump behaviour common in equity and crypto markets.

import json

from docs.examples._utils import FIXTURES, assets_path, print_model
from quantflow.options.calibration import HestonJCalibration
from quantflow.options.calibration.base import ResidualKind
from quantflow.options.pricer import OptionPricer
from quantflow.options.surface import VolSurface, VolSurfaceInputs, surface_from_inputs
from quantflow.sp.heston import HestonJ
from quantflow.utils.distributions import DoubleExponential
from quantflow.utils.marginal import OptionPricingMethod

# Load a saved volatility surface snapshot and build the surface
with open(FIXTURES / "volsurface_btc.json") as fp:
    surface: VolSurface = surface_from_inputs(VolSurfaceInputs(**json.load(fp)))

surface.bs()
surface.disable_outliers()

# Create a HestonJ pricer with initial parameters
pricer = OptionPricer(
    model=HestonJ.create(
        DoubleExponential,
        vol=0.5,
        kappa=2,
        rho=-0.2,
        sigma=0.8,
        jump_fraction=0.3,
        jump_asymmetry=0.2,
    ),
    method=OptionPricingMethod.COS,
)

# Set up the calibration, dropping the first (very short) maturity
calibration: HestonJCalibration[DoubleExponential] = HestonJCalibration(
    pricer=pricer,
    vol_surface=surface,
    residual_kind=ResidualKind.IV,
)

result = calibration.fit()
print(result.message)
print_model(calibration.model)

# Plot the calibrated smile for all maturities and save as PNG
fig = calibration.plot_maturities(max_moneyness=1.5, support=101)
fig.update_layout(title="HestonJ Calibrated Smiles")
fig.write_image(assets_path("hestonj_calibrated_smile.png"), width=1200)

xtol termination condition is satisfied.

{
  "variance_process": {
    "rate": 0.07073756375658709,
    "kappa": 1.991876104615829,
    "sigma": 0.9805742897490097,
    "theta": 0.2037139576347145,
    "sample_algo": "implicit"
  },
  "rho": -0.27968900795382623,
  "jumps": {
    "intensity": 99.97626995613065,
    "jumps": {
      "decay": 53.60576198456865,
      "loc": 0.007499627395456639,
      "kappa": 1.3072951424471793
    }
  }
}

Plotting the Calibrated Smile

fig = calibration.plot_maturities(max_moneyness_ttm=1.5, support=101)
fig.write_image("hestonj_calibrated_smile.png", width=1200)

HestonJ calibrated smile

Remaining Limitations at Short Maturities

Adding jumps improves the short-term smile significantly compared to plain Heston, but the fit at the nearest maturities is still imperfect. Several structural reasons combine:

Jump parameters are global. The compound Poisson component has a single intensity \(\lambda\), jump variance, and asymmetry shared across all maturities. Increasing \(\lambda\) to steepen the short-term smile simultaneously distorts the long-term smile, so the optimizer settles on a compromise.

Long maturities dominate the cost function. They have more liquid strikes and therefore more data points. The optimizer minimizes total squared residuals across the whole surface, so short maturities — with fewer strikes — are outvoted and their fit is systematically sacrificed.

The jump distribution is not rich enough. The short-term smile in crypto is driven by large, rare, asymmetric events. A DoubleExponential with fixed parameters cannot simultaneously match the wing curvature at short and long maturities.

The natural next step is a rough volatility model (for example rough Heston with Hurst parameter \(H < \tfrac{1}{2}\)). Because the variance process has long memory and does not behave diffusively at short time scales, rough models produce a steep short-term skew without requiring jumps, and the skew decays as a power law \(T^H\) rather than the \(T^{1/2}\) rate of classical stochastic volatility.

Parameter Reference

The calibrated parameter vector for the jump-diffusion model is:

Parameter Description
vol Initial volatility (\(\sqrt{v_0}\))
theta Long-run volatility (\(\sqrt{\theta}\))
kappa Mean reversion speed
sigma Volatility of variance
rho Spot-variance correlation
jump intensity Jump arrival rate (jumps per year)
jump variance Variance of a single jump
jump asymmetry Asymmetry of the jump distribution (DoubleExponential)