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.

Fig. — Payoff at expiry, in XBQ, of an inverse call and put struck at 64,000 USD
Level 3 lessons
  1. 3.1 · Futures in depth

    1. 3.1.1 Main futures contracts
    2. 3.1.2 Futures curve: basis, cost of carry, contango and roll yield
    3. 3.1.3 Roll management, margins, SPAN and implied leverage
    4. 3.1.4 Diversified futures systems: Carver's approach
  2. 3.2 · Futures spreads

    1. 3.2.1 Calendar spreads, butterflies and condors
    2. 3.2.2 Intercommodity spreads: crack, spark and crush
    3. 3.2.3 Rate and index spreads: 2s10s, NOB, NQ/ES and Europe versus the U.S.
    4. 3.2.4 Hedge ratios: by notional, by DV01 and by volatility
    5. 3.2.5 Spread data and backtesting: listed, synthetic and legged
    6. 3.2.6 Seasonality of spreads: multi-year analysis, patterns and traps
  3. 3.3 · Advanced forex

    1. 3.3.1 FX microstructure: OTC, ECN, last look and sessions
    2. 3.3.2 Carry and crash risk, momentum and value in currencies; baskets
    3. 3.3.3 Currency risk in multi-currency portfolios; execution via OANDA, IBKR or MT5
  4. 3.4 · Option theory and pricing

    1. 3.4.1 Payoffs, put-call parity and arbitrage bounds
    2. 3.4.2 Binomial trees, Black-Scholes-Merton, Black-76 and Monte Carlo
    3. 3.4.3 Greeks: delta, gamma, vega, theta, rho, vanna, volga and charm
    4. 3.4.4 Implied volatility: Newton-Raphson, Brent, py_vollib and QuantLib
    5. 3.4.5 Smile, skew and term structure; volatility surface with SVI
    6. 3.4.6 Realized vs implied volatility; volatility risk premium; VIX and VSTOXX
    7. 3.4.7 Advanced models: Heston, SABR and local volatility
  5. 3.5 · Option structures in Python

    1. 3.5.1 Directional and vertical structures: call, put, covered call, collar and spreads
    2. 3.5.2 Volatility and range structures: straddles, strangles, condors and butterflies
    3. 3.5.3 Calendars, diagonals, ratios, backspreads, risk reversals and jade lizard
    4. 3.5.4 A custom tool: payoff, aggregate Greeks and scenarios
    5. 3.5.5 Management: adjustments, rolls, early closes, early assignment and pin risk
  6. 3.6 · Systematic volatility and options strategies

    1. 3.6.1 Systematic premium selling: put-write, strangles and their tail risk
    2. 3.6.2 Delta hedging, gamma scalping and dispersion
    3. 3.6.3 VIX and VSTOXX strategies: volatility futures and ETPs
    4. 3.6.4 Options backtesting: data, fills and event adjustments
  7. 3.7 · Crypto derivatives and specific strategies

    1. 3.7.1 Perpetuals: funding mechanics, mark price and liquidations
    2. 3.7.2 Funding arbitrage, basis with futures and cross-exchange arbitrage
    3. 3.7.3 Crypto options, on-chain data and DeFi
    4. 3.7.4 ccxt for trading on multiple exchanges
    5. 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 CC is the Black-76 price in dollars with forward FF and a zero rate, the price in coin and the payoff at expiry are

V=C(F,K,T,σ)F,call payoff=max⁡(ST−K,0)ST,put payoff=max⁡(K−ST,0)ST,V = \frac{C(F, K, T, \sigma)}{F}, \qquad \text{call payoff} = \frac{\max(S_T - K, 0)}{S_T}, \qquad \text{put payoff} = \frac{\max(K - S_T, 0)}{S_T},

with STS_T the index at expiry. Multiplied by STS_T, 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:

IN [01]python
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)
OUT [01]A snapshot of the boreal chain
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.84

Prices 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:

IN [02]python
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)
OUT [02]Black-76 divided by the forward
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.10072

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

IN [03]python
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")
OUT [03]Implied volatility of the midpoint
difference from mark_iv: mean -0.02, maximum 0.20 points

Between 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:

IN [04]python
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")
OUT [04]The payoff in coin at expiry
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 XBQ

The 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

∂V∂F=∂(C/F)∂F=N(d1)−VF,\frac{\partial V}{\partial F} = \frac{\partial (C/F)}{\partial F} = \frac{N(d_1) - V}{F},

