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-08-15 08:00:00+00:00 0.015613 4216.375 4.500 7.79521 23943157 16572003.0
1 2025-08-22 08:00:00+00:00 0.034791 4222.625 10.750 7.75338 4600183 4693834.0
2 2025-08-29 08:00:00+00:00 0.053969 4227.00 15.125 6.88621 92735080 6439352.0
3 2025-09-26 08:00:00+00:00 0.130681 4255.375 43.500 7.97950 137096617 15935955.0
4 2025-12-26 08:00:00+00:00 0.379996 4325.375 113.500 7.03311 94644710 7837520.0
5 2026-03-27 08:00:00+00:00 0.629312 4390.75 178.875 6.62931 38526129 2542786.0
6 2026-06-26 08:00:00+00:00 0.878627 4449.75 237.875 6.26659 36361771 4310812.0
vs.spot
SpotPrice(security=<VolSecurityType.spot: 'spot'>, bid=Decimal('4211.85'), ask=Decimal('4211.90'), open_interest=Decimal('495125030'), volume=Decimal('370690765.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 3700.0 4216.375 2025-08-15 08:00:00+00:00 -0.130643 -1.045547 0.015613 0.784784 0.0039 39.0 16.443863 put bid 2605.0 16371.93
1 3700.0 4216.375 2025-08-15 08:00:00+00:00 -0.130643 -1.045547 0.015613 0.789961 0.0040 40.0 16.865500 put ask 2605.0 16371.93
2 3750.0 4216.375 2025-08-15 08:00:00+00:00 -0.117220 -0.938122 0.015613 0.751871 0.0045 45.0 18.973688 put bid 2072.0 13155.12
3 3750.0 4216.375 2025-08-15 08:00:00+00:00 -0.117220 -0.938122 0.015613 0.770084 0.0049 49.0 20.660238 put ask 2072.0 13155.12
4 3800.0 4216.375 2025-08-15 08:00:00+00:00 -0.103975 -0.832119 0.015613 0.730630 0.0055 55.0 23.190062 put bid 6035.0 71637.78
... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
392 11000.0 4449.750 2026-06-26 08:00:00+00:00 0.905047 0.965537 0.878627 0.728651 0.0445 445.0 198.013875 call ask 134.0 0.00
393 12000.0 4449.750 2026-06-26 08:00:00+00:00 0.992059 1.058364 0.878627 0.710322 0.0315 315.0 140.167125 call bid 121.0 397.90
394 12000.0 4449.750 2026-06-26 08:00:00+00:00 0.992059 1.058364 0.878627 0.735507 0.0365 365.0 162.415875 call ask 121.0 397.90
395 13000.0 4449.750 2026-06-26 08:00:00+00:00 1.072101 1.143757 0.878627 0.720501 0.0265 265.0 117.918375 call bid 6.0 645.56
396 13000.0 4449.750 2026-06-26 08:00:00+00:00 1.072101 1.143757 0.878627 0.743018 0.0305 305.0 135.717375 call ask 6.0 645.56

397 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.004951537332975305
        x: [ 4.396e-01  4.420e-01  1.488e+00  1.502e+00 -1.998e-02
             5.000e+01  1.051e-05  5.659e-03]
      nit: 10
      jac: [-2.550e-02 -8.638e-03 -3.392e-03  9.051e-03  1.896e-02
             5.543e-07 -2.702e+00 -3.554e-07]
     nfev: 720
     njev: 80
 hess_inv: <8x8 LbfgsInvHessProduct with dtype=float64>
pricer.model
HestonJ(variance_process=CIR(rate=np.float64(0.43963263603729064), kappa=np.float64(1.4882524777276394), sigma=np.float64(1.5017116246760047), theta=np.float64(0.4419759420612869), sample_algo=<SamplingAlgorithm.implicit: 'implicit'>), rho=np.float64(-0.019984453337970883), jumps=CompoundPoissonProcess(intensity=np.float64(50.00190342704047), jumps=DoubleExponential(decay=np.float64(436.3374213080039), loc=0.0, kappa=np.float64(1.0056746843804971))))
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)