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

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-04-18 08:00:00+00:00 0.011038 1619.00 0.025 0.14091 4560487 4104694
1 2025-04-25 08:00:00+00:00 0.030216 1619.50 0.525 1.07584 35643340 3339031
2 2025-05-30 08:00:00+00:00 0.126107 1624.25 5.275 2.58114 16527555 2141541
3 2025-06-27 08:00:00+00:00 0.202819 1631.125 12.150 3.68785 90530477 2992480
4 2025-09-26 08:00:00+00:00 0.452134 1652.25 33.275 4.50051 35821191 3213580
5 2025-12-26 08:00:00+00:00 0.701449 1677.75 58.775 5.08439 33421965 1427545
6 2026-03-27 08:00:00+00:00 0.950764 1700.625 81.650 5.17549 10581435 1847662
vs.spot
SpotPrice(security=<VolSecurityType.spot: 'spot'>, bid=Decimal('1618.95'), ask=Decimal('1619.00'), open_interest=339260161, volume=196231840)

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 moneyness moneyness_ttm ttm implied_vol price price_bp forward_price type side
0 1550.0 1619.000 -0.043554 -0.414550 0.011038 0.833239 0.0170 170.0 27.523000 put bid
1 1600.0 1619.000 -0.011805 -0.112362 0.011038 0.805222 0.0280 280.0 45.332000 put bid
2 1600.0 1619.000 -0.011805 -0.112362 0.011038 0.829472 0.0290 290.0 46.951000 put ask
3 1650.0 1619.000 0.018967 0.180527 0.011038 0.796945 0.0250 250.0 40.475000 call bid
4 1650.0 1619.000 0.018967 0.180527 0.011038 0.821196 0.0260 260.0 42.094000 call ask
... ... ... ... ... ... ... ... ... ... ... ...
226 4000.0 1700.625 0.855299 0.877165 0.950764 0.744522 0.0625 625.0 106.289062 call ask
227 5000.0 1700.625 1.078442 1.106013 0.950764 0.754264 0.0380 380.0 64.623750 call bid
228 5000.0 1700.625 1.078442 1.106013 0.950764 0.770372 0.0415 415.0 70.575937 call ask
229 6000.0 1700.625 1.260764 1.292996 0.950764 0.774567 0.0265 265.0 45.066563 call bid
230 6000.0 1700.625 1.260764 1.292996 0.950764 0.791822 0.0295 295.0 50.168438 call ask

231 rows × 11 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)
119
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.00029894311944297816
        x: [ 3.856e-01  4.006e-01  1.386e+00  1.342e+00 -1.995e-01
             4.998e+01  2.923e-03 -2.184e-02]
      nit: 22
      jac: [-2.437e-03  1.664e-03  5.988e-05 -3.197e-04  7.026e-04
            -1.007e-05  2.806e-04  2.227e-05]
     nfev: 423
     njev: 47
 hess_inv: <8x8 LbfgsInvHessProduct with dtype=float64>
pricer.model
HestonJ(variance_process=CIR(rate=np.float64(0.3856123161402622), kappa=np.float64(1.385863318205795), sigma=np.float64(1.3417233808052953), theta=np.float64(0.4006073850672286), sample_algo=<SamplingAlgorithm.implicit: 'implicit'>), rho=np.float64(-0.19948538479422845), jumps=CompoundPoissonProcess(intensity=np.float64(49.97560299364907), jumps=DoubleExponential(decay=np.float64(26.16983244377366), loc=0.0, kappa=np.float64(0.9783919943986888))))
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("../tests/volsurface.json", "w") as fp:
    fp.write(vs.inputs().model_dump_json())
---------------------------------------------------------------------------
FileNotFoundError                         Traceback (most recent call last)
Cell In[12], line 1
----> 1 with open("../tests/volsurface.json", "w") as fp:
      2     fp.write(vs.inputs().model_dump_json())

File ~/.cache/pypoetry/virtualenvs/quantflow-lUXkzsy2-py3.12/lib/python3.12/site-packages/IPython/core/interactiveshell.py:325, in _modified_open(file, *args, **kwargs)
    318 if file in {0, 1, 2}:
    319     raise ValueError(
    320         f"IPython won't let you open fd={file} by default "
    321         "as it is likely to crash IPython. If you know what you are doing, "
    322         "you can use builtins' open."
    323     )
--> 325 return io_open(file, *args, **kwargs)

FileNotFoundError: [Errno 2] No such file or directory: '../tests/volsurface.json'
from quantflow.options.surface import VolSurfaceInputs, surface_from_inputs
import json

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

vs2 = surface_from_inputs(inputs)