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:
Then run bs() to populate implied volatilities via Black-Scholes inversion:
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.
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:
- L-BFGS-B minimises the scalar cost function (sum of squared weighted price residuals) to reach a good basin of attraction.
- Trust-region reflective (
least_squareswithmethod="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.

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:
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)

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) |