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