Volatility Surface

Volatility Surface#

In this notebook we illustrate the use of the Volatility Surface tool in the library. We use deribit options on ETHUSD as example.

First thing, fetch the data

from quantflow.data.deribit import Deribit

async with Deribit() as cli:
    loader = await cli.volatility_surface_loader("eth", exclude_open_interest=0)

Once we have loaded the data, we create the surface and display the term-structure of forwards

vs = loader.surface()
vs.maturities = vs.maturities
vs.term_structure()
maturity ttm forward basis rate_percent open_interest volume
0 2025-12-05 08:00:00+00:00 0.015316 2979.375 0.550 1.34772 6016767 1711136.0
1 2025-12-12 08:00:00+00:00 0.034494 2981.375 2.550 2.60268 1814745 1215999.0
2 2025-12-26 08:00:00+00:00 0.072850 2983.625 4.800 2.26030 163449862 5975739.0
3 2026-01-30 08:00:00+00:00 0.168741 2995.75 16.925 3.39013 1177995 1315491.0
4 2026-03-27 08:00:00+00:00 0.322165 3015.875 37.050 3.85623 69416278 3610267.0
5 2026-06-26 08:00:00+00:00 0.571480 3050.875 72.050 4.19390 42558883 1338539.0
6 2026-09-25 08:00:00+00:00 0.820795 3088.25 109.425 4.40388 20276984 2556400.0
vs.spot
SpotPrice(security=<VolSecurityType.spot: 'spot'>, bid=Decimal('2978.80'), ask=Decimal('2978.85'), open_interest=Decimal('366436913'), volume=Decimal('82540521.0'))

bs method#

This method calculate the implied Black volatility from option prices. By default it uses the best option in the surface for the calculation.

The options_df method allows to inspect bid/ask for call options at a given cross section. Prices of options are normalized by the Forward price, in other words they are given as base currency price, in this case BTC.

Moneyness is defined as

(69)#\[\begin{equation} k = \log{\frac{K}{F}} \end{equation}\]
vs.bs()
df = vs.disable_outliers(0.95).options_df()
df
strike forward maturity moneyness moneyness_ttm ttm implied_vol price price_bp forward_price type side open_interest volume
0 2600.0 2979.375 2025-12-05 08:00:00+00:00 -0.136202 -1.100559 0.015316 0.754190 0.0028 28.0 8.342250 put bid 2777.0 506.99
1 2600.0 2979.375 2025-12-05 08:00:00+00:00 -0.136202 -1.100559 0.015316 0.778540 0.0032 32.0 9.534000 put ask 2777.0 506.99
2 2650.0 2979.375 2025-12-05 08:00:00+00:00 -0.117154 -0.946643 0.015316 0.729847 0.0039 39.0 11.619563 put bid 8114.0 480.05
3 2650.0 2979.375 2025-12-05 08:00:00+00:00 -0.117154 -0.946643 0.015316 0.754107 0.0044 44.0 13.109250 put ask 8114.0 480.05
4 2700.0 2979.375 2025-12-05 08:00:00+00:00 -0.098462 -0.795604 0.015316 0.709733 0.0055 55.0 16.386563 put bid 2538.0 879.12
... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
395 9000.0 3088.250 2026-09-25 08:00:00+00:00 1.069620 1.180625 0.820795 0.784009 0.0335 335.0 103.456375 call ask 1132.0 0.00
396 10000.0 3088.250 2026-09-25 08:00:00+00:00 1.174981 1.296920 0.820795 0.778063 0.0240 240.0 74.118000 call bid 1169.0 0.00
397 10000.0 3088.250 2026-09-25 08:00:00+00:00 1.174981 1.296920 0.820795 0.794038 0.0265 265.0 81.838625 call ask 1169.0 0.00
398 11000.0 3088.250 2026-09-25 08:00:00+00:00 1.270291 1.402122 0.820795 0.785708 0.0190 190.0 58.676750 call bid 1787.0 0.00
399 12000.0 3088.250 2026-09-25 08:00:00+00:00 1.357302 1.498163 0.820795 0.794643 0.0155 155.0 47.867875 call bid 901.0 0.00

