Skip to content

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

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