that is, the position behaves like N(d1)−VN(d_1) - V coins of linear exposure, not like N(d1)N(d_1). The premium paid in coin subtracts exposure: whoever bought the option has already handed over VV coins.

IN [05]python
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)
OUT [05]Delta in dollars and delta in coin
       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.037

The 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?

IN [06]python
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")
OUT [06]DVOL versus the volatility realized afterwards
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 days
Fig. — XBQ 30-day implied volatility index (DVOL-like) and volatility realised over the following 30 days

The 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:

IN [07]python
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")
OUT [07]Recomputing the index from the chain
33 Fridays: mean difference -0.90, range -3.80 to +0.27 points

The 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:

IN [08]python
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"])
OUT [08]Selling the weekly ATM straddle
        media   peor  ganadoras  semanas
activo
XBQ      0.03 -18.93      56.54      260
XEQ     -0.13 -26.93      56.54      260

The 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:

IN [09]deribit_rest.pypython
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:

IN [10]python
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])
OUT [10]A local Deribit: chain and paginated DVOL
358 options; XBQ-15MAR24-48000-C

The client is the same as before: only BASE changes. Pagination moves backwards in time, because continuation is a new end, not a new start:

IN [11]python
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})}")
OUT [11]Paginating the DVOL backwards with continuation
2192 daily candles in 22 calls; duplicates: 0

Twenty-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:

IN [12]python
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
OUT [12]From instrument name to columns
                     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.8

Seven 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.

get_instrumentsbook_summaryName to columnsIV and Greeksvolatility_indexcontinuationDaily chain
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 xx and yy 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 kk 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 kk grow, docs.uniswap.org, accessed on October 22, 2026.. With the fee cc on the input, whoever hands over Δx\Delta x receives

Δy=y (1−c) Δxx+(1−c) Δx,\Delta y = \frac{y \, (1 - c)\, \Delta x}{x + (1 - c)\, \Delta x},

and the pool's marginal price is y/xy/x. 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:

IN [13]python
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")
OUT [13]The pool: x·y = k with a fee
18489 swaps in 15438 blocks
median relative error 4.2e-07
k rises 2.557% in 7 days

Each output is reproduced with the formula from the reserves of the previous swap (the median relative error, 4.2⋅10−74.2 \cdot 10^{-7}, is the rounding of the data to six decimals), and kk 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:

IN [14]python
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})")
OUT [14]Slippage by size
   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)
Fig. — Price premium of an XEQ purchase in the x·y = k pool by trade size, 0.30% fee included

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.

UserPublic mempoolPrivate channelMEV searcherBuilderProposerBlock
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:

Attacker buyVictim buyAttacker sell
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.

IN [15]python
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%}")
OUT [15]Two sandwich detectors
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:

IN [16]python
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}")
OUT [16]Who keeps the victim's loss
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.013
Fig. — Where the 39,543 USD lost by the victims of the 54 sandwiches go

The 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:

IN [17]python
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)
OUT [17]Flows into exchanges and tomorrow's return
  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.097
Fig. — Correlation between each on-chain metric today and tomorrow's return, with its 95% interval

With nn ≈ 2,190 days, the standard error of a correlation close to zero is 1/n1/\sqrt{n} ≈ 0.021, and t=ρnt = \rho\sqrt{n}. The XEQ net flow (−0.097, tt = −4.5) stands apart from everything. The XBQ one (−0.034, tt = −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 tt = 2 by chance.

Exercises

  1. With defi3_sandwich_verdad.csv and 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
IN [18]python
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))
OUT [18]Victim's loss as a % of their trade
50%    0.50
max    2.98
Name: perdida_pct, dtype: float64
rank corr. with size: -0.41

The 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.

  1. 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
IN [19]python
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))
OUT [19]The XEQ signal versus a control, with costs
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.

  1. 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

  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. ↩ ↩2

  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. ↩ ↩2 ↩3 ↩4 ↩5

  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. ↩

  4. Uniswap, “Fees”: in v2 each pool charges a fixed fee of 0.30% that is added to the reserves and makes kk grow, docs.uniswap.org, accessed on October 22, 2026. ↩

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

  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. ↩

Each answer is folded under its question

Self-assessment

  1. 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.

  2. 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.

  3. 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.

  4. 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%.

  5. 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 continuation and names like XBQ-26APR24-70000-C carry 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.

Resources

Data