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