Volatility Surface

Volatility Surface#

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

First thing, fetch the data

from quantflow.data.client import HttpClient

deribit_url = "https://test.deribit.com/api/v2/public/get_book_summary_by_currency"
async with HttpClient() as cli:
    futures = await cli.get(deribit_url, params=dict(currency="BTC", kind="future"))
    options = await cli.get(deribit_url, params=dict(currency="BTC", kind="option"))
from decimal import Decimal
from quantflow.options.surface import VolSurfaceLoader, VolSecurityType
from datetime import timezone
from dateutil.parser import parse

def parse_maturity(v: str):
    return parse(v).replace(tzinfo=timezone.utc, hour=8)
    
loader = VolSurfaceLoader()

for future in futures["result"]:
    if (bid := future["bid_price"]) and (ask := future["ask_price"]):
        maturity = future["instrument_name"].split("-")[-1]
        if maturity == "PERPETUAL":
            loader.add_spot(VolSecurityType.spot, bid=Decimal(bid), ask=Decimal(ask))
        else:
            loader.add_forward(
                VolSecurityType.forward,
                maturity=parse_maturity(maturity),
                bid=Decimal(str(bid)),
                ask=Decimal(str(ask))
            )

for option in options["result"]:
    if (bid := option["bid_price"]) and (ask := option["ask_price"]):
        _, maturity, strike, ot = option["instrument_name"].split("-")
        loader.add_option(
            VolSecurityType.option,
            strike=Decimal(strike),
            maturity=parse_maturity(maturity),
            call=ot == "C",
            bid=Decimal(str(bid)),
            ask=Decimal(str(ask))
        )
    

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

vs = loader.surface()
vs.maturities = vs.maturities[1:]
vs.term_structure()
maturity ttm forward basis rate_percent
0 2024-11-08 08:00:00+00:00 0.032717 69576.25 2441.00 118.50565
1 2024-11-29 08:00:00+00:00 0.090251 72986.25 5851.00 95.31271
2 2024-12-27 08:00:00+00:00 0.166963 76877.5 9742.25 82.43160
3 2025-01-31 08:00:00+00:00 0.262854 79513.75 12378.50 65.01638
4 2025-03-28 08:00:00+00:00 0.416278 82742.5 15607.25 50.52570
5 2025-06-27 08:00:00+00:00 0.665593 88620.0 21484.75 41.87671
6 2025-09-26 08:00:00+00:00 0.914908 93633.75 26498.50 36.46511

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

(65)#\[\begin{equation} k = \log{\frac{K}{F}} \end{equation}\]
vs.bs()
df = vs.disable_outliers(0.95).options_df()
df
/home/runner/work/quantflow/quantflow/quantflow/options/bs.py:32: RuntimeWarning: overflow encountered in multiply
  sig2 = sigma * sigma * ttm
/home/runner/work/quantflow/quantflow/quantflow/options/bs.py:34: RuntimeWarning: invalid value encountered in divide
  d1 = (-k + 0.5 * sig2) / sig
/home/runner/work/quantflow/quantflow/quantflow/options/bs.py:45: RuntimeWarning: overflow encountered in multiply
  sig2 = sigma * sigma * ttm
/home/runner/work/quantflow/quantflow/quantflow/options/bs.py:47: RuntimeWarning: invalid value encountered in divide
  d1 = (-k + 0.5 * sig2) / sig
/home/runner/work/quantflow/quantflow/quantflow/options/bs.py:65: RuntimeWarning: some derivatives were zero
  return newton(
strike forward moneyness moneyness_ttm ttm implied_vol price price_bp forward_price call side
0 70000.0 69576.25 0.006072 0.033570 0.032717 0.594055 0.0400 400.0 2783.050000 True bid
1 70000.0 69576.25 0.006072 0.033570 0.032717 0.614843 0.0415 415.0 2887.414375 True ask
2 75000.0 69576.25 0.075065 0.415004 0.032717 0.586101 0.0155 155.0 1078.431875 True bid
3 75000.0 69576.25 0.075065 0.415004 0.032717 0.611596 0.0170 170.0 1182.796250 True ask
4 80000.0 69576.25 0.139603 0.771812 0.032717 0.601528 0.0055 55.0 382.669375 True bid
... ... ... ... ... ... ... ... ... ... ... ...
274 140000.0 93633.75 0.402252 0.420542 0.914908 0.540654 0.0775 775.0 7256.615625 True ask
275 160000.0 93633.75 0.535783 0.560144 0.914908 0.540847 0.0515 515.0 4822.138125 True bid
276 160000.0 93633.75 0.535783 0.560144 0.914908 0.561718 0.0575 575.0 5383.940625 True ask
277 180000.0 93633.75 0.653566 0.683283 0.914908 0.559940 0.0390 390.0 3651.716250 True bid
278 180000.0 93633.75 0.653566 0.683283 0.914908 0.583976 0.0450 450.0 4213.518750 True ask

279 rows × 11 columns

The plot function is enabled only if plotly is installed

vs.plot().update_layout(height=500, title="BTC Volatility Surface")

The moneyness_ttm is defined as

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

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

vs.plot3d().update_layout(height=800, title="BTC 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 HestonCalibration, OptionPricer
from quantflow.sp.heston import Heston

pricer = OptionPricer(Heston.create(vol=0.5))
cal = HestonCalibration(pricer=pricer, vol_surface=vs, moneyness_weight=-0)
len(cal.options)
/home/runner/work/quantflow/quantflow/quantflow/options/bs.py:65: RuntimeWarning:

some derivatives were zero
157
cal.model
Heston(variance_process=CIR(rate=0.25, kappa=1.0, sigma=0.8, theta=0.25, sample_algo=<SamplingAlgorithm.implicit: 'implicit'>), rho=0.0)
cal.fit()
  message: CONVERGENCE: REL_REDUCTION_OF_F_<=_FACTR*EPSMCH
  success: True
   status: 0
      fun: 0.019984767431236488
        x: [ 3.565e-01  8.241e-01  8.965e-01  1.829e+00 -5.830e-01]
      nit: 18
      jac: [ 1.891e-04 -1.907e-04  2.292e-04  1.490e-04 -7.147e-04]
     nfev: 216
     njev: 36
 hess_inv: <5x5 LbfgsInvHessProduct with dtype=float64>
pricer.model
Heston(variance_process=CIR(rate=np.float64(0.3564605040505743), kappa=np.float64(0.8964654736456338), sigma=np.float64(1.8292295316492129), theta=np.float64(0.8240830643869713), sample_algo=<SamplingAlgorithm.implicit: 'implicit'>), rho=np.float64(-0.5829856907657782))
cal.plot(index=4, 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:324, in _modified_open(file, *args, **kwargs)
    317 if file in {0, 1, 2}:
    318     raise ValueError(
    319         f"IPython won't let you open fd={file} by default "
    320         "as it is likely to crash IPython. If you know what you are doing, "
    321         "you can use builtins' open."
    322     )
--> 324 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)