400 rows × 14 columns

The plot function is enabled only if plotly is installed

from plotly.subplots import make_subplots

# consider 6 expiries
vs6 = vs.trim(6)

titles = []
for row in range(2):
    for col in range(3):
        index = row * 3 + col
        titles.append(f"Expiry {vs6.maturities[index].maturity}")
fig = make_subplots(rows=2, cols=3, subplot_titles=titles).update_layout(height=600, title="ETH Volatility Surface")
for row in range(2):
    for col in range(3):
        index = row * 3 + col
        vs6.plot(index=index, fig=fig, showlegend=False, fig_params=dict(row=row+1, col=col+1))
fig

The moneyness_ttm is defined as

(70)#\[\begin{equation} \frac{1}{\sqrt{T}} \ln{\frac{K}{F}} \end{equation}\]

where \(T\) is the time-to-maturity.

vs6.plot3d().update_layout(height=800, title="ETH Volatility Surface", scene_camera=dict(eye=dict(x=1, y=-2, z=1)))

Model Calibration#

We can now use the Vol Surface to calibrate the Heston stochastic volatility model.

from quantflow.options.calibration import HestonJCalibration, OptionPricer
from quantflow.utils.distributions import DoubleExponential
from quantflow.sp.heston import HestonJ

model = HestonJ.create(DoubleExponential, vol=0.8, sigma=1.5, kappa=0.5, rho=0.1, jump_intensity=50, jump_fraction=0.3)
pricer = OptionPricer(model=model)
cal = HestonJCalibration(pricer=pricer, vol_surface=vs6, moneyness_weight=-0)
len(cal.options)
198
cal.model.model_dump()
{'variance_process': {'rate': 0.44800000000000006,
  'kappa': 0.5,
  'sigma': 1.5,
  'theta': 0.44800000000000006,
  'sample_algo': <SamplingAlgorithm.implicit: 'implicit'>},
 'rho': 0.1,
 'jumps': {'intensity': 50.0,
  'jumps': {'decay': 22.821773229381918, 'loc': 0.0, 'kappa': 1.0}}}
cal.fit()
  message: CONVERGENCE: RELATIVE REDUCTION OF F <= FACTR*EPSMCH
  success: True
   status: 0
      fun: 0.31683665511816295
        x: [ 4.480e-01  4.480e-01  5.000e-01  1.500e+00  0.000e+00
             5.000e+01  3.840e-03  0.000e+00]
      nit: 1
      jac: [-2.522e+04 -2.523e+04 -2.525e+04 -2.516e+04  2.531e+04
            -2.523e+04 -2.532e+04 -2.523e+04]
     nfev: 27
     njev: 3
 hess_inv: <8x8 LbfgsInvHessProduct with dtype=float64>
pricer.model
HestonJ(variance_process=CIR(rate=np.float64(0.44800000000000006), kappa=np.float64(0.5), sigma=np.float64(1.5), theta=np.float64(0.44800000000000006), sample_algo=<SamplingAlgorithm.implicit: 'implicit'>), rho=np.float64(0.0), jumps=CompoundPoissonProcess(intensity=np.float64(50.0), jumps=DoubleExponential(decay=np.float64(22.821773229381918), loc=0.0, kappa=np.float64(1.00000001))))
cal.plot(index=5, max_moneyness_ttm=1)

#

Serialization

It is possible to save the vol surface into a json file so it can be recreated for testing or for serialization/deserialization.

with open("../../quantflow_tests/volsurface.json", "w") as fp:
    fp.write(vs.inputs().model_dump_json(indent=2))
from quantflow.options.surface import VolSurfaceInputs, surface_from_inputs
import json

with  open("../../quantflow_tests/volsurface.json", "r") as fp:
    inputs = VolSurfaceInputs(**json.load(fp))

vs2 = surface_from_inputs(inputs)