Heston Volatility Model¶
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.11778688773097408,
"kappa": 4.135884228360701,
"sigma": 2.1460566280048927,
"theta": 0.3355100689649244,
"sample_algo": "implicit"
},
"rho": -0.48031213961820185
}
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.dists.distributions1d import DoubleExponential
from quantflow.dists.marginal1d import OptionPricingMethod
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
# 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.1291583357700718,
"kappa": 1.5928328812311718,
"sigma": 1.0828743632304592,
"theta": 0.1925335796866295,
"sample_algo": "implicit"
},
"rho": -0.29856931631871036,
"jumps": {
"intensity": 79.5395260207515,
"jumps": {
"loc": 0.007499627395456639,
"decay": 58.832836449037394,
"kappa": 1.2118070437262596
}
}
}
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) |