BNS Volatility Model¶
This tutorial calibrates the BNS stochastic-volatility model (Barndorff-Nielsen and Shephard) to an implied volatility surface, using the same workflow as the Heston tutorial in Volatility Surface.
BNS is structurally different from Heston. The variance process is a non-Gaussian Ornstein-Uhlenbeck process driven by a pure-jump Lévy process (Gamma-OU in this implementation), and the leverage effect is introduced by correlating the same jumps into the log-price.
Model Parameters¶
BNSCalibration fits five parameters to the surface:
| Parameter | Description |
|---|---|
v0 |
Initial variance (\(v_0\)) |
theta |
Long-run variance (\(\theta = \lambda / \beta\)) |
kappa |
Mean reversion speed of the variance process |
beta |
Exponential decay rate of the BDLP jump-size distribution |
rho |
Leverage parameter (correlation between jumps in variance and log-price) |
The BDLP intensity is set as \(\lambda = \theta \beta\) so that the stationary mean of the Gamma-OU variance process equals \(\theta\). This gives the same \((v_0, \theta)\) parameterisation as Heston.
Because the variance is built from positive jumps and exponential mean reversion, it stays positive by construction. No Feller-style penalty is needed.
How BNS Fits the Surface¶
The mechanism that produces a smile in BNS is structurally different from Heston. Heston relies on a diffusive volatility-of-variance \(\sigma\) for the wings and a spot-variance correlation \(\rho\) for the skew, both accumulating as \(\sqrt{T}\).
BNS instead injects discrete jumps directly into the variance process: each jump in \(v_t\) is mirrored, scaled by \(\rho\), into the log-price. The wing thickness is governed by the jump-size distribution (controlled by \(\beta\)) and the skew by \(\rho\).
A consequence of this structural difference is that the calibrator often settles at a small \(\kappa\) together with a large \(\theta\). The time scale of mean reversion is \(1/\kappa\), so when \(\kappa\) is small the variance process barely relaxes towards \(\theta\) over the calibration horizon and stays close to \(v_0\) throughout.
In that regime \(\theta\) is only weakly identified by the surface and the optimizer can move it freely as long as the jump-driven smile dynamics are preserved. The headline number to read in the output is \(v_0\), which sets the at-the-money level.
Calibration¶
The fit reuses VolModelCalibration two-stage optimiser from the Heston tutorial: L-BFGS-B for basin search, followed by trust-region reflective on the residual vector with parameter bounds.
xtol termination condition is satisfied.
{
"variance_process": {
"rate": 0.12415754170327187,
"kappa": 0.17471505152676173,
"bdlp": {
"intensity": 3.944021972492197,
"jumps": {
"decay": 2.777430034193537
}
}
},
"rho": -0.7019056156146245
}
Calibrated Smile¶
The fit is good for medium and long maturities and visibly off at the front expiries. This is the same short-maturity gap seen for Heston and Heston-jump-diffusion.
The cause here is structural: BNS adds jumps, but they live in the variance process, not directly in the log-price. The jump-driven contribution to the log-price is bounded by the size of the variance jumps multiplied by \(|\rho|\), which is small for short tenors.
A model with explicit jumps in the log-price (such as HestonJ) or a rough volatility model is better suited to the steep short-term skew observed in crypto markets.
Code¶
import json
from docs.examples._utils import assets_path, print_model
from quantflow.options.calibration import BNSCalibration
from quantflow.options.pricer import OptionPricer
from quantflow.options.surface import VolSurface, VolSurfaceInputs, surface_from_inputs
from quantflow.sp.bns import BNS
# Load a saved volatility surface snapshot and build the surface
with open("docs/examples/volsurface.json") as fp:
surface: VolSurface = surface_from_inputs(VolSurfaceInputs(**json.load(fp)))
surface.bs()
surface.disable_outliers()
# Create a BNS pricer with initial parameters
pricer = OptionPricer(
model=BNS.create(vol=0.5, kappa=1.0, decay=10.0, rho=-0.2),
)
calibration: BNSCalibration[BNS] = BNSCalibration(
pricer=pricer,
vol_surface=surface,
moneyness_weight=0.5,
)
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="BNS Calibrated Smiles")
fig.write_image(assets_path("bns_calibrated_smile.png"), width=1200)
