Volatility Surface¶
This tutorial covers the full workflow for building an implied volatility surface: fetching option quotes from Deribit, extracting implied forwards and discount factors from option prices, and inspecting the surface inputs.
Fetching Data from Deribit¶
The Deribit client exposes a high-level volatility_surface_loader method that fetches all option quotes for a given asset and assembles them into a VolSurfaceLoader:
import asyncio
from quantflow.data.deribit import Deribit
async def load():
async with Deribit() as cli:
loader = await cli.volatility_surface_loader("btc")
return loader
loader = asyncio.run(load())
Key parameters of volatility_surface_loader:
| Parameter | Default | Description |
|---|---|---|
asset |
required | Underlying asset, e.g. "btc", "eth", "sol" |
inverse |
True |
Inverse options (settled in the underlying) |
use_perp |
False |
Derive spot from the perpetual contract |
exclude_open_interest |
0 |
Drop strikes with open interest below this threshold |
Building the Surface¶
Before creating the surface, calibrate the forwards and discount curves with calibrate_curves:
This step infers the forward price of each maturity from put-call parity and fits the asset discount curve to the resulting discount factors. The surface prices options off these calibrated forwards: skipping the call leaves the surface with forwards taken from the raw futures quotes, which are often illiquid or stale.
See Extracting Forwards and Discount Factors below for how the calibration works.
The loader holds the raw market data. Call surface() to construct a VolSurface:
Then run bs() to populate implied volatilities via Black-Scholes inversion:
bs() solves for the implied volatility that
matches each bid and ask price and marks each option as converged or not.
Removing Outliers¶
Raw option quotes often contain illiquid or stale prices that produce unrealistic implied volatilities. disable_outliers() removes them in two passes per maturity.
Inspecting Surface Inputs¶
The examples below use a saved snapshot of a real ETH surface. The workflow is identical for a live surface fetched from Deribit.
import json
import pandas as pd
from docs.examples._utils import FIXTURES
from quantflow.options.inputs import OptionInput
from quantflow.options.surface import VolSurface, VolSurfaceInputs, surface_from_inputs
# Load a saved volatility surface snapshot from JSON
with open(FIXTURES / "volsurface_btc.json") as fp:
surface_inputs = VolSurfaceInputs(**json.load(fp))
# Build the VolSurface from the inputs and calculate implied volatilities
surface: VolSurface = surface_from_inputs(surface_inputs)
surface.bs()
surface.disable_outliers()
# Print the term structure (forward prices and implied rates per maturity)
print(surface.term_structure().to_string(index=False))
# Display the surface inputs for converged options only
inputs = surface.inputs(converged=True)
option_inputs = [i for i in inputs.inputs if isinstance(i, OptionInput)]
df = pd.DataFrame([i.model_dump() for i in option_inputs])
print("\n\n10 Converged option inputs")
print(
df[["maturity", "strike", "option_type", "bid", "ask", "iv_bid", "iv_ask"]]
.head(10)
.to_string(index=False)
)
term_structure() shows forward prices and the interest rate implied by the forward-spot basis for each maturity. The option inputs table lists the bid/ask prices together with the corresponding implied volatilities for each strike:
maturity ttm forward implied_forward forward_basis rate bid_ask_spread basis open_interest volume
2026-05-22 08:00:00+00:00 0.002503 77568.75 77568.75 0.00 -0.1441613 2.5 -28.00 6770230 2446790
2026-05-23 08:00:00+00:00 0.005243 77441.25 77441.25 0.00 -0.3825828 372.5 -155.50 54290 6260
2026-05-24 08:00:00+00:00 0.007983 77552.5 77552.5 0.0 -0.0714548 565 -44.25 10410 10150
2026-05-25 08:00:00+00:00 0.010723 77557.5 77557.5 0.0 -0.0471850 565 -39.25 0 0
2026-05-29 08:00:00+00:00 0.021682 77616.25 77616.25 0.00 0.0115890 2.5 19.50 96001750 6722440
2026-06-05 08:00:00+00:00 0.040860 77623.75 77623.75 0.00 0.0085143 2.5 27.00 8424810 2094940
2026-06-12 08:00:00+00:00 0.060038 77621.25 77621.25 0.00 0.0052581 97.5 24.50 3160 3170
2026-06-26 08:00:00+00:00 0.098394 77698.75 77698.75 0.00 0.0133507 2.5 102.00 587314120 20900300
2026-07-31 08:00:00+00:00 0.194284 77838.75 77838.75 0.00 0.0160272 17.5 242.00 5813390 845200
2026-09-25 08:00:00+00:00 0.347709 78168.75 78168.75 0.00 0.0211223 2.5 572.00 289191460 3088870
2026-12-25 08:00:00+00:00 0.597024 78890.0 78890.0 0.0 0.0276855 5.0 1293.25 125882130 1281370
2027-03-26 08:00:00+00:00 0.846339 79551.25 79551.25 0.00 0.0293924 17.5 1954.50 16356610 1525090
10 Converged option inputs
maturity strike option_type bid ask iv_bid iv_ask
2026-05-22 08:00:00+00:00 74000 put 0.0001 0.0002 0.4266102 0.47244
2026-05-22 08:00:00+00:00 75000 put 0.0003 0.0004 0.3831767 0.4050766
2026-05-22 08:00:00+00:00 75500 put 0.0004 0.0006 0.3399481 0.3720871
2026-05-22 08:00:00+00:00 76000 put 0.0008 0.0011 0.3246269 0.3558524
2026-05-22 08:00:00+00:00 76500 put 0.0015 0.0019 0.3052505 0.3346447
2026-05-22 08:00:00+00:00 77000 put 0.0029 0.0033 0.2941343 0.3167239
2026-05-22 08:00:00+00:00 77500 put 0.005 0.0055 0.272236 0.2973453
2026-05-22 08:00:00+00:00 78000 call 0.0032 0.0036 0.276864 0.2983897
2026-05-22 08:00:00+00:00 78500 call 0.0015 0.0019 0.2767819 0.3046884
2026-05-22 08:00:00+00:00 79000 call 0.0008 0.0009 0.2980222 0.3082881
Serialising and Restoring¶
inputs() serialises the surface to a VolSurfaceInputs object — a list of SpotInput, ForwardInput, and OptionInput records — that can be stored or transmitted as JSON and later reconstructed via surface_from_inputs:
from quantflow.options.surface import surface_from_inputs
inputs = surface.inputs(converged=True) # VolSurface -> VolSurfaceInputs
surface2 = surface_from_inputs(inputs) # VolSurfaceInputs -> VolSurface
Extracting Forwards and Discount Factors¶
Pricing an option requires two market inputs beyond the option price itself: the forward price \(F\) of the underlying at expiry, and the quote discount factor \(D_q\) for that maturity, where the quote currency is usually USD.
In liquid markets these quantities are directly observable. Futures and forward contracts give \(F\) outright, and interest rate swaps or government bond strips give \(D_q\). In many option markets, however, neither is quoted directly.
Deribit Forward
Crypto options on Deribit are a clear example. There is no liquid term structure of interest rates, and while Deribit quotes futures for each expiry, they are often illiquid, with wide bid-ask spreads and stale or outright wrong prices.
The forward for each expiry must therefore be inferred from the options themselves.
Even when forwards are available, the discount factor used to value options may differ from the rate implied by the forward-spot basis. For equity options the carry includes dividends and repo costs that are not captured by a simple interest rate curve. For crypto inverse options the discount factor reflects funding in the underlying asset rather than in dollars.
For these reasons, quantflow extracts \(D_q\) and \(D_a\) directly from the market prices of options using put-call parity.
Put-call parity and the implied forward¶
For each maturity, the parity relationship is fitted in the normalized form:
where \(S\) is the spot price and \(D_a\) the asset discount factor. The same equation holds for inverse options with the left hand side replaced by \(c - p\), the price difference in units of the underlying.
The price difference is linear in the strike, so a regression across strikes identifies \(D_a\) and \(D_q\), and the line crosses zero exactly at the forward:
put_call_parities collects the most liquid pairs at each maturity, ranked by the bid-ask spread of the parity price, and calibrate_forward fits the regression and returns the implied forward.
Discount curve calibration¶
The calibrate_curves method builds the discount curves on top of the calibrated forwards. With the forward of each maturity held fixed, put-call parity identifies the quote discount factor \(D_q\) as its only remaining parameter, and the asset discount factor follows from the forward formula \(D_a = D_q F / S\).
- Quote curve: pass a YieldCurve
type for
quote_curveto fit it to the per maturity quote discount factors. Leave it asNoneto keep the current quote curve and treat it as known: this is the setup for exchanges that settle without discounting, such as Deribit, where the quote curve is a NoDiscountCurve with \(D_q = 1\) at every maturity. - Asset curve: always fitted to the discount factors \(D_a = D_q F / S\). It cannot be a no discount curve, since the parity forwards and the quote curve define it.
See the curve calibration tutorial for how the forwards and the discount factor split are estimated.