Lesson 3.7.3 · 75 min
Crypto options, on-chain data and DeFi
The most traded bitcoin options are quoted and settled in bitcoin, not in dollars, and that changes the payoff, the delta and the seller's risk. This lesson values them on the synthetic chain of an exchange that mimics Deribit, measures the premium of its volatility index and then goes down to the blockchain: a constant-product pool with planted sandwich attacks and on-chain metrics with a weak signal and its controls.
- Module
- 3.7 · Crypto derivatives and specific strategies
- Duration
- 75 min
- Prerequisites
- 3.7.1 · Perpetuals: funding mechanics, mark price and liquidations, 3.4.4 · Implied volatility: Newton-Raphson, Brent, py_vollib and QuantLib, 1.3.5 · Crypto and on-chain data
- Author
- Jesús Cuesta
- Updated
Level 3 lessons
3.1 · Futures in depth
3.2 · Futures spreads
- 3.2.1 Calendar spreads, butterflies and condors
- 3.2.2 Intercommodity spreads: crack, spark and crush
- 3.2.3 Rate and index spreads: 2s10s, NOB, NQ/ES and Europe versus the U.S.
- 3.2.4 Hedge ratios: by notional, by DV01 and by volatility
- 3.2.5 Spread data and backtesting: listed, synthetic and legged
- 3.2.6 Seasonality of spreads: multi-year analysis, patterns and traps
3.3 · Advanced forex
3.4 · Option theory and pricing
- 3.4.1 Payoffs, put-call parity and arbitrage bounds
- 3.4.2 Binomial trees, Black-Scholes-Merton, Black-76 and Monte Carlo
- 3.4.3 Greeks: delta, gamma, vega, theta, rho, vanna, volga and charm
- 3.4.4 Implied volatility: Newton-Raphson, Brent, py_vollib and QuantLib
- 3.4.5 Smile, skew and term structure; volatility surface with SVI
- 3.4.6 Realized vs implied volatility; volatility risk premium; VIX and VSTOXX
- 3.4.7 Advanced models: Heston, SABR and local volatility
3.5 · Option structures in Python
- 3.5.1 Directional and vertical structures: call, put, covered call, collar and spreads
- 3.5.2 Volatility and range structures: straddles, strangles, condors and butterflies
- 3.5.3 Calendars, diagonals, ratios, backspreads, risk reversals and jade lizard
- 3.5.4 A custom tool: payoff, aggregate Greeks and scenarios
- 3.5.5 Management: adjustments, rolls, early closes, early assignment and pin risk
3.6 · Systematic volatility and options strategies
3.7 · Crypto derivatives and specific strategies
- 3.7.1 Perpetuals: funding mechanics, mark price and liquidations
- 3.7.2 Funding arbitrage, basis with futures and cross-exchange arbitrage
- 3.7.3 Crypto options, on-chain data and DeFi
- 3.7.4 ccxt for trading on multiple exchanges
- 3.7.5 MiCA: the EU framework for crypto-assets and their service providers (CASPs)
Three layers of the crypto market
Lessons 3.7.1 and 3.7.2 covered perpetuals, funding and arbitrage between exchanges. This lesson goes through three more pieces that a crypto system runs into sooner or later, each with its own logic:
- Options on centralized exchanges. Deribit's classic options on bitcoin and ether are inverse: they are quoted and settled in the underlying coin. The Greeks, the margin and the seller's risk change relative to a dollar option (lesson 3.4.2).
- DeFi. On the blockchain, a decentralized exchange has no order book: a contract with two reserves sets the price with a formula. The order of transactions within a block is negotiable, and that is where MEV comes from.
- On-chain data. The chain publishes flows, addresses and fees. They are abundant and free data, and for that very reason a fertile source of spurious correlations.
Everything that runs uses the level 3 synthetic data. The assets (XBQ mimics BTC, XEQ ETH) and the exchanges (boreal mimics Deribit) are fictitious; the effects (the volatility premium, 54 sandwiches, a correlation of −0.06 between flows and return) are planted with known parameters, and each method is checked against that truth and against controls with no effect. No figure from this data is evidence that a strategy works in the real market.
Inverse options: price in coin, risk in dollars
In the Deribit API, inverse contracts (reversed) settle in the base currency, BTC for BTC-PERPETUAL, and for options
the amount is expressed in the underlying coin: one option is a contract on one coin1Note 1 · Deribit API documentation: public/get_instruments (reversed contracts settled in the base currency,
options' contract_size in the coin), “Options Data Collection Best Practices” (option amounts are in the
underlying coin) and public/get_expirations (daily expiries at 08:00 UTC),
docs.deribit.com, accessed on
October 22, 2026.. They coexist with linear
options settled in USDC, which are valued like those of lesson 3.4.2. The boreal options in the data follow the
inverse scheme. If is the Black-76 price in dollars with forward and a zero rate, the price in coin and the
payoff at expiry are
with the index at expiry. Multiplied by , the payoff in dollars is the usual one. What changes is the unit in which it is collected, the premium is paid and the margin is deposited.
The snapshot is that of Friday, March 8, 2024 at 08:00 UTC, right after the weekly expiry (the snapshots in the data are weekly; on Deribit daily expiries are at 08:00 UTC1). The chosen expiry is April 26, 49 days away:
import math
import numpy as np
import pandas as pd
op = pd.read_parquet("cripto3_opciones.parquet")
foto = pd.Timestamp("2024-03-08 08:00", tz="UTC")
venc = pd.Timestamp("2024-04-26 08:00")
c = op[(op["activo"] == "XBQ") & (op["ts"] == foto)
& (op["vencimiento"] == venc)].copy()
F = c["precio_subyacente"].iloc[0]
S = c["precio_indice"].iloc[0]
T = (venc - foto.tz_localize(None)) / pd.Timedelta(days=365)
print(f"index {S:,.1f} USD, forward {F:,.1f} USD, T = {T:.4f}")
cerca = c["strike"].between(56_000, 76_000)
c.loc[cerca, ["tipo", "strike", "bid", "ask", "mark_iv"]].head(8)index 63,355.9 USD, forward 64,277.7 USD, T = 0.1342
tipo strike bid ask mark_iv
152020 C 56000.0 0.1770 0.1805 74.65
152021 C 58000.0 0.1560 0.1595 73.38
152022 C 60000.0 0.1370 0.1400 72.29
152023 C 62000.0 0.1195 0.1225 71.40
152024 C 64000.0 0.1035 0.1065 70.72
152025 C 66000.0 0.0895 0.0925 70.23
152026 C 68000.0 0.0775 0.0800 69.95
152027 C 70000.0 0.0665 0.0690 69.84Prices are fractions of XBQ: the 64,000 call, almost at the money, costs about 0.10 XBQ, about 6,700 USD. The forward
trades 1.5% above the index: it is the futures basis (lesson 3.7.2), and that is why the valuation uses Black-76 on the
forward and not Black-Scholes on spot. With the model volatility (mark_iv) the formula reproduces the chain:
from scipy.special import ndtr
def b76_usd(F, K, T, sigma, tipo):
sd = sigma * np.sqrt(T)
d1 = (np.log(F / K) + sd**2 / 2) / sd
d2 = d1 - sd
call = F * ndtr(d1) - K * ndtr(d2)
put = K * ndtr(-d2) - F * ndtr(-d1)
return np.where(tipo == "C", call, put)
c["medio"] = (c["bid"] + c["ask"]) / 2
c["modelo"] = b76_usd(F, c["strike"], T, c["mark_iv"] / 100,
c["tipo"]) / F
err = (c["medio"] - c["modelo"]).abs()
print(f"{len(c)} options; maximum error {err.max():.5f} XBQ")
print(f"median bid-ask spread {(c['ask'] - c['bid']).median():.4f} XBQ")
c.loc[c["strike"] == 64_000,
["tipo", "bid", "ask", "modelo"]].round(5)46 options; maximum error 0.00062 XBQ
median bid-ask spread 0.0030 XBQ
tipo bid ask modelo
152024 C 0.1035 0.1065 0.10504
152047 P 0.0990 0.1020 0.10072The model and the midpoint differ by at most 0.00062 XBQ, a fifth of the median bid-ask spread (0.0030 XBQ): what
remains is the market maker's noise and the rounding to the tick (0.0001 below 0.005 XBQ and 0.0005 above). In the
opposite direction, implied volatility is obtained by converting the midpoint to dollars and using the Black inverter
from vollib, the library to which py_vollib 1.0.12 redirects its imports (importing it directly avoids the
DeprecationWarning that py_vollib emits; lesson 3.4.4 covers inversion methods):
from vollib.black.implied_volatility import implied_volatility
def iv_inversa(fila):
usd = fila["medio"] * F # from coin to USD
return 100 * implied_volatility(usd, F, fila["strike"], 0.0, T,
fila["tipo"].lower())
c["iv_medio"] = c.apply(iv_inversa, axis=1)
dif = (c["iv_medio"] - c["mark_iv"])[cerca]
print(f"difference from mark_iv: mean {dif.mean():+.2f}, "
f"maximum {dif.abs().max():.2f} points")difference from mark_iv: mean -0.02, maximum 0.20 pointsBetween 56,000 and 76,000 the midpoint's implied volatility deviates from the model's by at most 0.20 points. Outside that zone, with premiums of a few ticks, rounding weighs more and the implied volatility of a mid price is less reliable: it is the same wing problem as in lesson 3.4.5, made worse by a fixed tick in coin.
The payoff in coin
The figure at the top draws the payoff in XBQ of a call and a put with a strike of 64,000. The asymmetry is obvious:
def pago_moneda(S_T, K, tipo):
usd = np.maximum(S_T - K, 0) if tipo == "C" \
else np.maximum(K - S_T, 0)
return usd / S_T
K = 64_000
for S_T in [32_000, 48_000, 64_000, 96_000, 256_000]:
print(f"S_T = {S_T:>7,}: call {pago_moneda(S_T, K, 'C'):.3f}"
f" put {pago_moneda(S_T, K, 'P'):.3f} XBQ")S_T = 32,000: call 0.000 put 1.000 XBQ
S_T = 48,000: call 0.000 put 0.333 XBQ
S_T = 64,000: call 0.000 put 0.000 XBQ
S_T = 96,000: call 0.333 put 0.000 XBQ
S_T = 256,000: call 0.750 put 0.000 XBQThe inverse call approaches 1 XBQ without ever reaching it: at 256,000 USD it pays 0.75 XBQ (192,000 USD). The put, by contrast, pays 1 XBQ with the index at 32,000 and would grow without limit if it kept falling. For the buyer it makes no difference in dollars; for the put seller with margin in coin, it is the worst possible case: they owe more coins exactly when each coin of their collateral is worth less. That is why exchanges of inverse options require margins that grow quickly with the decline, and why a put seller who thinks in dollars hedges the dollar value of their collateral.
The delta changes units
If the result is measured in coin, the sensitivity of the value in coin to the forward is
that is, the position behaves like coins of linear exposure, not like . The premium paid in coin subtracts exposure: whoever bought the option has already handed over coins.
def delta_b76(F, K, T, sigma, tipo):
sd = sigma * np.sqrt(T)
d1 = (np.log(F / K) + sd**2 / 2) / sd
return np.where(tipo == "C", ndtr(d1), ndtr(d1) - 1)
c["delta_usd"] = delta_b76(F, c["strike"], T, c["mark_iv"] / 100,
c["tipo"])
c["delta_moneda"] = c["delta_usd"] - c["modelo"]
sel = c["strike"].isin([48_000, 64_000, 80_000])
c.loc[sel, ["tipo", "strike", "modelo", "delta_usd",
"delta_moneda"]].round(3) tipo strike modelo delta_usd delta_moneda
152018 C 48000.0 0.275 0.871 0.595
152024 C 64000.0 0.105 0.558 0.453
152030 C 80000.0 0.033 0.240 0.207
152041 P 48000.0 0.022 -0.129 -0.152
152047 P 64000.0 0.101 -0.442 -0.543
152053 P 80000.0 0.277 -0.760 -1.037The 64,000 call goes from a delta of 0.558 to 0.453; the 80,000 put, deep in the money, from −0.760 to −1.037: more than one coin of short exposure per contract. A hedge computed with the textbook delta leaves, in a coin account, a residual exposure the size of the premium. The Greeks an exchange publishes may follow one convention or the other: you have to read in its documentation which one it uses before hedging with them.
The DVOL and the volatility premium
Deribit publishes a volatility index of its options, the DVOL, which its API serves with
public/get_volatility_index_data as candles of five values (timestamp, open, high, low and close)2Note 2 · Deribit API documentation: introduction (JSON-RPC interfaces over WebSocket and HTTP, and FIX; production and
test servers), public/get_book_summary_by_currency, public/get_volatility_index_data and “Rate Limits”
(default credits for requests that do not touch the matching engine),
docs.deribit.com/articles/rate-limits, accessed on October 22,
2026.. In the data,
cripto3_dvol.csv mimics that index with a known definition: at 00:00 UTC each day, 100 times the square root of the
30-day implied variance. The useful question is that of lesson 3.4.6: how much is paid, on average, above the
volatility that is later realized?
dv = pd.read_csv("cripto3_dvol.csv", parse_dates=["fecha"])
dia = pd.read_parquet("cripto3_diario.parquet")
indice = (dia.groupby(["fecha", "activo"])["cierre_spot"]
.median().unstack()) # the index: median of spot prices
r = np.log(indice).diff()
rv_sig = r.rolling(30).std().shift(-30) * math.sqrt(365) * 100
for a in ["XBQ", "XEQ"]:
x = dv[dv["activo"] == a].set_index("fecha")["dvol"]
prima = (x - rv_sig[a].reindex(x.index)).dropna()
print(f"{a}: average DVOL {x.mean():.1f}, average premium "
f"{prima.mean():.1f} points, negative on "
f"{(prima < 0).mean():.0%} of days")XBQ: average DVOL 64.0, average premium 9.5 points, negative on 17% of days
XEQ: average DVOL 80.8, average premium 14.4 points, negative on 19% of daysThe average premium is 9.5 points on XBQ and 14.4 on XEQ, the planted answer. The index is not a prediction biased by mistake: it is the price of insurance, and the premium is what the seller collects. The figure shows the cost: in March 2020, May 2021 and June and November 2022 realized volatility exceeds the index by a wide margin, and the premium is negative on 17% of days on XBQ and 19% on XEQ.
For a published index, the natural check is to recompute it from the chain. With Cboe's variance formula from lesson 3.4.6, on out-of-the-money options and with interpolation between the expiries around 30 days, on one Friday in eight:
def var_cboe(g, T, F):
"""Cboe-style variance with OTM options, r = 0 (USD)."""
piv = g.pivot_table(index="strike", columns="tipo",
values="usd")
piv = piv.dropna()
ks = piv.index.to_numpy()
K0 = ks[ks <= F].max()
q = np.where(ks < K0, piv["P"], piv["C"])
q[ks == K0] = piv.loc[K0].mean()
dk = np.gradient(ks)
return 2 / T * np.sum(dk / ks**2 * q) - (F / K0 - 1)**2 / T
def indice_30d(cadena, t0):
vs = []
for v, g in cadena.groupby("vencimiento"):
Tv = (v - t0) / pd.Timedelta(days=365)
vs.append((Tv, var_cboe(g, Tv, g["precio_subyacente"].iloc[0])))
T30 = 30 / 365
(T1, v1), = [x for x in vs if x[0] <= T30][-1:]
(T2, v2), = [x for x in vs if x[0] > T30][:1]
w = (T2 - T30) / (T2 - T1)
return 100 * math.sqrt((T1 * v1 * w + T2 * v2 * (1 - w)) / T30)
xbq = op[op["activo"] == "XBQ"].copy()
xbq["usd"] = (xbq["bid"] + xbq["ask"]) / 2 * xbq["precio_subyacente"]
filas = []
for ts in sorted(xbq["ts"].unique())[::8]:
t0 = ts.tz_localize(None)
filas.append((t0, indice_30d(xbq[xbq["ts"] == ts], t0)))
calc = pd.DataFrame(filas, columns=["t", "calculado"]).set_index("t")
oficial = dv[dv["activo"] == "XBQ"].set_index("fecha")["dvol"]
calc["dvol"] = oficial.reindex(calc.index.normalize()).to_numpy()
d = calc["calculado"] - calc["dvol"]
print(f"{len(calc)} Fridays: mean difference {d.mean():+.2f}, "
f"range {d.min():+.2f} to {d.max():+.2f} points")33 Fridays: mean difference -0.90, range -3.80 to +0.27 pointsThe recomputation is on average 0.9 points below the index, with a difference of up to 3.8 points. There are three reasons, and all three also appear with real data: the chain is cut off at about three standard deviations from the forward and the formula loses the contribution of the wings; the prices are midpoints with a bid-ask spread; and the snapshot is from 08:00 UTC, eight hours after the index data point. A vendor's index and one's own do not match to the decimal; what has to be checked is that the bias is small and stable.
Selling the premium, with its tail
The most direct way to collect the premium is to sell every Friday the ATM straddle that expires the following week, at the bid, and settle it at expiry:
res = []
for a in ["XBQ", "XEQ"]:
o = op[op["activo"] == a]
fotos = sorted(o["ts"].unique())
for d0, d1 in zip(fotos[:-1], fotos[1:]):
sub = o[(o["ts"] == d0)
& (o["vencimiento"] == d1.tz_localize(None))]
if sub.empty:
continue
S0 = sub["precio_indice"].iloc[0]
Fw = sub["precio_subyacente"].iloc[0]
k = sub["strike"].iloc[(sub["strike"] - Fw).abs().argmin()]
prima = sub.loc[sub["strike"] == k, "bid"].sum() * S0
S1 = o.loc[o["ts"] == d1, "precio_indice"].iloc[0]
res.append((a, d0, (prima - abs(S1 - k)) / S0))
st = pd.DataFrame(res, columns=["activo", "ts", "pnl"])
resumen = st.groupby("activo")["pnl"].agg(
semanas="size", media="mean", peor="min",
ganadoras=lambda s: (s > 0).mean())
(100 * resumen.drop(columns="semanas")).round(2).assign(
semanas=resumen["semanas"]) media peor ganadoras semanas
activo
XBQ 0.03 -18.93 56.54 260
XEQ -0.13 -26.93 56.54 260The result is almost zero: 0.03% of the underlying per week on XBQ and −0.13% on XEQ, with 56.5% winning weeks on both (147 of 260). The worst weeks lose 18.9% and 26.9% of the underlying. The 9.5-point premium exists, but the bid-ask spread of selling at the bid and the shock weeks eat it. Selling weekly crypto options without a hedge is not an income: it is a short exposure to jumps, and the jumps in this market are large (lesson 3.6.1 covers systematic premium selling and its tail risk management).
The Deribit API, against a local server
Deribit offers the same API in three ways: JSON-RPC over WebSocket (the recommended one for almost everything),
JSON-RPC over HTTP and FIX2. The public data methods can be requested with a GET to
https://www.deribit.com/api/v2/public/<method>; the response is a JSON-RPC envelope with result or error. This is
the real code, which needs network access:
import json
import urllib.parse
import urllib.request
BASE = "https://www.deribit.com/api/v2"
def publico(metodo, **params):
url = f"{BASE}/public/{metodo}?" + urllib.parse.urlencode(params)
with urllib.request.urlopen(url, timeout=10) as r:
resp = json.load(r)
if "error" in resp:
raise RuntimeError(resp["error"])
return resp["result"]
cadena = publico("get_book_summary_by_currency",
currency="BTC", kind="option")
dvol = publico("get_volatility_index_data", currency="BTC",
start_timestamp=1_735_689_600_000,
end_timestamp=1_738_368_000_000, resolution="1D")get_book_summary_by_currency returns a summary per instrument: instrument_name, bid_price, ask_price,
mark_price, mark_iv, underlying_price, open_interest and interest_rate, among others2. Request limits are
counted in credits: with the default configuration, each request that does not touch the matching engine costs 500
credits, the maximum reserve is 50,000 and 10,000 are refilled per second (20 sustained requests per second); when
they run out, the response is the too_many_requests error (code 10028)2. To follow an entire chain in real time,
the documentation recommends WebSocket subscriptions instead of polling in a loop.
To run the client without network access, a local server in a thread mimics those two methods with the boreal
snapshot and the index from the data, including a pagination rule: a maximum of 100 candles per response and
continuation as the end_timestamp of the next request, which is how Deribit's documentation describes it2:
import json
import threading
import urllib.parse
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
MESES = "JAN FEB MAR APR MAY JUN JUL AUG SEP OCT NOV DEC".split()
def nombre(activo, venc, strike, tipo):
f = f"{venc.day}{MESES[venc.month - 1]}{venc:%y}"
return f"{activo}-{f}-{strike:.0f}-{tipo}"
cad = op[(op["activo"] == "XBQ") & (op["ts"] == foto)]
libro = [{"instrument_name": nombre("XBQ", v, k, t),
"bid_price": b, "ask_price": a, "mark_iv": iv,
"underlying_price": u, "open_interest": oi,
"interest_rate": 0.0}
for v, k, t, b, a, iv, u, oi in cad[
["vencimiento", "strike", "tipo", "bid", "ask",
"mark_iv", "precio_subyacente", "interes_abierto"]
].itertuples(index=False)]
x = dv[dv["activo"] == "XBQ"]
velas = [[int(t.timestamp() * 1000), v, v, v, v]
for t, v in zip(x["fecha"], x["dvol"])]
class Deribit(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def do_GET(self):
u = urllib.parse.urlparse(self.path)
q = dict(urllib.parse.parse_qsl(u.query))
if u.path.endswith("get_book_summary_by_currency"):
res = libro if q.get("kind") == "option" else []
elif u.path.endswith("get_volatility_index_data"):
t0, t1 = int(q["start_timestamp"]), int(q["end_timestamp"])
dentro = [v for v in velas if t0 <= v[0] <= t1]
pagina = dentro[-100:] # maximum 100 per response
sigue = pagina[0][0] - 1 if len(dentro) > 100 else None
res = {"data": pagina, "continuation": sigue}
else:
res = None
cuerpo = json.dumps({"jsonrpc": "2.0", "result": res}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(cuerpo)
srv = ThreadingHTTPServer(("127.0.0.1", 0), Deribit)
threading.Thread(target=srv.serve_forever, daemon=True).start()
BASE = f"http://127.0.0.1:{srv.server_address[1]}/api/v2"
def publico(metodo, **params):
url = f"{BASE}/public/{metodo}?" + urllib.parse.urlencode(params)
with urllib.request.urlopen(url, timeout=10) as r:
resp = json.load(r)
if "error" in resp:
raise RuntimeError(resp["error"])
return resp["result"]
cadena = pd.DataFrame(publico("get_book_summary_by_currency",
currency="XBQ", kind="option"))
print(len(cadena), "options;", cadena["instrument_name"].iloc[0])358 options; XBQ-15MAR24-48000-CThe client is the same as before: only BASE changes. Pagination moves backwards in time, because continuation is a
new end, not a new start:
def dvol_historico(desde_ms, hasta_ms):
filas, fin, llamadas = [], hasta_ms, 0
while fin is not None:
r = publico("get_volatility_index_data", currency="XBQ",
start_timestamp=desde_ms, end_timestamp=fin,
resolution="1D")
llamadas += 1
filas = r["data"] + filas
fin = r["continuation"]
return filas, llamadas
ms = lambda s: int(pd.Timestamp(s, tz="UTC").timestamp() * 1000)
filas, n = dvol_historico(ms("2020-01-01"), ms("2025-12-31"))
print(f"{len(filas)} daily candles in {n} calls; "
f"duplicates: {len(filas) - len({f[0] for f in filas})}")2192 daily candles in 22 calls; duplicates: 0Twenty-two calls for six years of daily candles, with no duplicates because the server cuts off just before the first candle delivered. With one-minute candles and pages of 100, as in the simulator, it would be more than 30,000 calls: at 20 per second, almost half an hour of downloading.
Instrument names follow the template CURRENCY-DMMMYY-STRIKE-TYPE (one- or two-digit day, three-letter English month,
C or P)3Note 3 · Deribit, “JSON-RPC Overview”, table of instrument names (BTC-25MAR23-420-C; for linear options, d as the
strike's decimal separator),
docs.deribit.com/articles/json-rpc-overview, accessed on
October 22, 2026.. Turning them into columns is the first step of any analysis of the chain:
partes = cadena["instrument_name"].str.split("-", expand=True)
cadena["vencimiento"] = pd.to_datetime(partes[1], format="%d%b%y") \
+ pd.Timedelta(hours=8)
cadena["strike"] = partes[2].astype(float)
cadena["tipo"] = partes[3]
resumen = cadena.groupby("vencimiento").agg(
strikes=("strike", "nunique"), oi=("open_interest", "sum"))
resumen strikes oi
vencimiento
2024-03-15 08:00:00 15 485.4
2024-03-22 08:00:00 16 604.6
2024-03-29 08:00:00 18 690.2
2024-04-26 08:00:00 23 977.7
2024-05-31 08:00:00 29 1358.1
2024-06-28 08:00:00 32 1564.8
2024-09-27 08:00:00 46 2613.8Seven expiries: two weeklies, the last Fridays of March, April and May, and the June and September quarterlies. The
number of strikes and the open interest grow with maturity, because the strike window widens with the square root of
time. pandas' %d%b%y format reads 15MAR24 with the default English locale; on a system with a different locale, the
safe option is to translate the month with a dictionary.
Mermaid source
flowchart TB A[get_instruments] --> B[book_summary] B --> C[Name to columns] C --> D[IV and Greeks] E[volatility_index] --> F[continuation] F --> E D --> G[Daily chain] F --> G
The diagram sums up the daily collection of a chain: live instruments, price summary, names converted into expiry, strike and type, implied volatilities and Greeks recomputed with one's own convention, and the volatility index paginated up to the last data point stored.
DeFi: a constant-product pool
Uniswap v2, one of the reference designs for a decentralized exchange, keeps two reserves and and only accepts swaps that do not reduce their product. Each swap pays a fixed fee of 0.30% that is added to the reserves, so that grows with each trade4Note 4 · Uniswap, “Fees”: in v2 each pool charges a fixed fee of 0.30% that is added to the reserves and makes grow, docs.uniswap.org, accessed on October 22, 2026.. With the fee on the input, whoever hands over receives
and the pool's marginal price is . The pool in the data, XEQ/USDQ, follows that formula for seven days from March 4, 2024, with a block every 12 seconds, retail traders, arbitrageurs who bring the price back to the fee band around the price of the centralized exchanges and three MEV searchers:
sw = pd.read_parquet("defi3_swaps.parquet")
sw = sw.sort_values(["bloque", "posicion"]).reset_index(drop=True)
COM = 0.003
def salida(dx, x, y, com=COM):
dxc = dx * (1 - com)
return y * dxc / (x + dxc)
x0 = sw["reserva_xeq"].shift()
y0 = sw["reserva_usdq"].shift()
entra_xeq = sw["token_entrada"] == "XEQ"
pred = salida(sw["importe_entrada"], np.where(entra_xeq, x0, y0),
np.where(entra_xeq, y0, x0))
err = (pred - sw["importe_salida"]).abs() / sw["importe_salida"]
k = sw["reserva_xeq"] * sw["reserva_usdq"]
print(f"{len(sw)} swaps in {sw['bloque'].nunique()} blocks")
print(f"median relative error {err.iloc[1:].median():.1e}")
print(f"k rises {k.iloc[-1] / k.iloc[0] - 1:.3%} in 7 days")18489 swaps in 15438 blocks
median relative error 4.2e-07
k rises 2.557% in 7 daysEach output is reproduced with the formula from the reserves of the previous swap (the median relative error, , is the rounding of the data to six decimals), and grows 2.6% in a week: those are the fees kept by the liquidity providers. The cost of trading against the pool depends on size:
x, y = sw[["reserva_xeq", "reserva_usdq"]].iloc[0]
p0 = y / x
for usd in [1_000, 5_000, 25_000, 100_000, 500_000]:
recibe = salida(usd, y, x) # USDQ in, XEQ out
pagado = usd / recibe
print(f"{usd:>8,} USDQ: price {pagado:,.2f} "
f"({pagado / p0 - 1:+.2%} over {p0:,.2f})") 1,000 USDQ: price 3,356.11 (+0.31% over 3,345.80)
5,000 USDQ: price 3,357.11 (+0.34% over 3,345.80)
25,000 USDQ: price 3,362.12 (+0.49% over 3,345.80)
100,000 USDQ: price 3,380.88 (+1.05% over 3,345.80)
500,000 USDQ: price 3,480.92 (+4.04% over 3,345.80)Up to a few thousand USDQ the cost is almost only the fee; beyond that it grows quickly: 1.05% above the price for 100,000 USDQ and 4% for 500,000. An order book (lesson 1.1.3) has depth that is replenished; a pool has a fixed curve until someone adds liquidity or an arbitrageur brings it back to the outside price.
MEV: sandwiches within a block
A transaction sent to the network waits in the public mempool until a builder includes it in a block. Searchers watch that mempool and send transactions that make money from the ordering; builders order the transactions and assemble the blocks; the proposer (the validator on duty) publishes the block. What can be extracted by reordering is called MEV (maximal extractable value)5Note 5 · ethereum.org, “Maximal extractable value (MEV)”: searchers, builders, proposer-builder separation and sandwiches, ethereum.org, accessed on October 22, 2026. The reference paper on transaction reordering on DEXs is Daian et al., “Flash Boys 2.0” (arXiv:1904.05234).. A sandwich is the textbook case: the searcher sees a large buy, buys just before, lets the victim buy at a higher price and sells just after.
Mermaid source
flowchart TB U[User] --> MP[Public mempool] U --> PR[Private channel] MP --> BU[MEV searcher] BU --> CO[Builder] MP --> CO PR --> CO CO --> PO[Proposer] PO --> BL[Block]
The private channel is the defense: services like Flashbots Protect send the transaction to a private mempool where sandwich bots do not see it6Note 6 · Flashbots, “Flashbots Protect”: transactions are sent to a private mempool, hidden from frontrunning and sandwich bots, docs.flashbots.net, accessed on October 22, 2026.. In the data, 20% of retail traders use it and cannot be attacked. Within the block, the sandwich leaves a signature of three consecutive transactions:
Mermaid source
flowchart LR A[Attacker buy] --> V[Victim buy] V --> B[Attacker sell]
A detector that only looks for “the same sender buys and sells in the same block” also finds other things. The generator planted 54 control blocks in which an arbitrageur buys and unwinds the purchase in two consecutive transactions, with nobody in between: the same input and output direction as a sandwich, without a victim.
verdad = pd.read_csv("defi3_sandwich_verdad.csv")
def detectar(sw, estricto):
hallados = []
for b, g in sw.groupby("bloque"):
g = g.reset_index(drop=True)
for i in range(len(g) - 1):
a = g.iloc[i]
for j in range(i + 1, len(g)):
z = g.iloc[j]
vuelta = (a["remitente"] == z["remitente"]
and a["token_entrada"] == z["token_salida"])
if not vuelta:
continue
if not estricto:
hallados.append((b, a["posicion"]))
elif j == i + 2:
m = g.iloc[i + 1]
if (m["remitente"] != a["remitente"] and
m["token_entrada"] == a["token_entrada"]):
hallados.append((b, a["posicion"]))
return set(hallados)
real = set(zip(verdad["bloque"], verdad["pos_frontrun"]))
for estricto in (False, True):
h = detectar(sw[sw.duplicated("bloque", keep=False)], estricto)
acierta = len(h & real)
print(f"strict={estricto}: {len(h)} flagged, {acierta} real,"
f" precision {acierta / len(h):.0%}, "
f"recall {acierta / len(real):.0%}")strict=False: 108 flagged, 54 real, precision 50%, recall 100%
strict=True: 54 flagged, 54 real, precision 100%, recall 100%The loose detector flags 108 cases: the 54 sandwiches and the 54 controls, with a precision of 50%. The strict one requires the triad (attacker, another sender in the same direction, attacker unwinding right after) and gets all 54 without a single false positive. With real data the strict rule is not enough either: there are sandwiches with several victims, attackers who use different contracts for each leg and attacks spread across blocks. The lesson is the usual one: a detector is measured against a known truth and against controls that resemble it.
With the generator's truth, the money of each attack can be followed:
filas = []
for t in verdad.itertuples():
g = sw[sw["bloque"] == t.bloque].set_index("posicion")
f, v, bk = (g.loc[t.pos_frontrun], g.loc[t.pos_victima],
g.loc[t.pos_backrun])
px = f["precio_cex"]
a_usd = 1.0 if f["token_entrada"] == "USDQ" else px
b_usd = px if f["token_entrada"] == "USDQ" else 1.0
lp = COM * (f["importe_entrada"] * a_usd
+ bk["importe_entrada"] * b_usd)
filas.append((lp, t.soborno_usd, t.gas_usd,
t.beneficio_neto_usd, t.perdida_victima_usd))
rep = pd.DataFrame(filas, columns=["comision_lp", "propina",
"gas", "neto_atacante",
"perdida_victima"]).sum()
print(rep.round(0))
print(f"sum of destinations / loss: "
f"{rep.iloc[:4].sum() / rep['perdida_victima']:.3f}")comision_lp 20972.0
propina 11036.0
gas 2099.0
neto_atacante 5966.0
perdida_victima 39543.0
dtype: float64
sum of destinations / loss: 1.013The victims lose 39,543 USD compared with executing without an attack, 0.9% of what they traded. Of that loss, about 21,000 USD ends up in the fees the attacker pays the pool on its two legs (the liquidity providers collect from the sandwich), 11,036 in tips to the builder to get the ordering, 2,099 in gas and only 5,966 in the attacker's pocket. The four destinations add up to the loss with a 1.3% difference, because the fee on the legs also changes the final state of the pool. Competition among searchers pushes the tip towards the gross profit: MEV is collected above all by whoever controls the ordering.
For a trading system in DeFi, the practical consequence is to set a tight slippage tolerance (the generator takes it from 0.5, 1 or 3%, and the attacker buys the maximum that leaves the victim right at their minimum) and to send large orders through a private channel.
On-chain as a source of signals
On-chain metrics (lesson 1.3.5) have a long and attractive story: net inflows to exchanges as a harbinger of selling,
active addresses as a measure of usage. onchain3_metricas.csv has a planted relationship: the correlation between
today's net flow into exchanges and tomorrow's return is −0.06 on both assets. The change in active addresses and in
transactions are controls with no relationship to the return. The return is that of the index, the median of the
exchanges' spot prices at the 23:00 UTC close:
oc = pd.read_csv("onchain3_metricas.csv", parse_dates=["fecha"])
filas = []
for a in ["XBQ", "XEQ"]:
o = oc[oc["activo"] == a].set_index("fecha")
r1 = np.log(indice[a].reindex(o.index)).diff().shift(-1)
cand = {"flujo_neto": o["flujo_neto_exchanges"],
"d_direcciones": np.log(o["direcciones_activas"]).diff(),
"d_transacciones": np.log(o["transacciones"]).diff()}
for nombre_s, s in cand.items():
ok = s.notna() & r1.notna()
rho = s[ok].corr(r1[ok])
filas.append((a, nombre_s, rho, rho * math.sqrt(ok.sum())))
ic = pd.DataFrame(filas, columns=["activo", "serie", "corr", "t"])
ic.round(3) activo serie corr t
0 XBQ flujo_neto -0.034 -1.594
1 XBQ d_direcciones -0.033 -1.538
2 XBQ d_transacciones -0.035 -1.658
3 XEQ flujo_neto -0.097 -4.530
4 XEQ d_direcciones 0.035 1.643
5 XEQ d_transacciones 0.023 1.097With ≈ 2,190 days, the standard error of a correlation close to zero is ≈ 0.021, and . The XEQ net flow (−0.097, = −4.5) stands apart from everything. The XBQ one (−0.034, = −1.6) is indistinguishable from the controls: the change in XBQ transactions gives −0.035 and that of XEQ addresses, 0.035, without any planted relationship. The truth is the same on both assets (−0.06); in a six-year sample, one comes out almost twice as strong and the other halfway to zero. If twenty metrics were tested, one of the control ones would exceed = 2 by chance.
Exercises
- With
defi3_sandwich_verdad.csvand the swaps, compute each victim's loss as a percentage of their trade. What is the median, what is the maximum, and does the loss grow with size?
Solution
pos = verdad.merge(sw, left_on=["bloque", "pos_victima"],
right_on=["bloque", "posicion"])
usd = np.where(pos["token_entrada"] == "USDQ",
pos["importe_entrada"],
pos["importe_entrada"] * pos["precio_cex"])
pos["perdida_pct"] = 100 * pos["perdida_victima_usd"] / usd
pos["tamano_usd"] = usd
print(pos["perdida_pct"].describe()[["50%", "max"]].round(2))
print("rank corr. with size:",
round(pos["perdida_pct"].corr(pos["tamano_usd"],
method="spearman"), 2))50% 0.50
max 2.98
Name: perdida_pct, dtype: float64
rank corr. with size: -0.41The median is 0.50% and the maximum 2.98%: almost exactly the slippage tolerances used by the generator's retail traders (0.5%, 1% and 3%, with 0.5% in 60% of cases). The attacker buys the maximum that leaves the victim at their minimum acceptable, so the victim loses almost all of their tolerance. The rank correlation with size is negative (−0.41): in a large trade, part of the tolerance is already eaten by the trade's own slippage and there is less room left for the attacker. The tolerance is not an interface detail: it is what the user gives away if someone sandwiches them.
- Build a daily rule on XEQ: position −1 (short) if today's net flow is above its median of the previous 90 days and +1 (long) if it is below, applied to tomorrow's return. Measure the gross annual Sharpe and with a cost of 10 bp per unit traded (going from long to short is 20 bp), and do the same with the active addresses control.
Solution
def regla(s, r1, coste=0.001):
med = s.rolling(90).median().shift(1)
pos = np.sign(med - s).where(med.notna())
bruto = pos * r1
neto = bruto - coste * pos.diff().abs()
sh = lambda x: x.mean() / x.std() * math.sqrt(365)
return (round(float(sh(bruto.dropna())), 2),
round(float(sh(neto.dropna())), 2))
o = oc[oc["activo"] == "XEQ"].set_index("fecha")
r1 = np.log(indice["XEQ"].reindex(o.index)).diff().shift(-1)
print("net flow (gross, net):",
regla(o["flujo_neto_exchanges"], r1))
print("addresses control:",
regla(np.log(o["direcciones_activas"]).diff(), r1))net flow (gross, net): (1.5, 0.97)
addresses control: (-0.75, -1.31)With the planted signal, the XEQ rule gives a gross Sharpe of 1.5 and 0.97 with costs. The control, with no relationship at all to the return, gives −0.75 gross: half the magnitude of the real signal, by pure chance and with the opposite sign. Had the sign come out positive, it would have looked like a second signal. This is synthetic data with a planted effect of the size the generator chose: the exercise shows the scale of false positives, not that net flow predicts ether's return. The rule flips position almost one day in two (48% of days), and at 20 bp per flip it loses a third of its Sharpe.
- You sell an inverse put with a strike of 64,000 on XBQ at the bid of the snapshot (0.0990 XBQ, with the index at 63,355.9 USD) and keep 1 XBQ as margin. At expiry the index is worth 32,000 USD. Compute what you owe in coin and the result in dollars, and compare it with a linear USD put with the same premium in dollars.
Solution
The inverse put owes (64,000 − 32,000)/32,000 = 1 XBQ. The premium collected, 0.0990 XBQ, was worth 6,272 USD when sold and 3,168 USD at expiry, so the option's result is −0.901 XBQ, about −28,832 USD. A linear put with the same premium in dollars (6,272 USD) would pay 32,000 USD and lose 25,728 USD: the difference, about 3,100 USD, is the loss of dollar value of the premium collected in coin. Add the margin to that: 1 XBQ that was worth 63,356 USD is worth 32,000, and on top of that it is handed over almost entirely to pay the put. A seller who thinks in dollars has to hedge the coin of the margin and of the premium, not just the option's delta.
Footnotes
-
Deribit API documentation:
public/get_instruments(reversedcontracts settled in the base currency, options'contract_sizein the coin), “Options Data Collection Best Practices” (option amounts are in the underlying coin) andpublic/get_expirations(daily expiries at 08:00 UTC), docs.deribit.com, accessed on October 22, 2026. ↩ ↩2 -
Deribit API documentation: introduction (JSON-RPC interfaces over WebSocket and HTTP, and FIX; production and test servers),
public/get_book_summary_by_currency,public/get_volatility_index_dataand “Rate Limits” (default credits for requests that do not touch the matching engine), docs.deribit.com/articles/rate-limits, accessed on October 22, 2026. ↩ ↩2 ↩3 ↩4 ↩5 -
Deribit, “JSON-RPC Overview”, table of instrument names (
BTC-25MAR23-420-C; for linear options,das the strike's decimal separator), docs.deribit.com/articles/json-rpc-overview, accessed on October 22, 2026. ↩ -
Uniswap, “Fees”: in v2 each pool charges a fixed fee of 0.30% that is added to the reserves and makes grow, docs.uniswap.org, accessed on October 22, 2026. ↩
-
ethereum.org, “Maximal extractable value (MEV)”: searchers, builders, proposer-builder separation and sandwiches, ethereum.org, accessed on October 22, 2026. The reference paper on transaction reordering on DEXs is Daian et al., “Flash Boys 2.0” (arXiv:1904.05234). ↩
-
Flashbots, “Flashbots Protect”: transactions are sent to a private mempool, hidden from frontrunning and sandwich bots, docs.flashbots.net, accessed on October 22, 2026. ↩
Each answer is folded under its question
Self-assessment
You buy an inverse call with a strike of 64,000 USD and at expiry the index is worth 256,000 USD. How much do you collect?
- A192,000 USD in dollars
- B3 XBQ
- C0.75 XBQ, worth 192,000 USD at that price
- D1 XBQ, the call's maximum
Show answer
Answer C. 0.75 XBQ, worth 192,000 USD at that price
The payoff of an inverse call is (S − K)/S coins: (256,000 − 64,000)/256,000 = 0.75 XBQ. In dollars it is equivalent to S − K, like a linear call; in coin, a call's payoff never reaches 1 XBQ.
Why is a sold inverse put especially dangerous for someone who keeps their margin in the coin itself?
- ABecause its payoff in coin, (K − S)/S, grows without limit when the price falls, while the coin collateral loses value in dollars
- BBecause its premium is lower than the call's
- CBecause inverse puts cannot be closed before expiry
- DBecause they settle in dollars
Show answer
Answer A. Because its payoff in coin, (K − S)/S, grows without limit when the price falls, while the coin collateral loses value in dollars
With S = 32,000 and K = 64,000 the put owes 1 XBQ; with S = 16,000, 3 XBQ. The seller pays more coins exactly when each coin of their collateral is worth less: the risk is multiplied in two ways.
In the data, the DVOL-style index of XBQ exceeds the volatility realized over the next 30 days by 9.5 points on average. Which conclusion is correct?
- ASelling volatility is a safe income of 9.5 points
- BThe index is miscalculated
- CThe premium only exists on XEQ
- DThere is an average premium, but the weekly straddle sold at the bid earns 0.03% on average and loses 18.9% in its worst week: the premium is paid for with tails
Show answer
Answer D. There is an average premium, but the weekly straddle sold at the bid earns 0.03% on average and loses 18.9% in its worst week: the premium is paid for with tails
The implied variance premium is not net profit: you have to sell at the bid, hedge or take the move, and the shocks (2020, 2021, 2022) take in one week what is earned in months. In addition, on 17% of days realized volatility exceeds the index.
A detector flags as a sandwich any pair of swaps from the same sender in the same block and in opposite directions. In the data's pool, what happens?
- AIt finds the 54 sandwiches and nothing else
- BIt finds the 54 and another 54 false positives: an arbitrageur's round trips with no victim in between
- CIt finds none
- DIt finds half
Show answer
Answer B. It finds the 54 and another 54 false positives: an arbitrageur's round trips with no victim in between
The signature of a sandwich is a triad: the attacker's buy, a swap from another sender in the same direction and the attacker's sell right after. Without requiring the victim in the middle, the detector flags 108 cases with a precision of 50%.
XBQ net flow into exchanges has a correlation of −0.034 with the next day's return (t ≈ −1.6). What can be said?
- AIt is a significant signal at 1%
- BIt proves that exchange inflows anticipate nothing
- CThat the planted signal was zero
- DIt cannot be distinguished from the controls (change in addresses and in transactions), which give correlations of the same magnitude by chance
Show answer
Answer D. It cannot be distinguished from the controls (change in addresses and in transactions), which give correlations of the same magnitude by chance
With 2,190 days the standard error is ≈ 0.021, so −0.034 is 1.6 errors from zero, like the controls (−0.035 and 0.035). The planted signal was −0.06: the XBQ estimate is compatible both with it and with zero. Only on XEQ (−0.097, t ≈ −4.5) does it stand out.
Key points
- An inverse option is quoted in coin: price = Black-76(F, K, T, σ) / F and payoff = max(S − K, 0)/S or max(K − S, 0)/S coins. The call never pays 1 coin; the sold put can owe several.
- The delta in coin is the Black-76 delta minus the price in coin: an ATM call long goes from 0.56 to 0.45, and an in-the-money put, from −0.76 to −1.04.
- The DVOL-style index in the data exceeds subsequent realized volatility by 9.5 points (XBQ) and 14.4 (XEQ), but the weekly straddle sold at the bid barely earns and its worst week loses 18.9% and 26.9% of the underlying.
- The Deribit API is JSON-RPC over HTTP or WebSocket; the DVOL is paginated backwards with
continuationand names likeXBQ-26APR24-70000-Ccarry expiry, strike and type. - In an x·y = k pool the 0.30% fee stays in the reserves and slippage grows with size; a sandwich is a triad in the same block, and of the 39,543 USD the victims lose the attacker only keeps about 6,000.
- An on-chain metric is tested against controls and with its standard error: the planted signal of −0.06 is only clearly visible on one of the two assets.