Multi-exchange vol surface
Pulling a full BTC option chain across Deribit, Binance, OKX, and Bybit is a single SQL query — and once the data is in a DataFrame, four side-by-side vol surfaces, an ATM term-structure curve, and a single-expiry smile are all a short Plotly script away.
This article walks through the query and each plot. The full notebook is downloadable at the bottom.
The query
WITH (
SELECT max(timestamp)
FROM api.option_chains
WHERE underlying_asset = 'BTC' AND timestamp > now() - INTERVAL 30 MINUTE
) AS latest_ts
SELECT
exchange,
toDate(expiration) AS expiry,
toFloat64(strike) AS strike,
toUInt32(date_diff('day', timestamp, expiration)) AS dte,
toFloat64(mark_iv) AS iv,
toFloat64(underlying_price) AS spot,
toFloat64(abs(strike - underlying_price)) AS moneyness
FROM api.option_chains
WHERE underlying_asset = 'BTC'
AND mark_iv != 0
AND timestamp >= latest_ts - INTERVAL 5 MINUTE -- snapshots refresh every 5 minutes
AND dte BETWEEN 2 AND 150
ORDER BY exchange, expiry, strikeThree things worth noting:
No
WHERE exchange = …— the table holds every venue's snapshot at the sametimestampcadence, so omitting the exchange filter returns all four in one round-trip.timestamp >= latest_ts - 5 min— different exchanges' materialized views flush a few seconds apart. Widening the latest-snapshot anchor by one snapshot tick (5 minutes) catches all four venues' freshest data without doubling rows.dte BETWEEN 2 AND 150— server-side crop of same-day and far-dated expiries. Write it asBETWEEN: a chained2 <= dte <= 150parses in ClickHouse as(2 <= dte) <= 150, which is always true and silently disables the filter.
underlying_price (aliased spot) and moneyness = abs(strike − underlying_price) are computed server-side, so locating the at-the-money strike per expiry is a pure sort rather than a pandas step. The toFloat64(...) casts turn the Decimal(38, 18) columns into plot-ready floats in the query — no pandas .astype(float) needed. The IV column is already in consistent annualized-percent units across all four exchanges (see the Option chain page for the normalization detail).
Interpolation onto a shared grid
Listed strikes differ across venues. To compare surfaces directly, interpolate each onto the same (dte, strike) grid. We also crop the strike axis to [40k, 160k] to drop deep-wing noise. It stays a client-side constant so you can re-tune the plotted band without re-issuing the query — but spot is already in the result set, so you could equally express this as a server-side moneyness filter.
griddata with method='linear' produces NaNs outside the convex hull of each exchange's listed instruments; a second 'nearest' pass fills those edges so the surface is plottable everywhere on the shared grid.
The surfaces
One Plotly Surface trace per exchange, laid out in a 2-column grid. The exchange list comes from the dataframe (so a query that returns only 3 venues for some asset still plots correctly), with a shared colour scale across all panels:

ATM term structure
At-the-money IV as a function of time to expiry. moneyness comes from the query, so keeping the strike closest to spot for each (exchange, expiry) is just a sort + dedup — one line per venue:

The four venues track each other tightly at ATM — this is the least-arbitrageable slice. Where they fan out is usually a venue carrying an expiry the others don't list.
Volatility smile
Fix the expiry, sweep the strike. Per venue, take the listed expiry closest to 30 DTE (they line up on the same date here), crop to the same strike band as the surface, and overlay all four:

The smile bottoms out near spot and lifts on both wings. The call wing is where it gets interesting: a gap between venues at the same strike and expiry is a candidate arb — after netting funding, basis, and leg-out cost.
Download
Connection setup (host, credentials) at the top of the notebook follows the same template as How to connect. Replace the placeholders with your provisioned values.
Last updated
Was this helpful?