---
title: "3.7.3 Crypto options, on-chain and DeFi"
description: "Deribit-style inverse options, the DVOL and its premium, its API against a local simulator, an x·y = k pool with MEV sandwiches and on-chain signals."
url: "https://pythonparatrading.com/en/python-trading-course/level-3/crypto-options-on-chain-defi/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-3/crypto-options-on-chain-defi.png"
---

# 3.7.3 · 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.

- Course: [Level 3: Derivatives: futures, spreads, forex and options](https://pythonparatrading.com/en/python-trading-course/level-3/)
- Module: 3.7 · Crypto derivatives and specific strategies
- Duration: 75 min
- Prerequisites: [3.7.1 · Perpetuals: funding mechanics, mark price and liquidations](https://pythonparatrading.com/en/python-trading-course/level-3/perpetual-futures-funding-mark-price-liquidations/), [3.4.4 · Implied volatility: Newton-Raphson, Brent, py_vollib and QuantLib](https://pythonparatrading.com/en/python-trading-course/level-3/implied-volatility-newton-brent-quantlib/), [1.3.5 · Crypto and on-chain data](https://pythonparatrading.com/en/python-trading-course/level-1/crypto-and-on-chain-data/)

## By the end of this lesson

- Value an inverse option (price in coin = Black-76 / forward), recover its implied volatility and explain why its payoff and its delta are not those of a dollar option.
- Measure the premium of a DVOL-style 30-day implied volatility index versus the volatility realized afterwards, and present straddle selling with its worst week.
- Consume the Deribit JSON-RPC API (chain, DVOL paginated with `continuation`, instrument names) against a local server that mimics it.
- Reproduce an x·y = k pool with a fee, measure its slippage and detect MEV sandwiches without confusing them with legitimate round trips.
- Test an on-chain signal against controls with no effect and with its standard error before treating it as a source of alpha.

## 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 coin[^1]. 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 $C$ is the Black-76 price in dollars with forward $F$ and a zero rate, the price in coin and the
payoff at expiry are

$$
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 $S_T$ the index at expiry. Multiplied by $S_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 UTC[^1]). The chosen expiry is April 26, 49 days away:

```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)
```
```output
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:

```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)
```
```output
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):

```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")
```
```output
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:

```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")
```
```output
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

$$
\frac{\partial V}{\partial F} = \frac{\partial (C/F)}{\partial F} = \frac{N(d_1) - V}{F},
$$

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

```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)
```
```output
       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)[^2]. 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?

```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")
```
```output
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.* Two weekly lines from 2020 to 2025 in annual volatility points. The implied index, highlighted, is almost always above the volatility realised afterwards: the average premium is 9.5 points. Realised volatility beats the index in the shocks (March 2020, May 2021, June and November 2022), when it rises to 131.

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:

```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")
```
```output
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:

```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"])
```
```output
        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 FIX[^2]. 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:

```python skip title="deribit_rest.py"
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 others[^2]. 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 it[^2]:

```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])
```
```output
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:

```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})}")
```
```output
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`)[^3]. Turning them into columns is the first step of any analysis of the chain:

```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
```
```output
                     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.

```mermaid
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 $x$ and $y$ 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 $k$
grows with each trade[^4]. With the fee $c$ on the input, whoever hands over $\Delta x$ receives

$$
\Delta y = \frac{y \, (1 - c)\, \Delta x}{x + (1 - c)\, \Delta x},
$$

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

```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")
```
```output
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 \cdot 10^{-7}$, is the rounding of the data to six decimals), and $k$ 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:

```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})")
```
```output
   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.* Curve rising with trade size on a log scale, from 1,000 to 1,000,000 USDQ. Up to about 10,000 USDQ the premium barely exceeds the fee (0.30%); at 5,000 USDQ, the threshold MEV searchers look at, it is 0.34%; at 1,000,000 USDQ it reaches 7.8%.

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)[^5]. 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
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 it[^6]. 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
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.

```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%}")
```
```output
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:

```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}")
```
```output
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.* Horizontal bars in thousand USD: pool fees 21.0; block builder tip 11.0; attacker net profit 6.0 (highlighted); gas 2.1. The attacker keeps about 15% of what the victims lose.

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:

```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)
```
```output
  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.* Six points with ±0.042 bars. The dashed line marks the correlation planted in net flow, −0.06. Only XEQ net flow (−0.097, highlighted) is far from zero; XBQ net flow (−0.034) cannot be told apart from the four controls, between −0.035 and 0.035.

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

<aside class="callout" data-kind="aviso">

Before using an on-chain metric as a signal: check that it is point-in-time (vendors relabel exchange addresses and
recompute the history, lesson 1.3.5), test it against controls and against the number of metrics tried, and measure it
net of costs. A correlation of −0.06 explains 0.36% of the next day's variance.

</aside>

## 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.**

```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))
```
```output
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.

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

```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))
```
```output
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.

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

[^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](https://docs.deribit.com/api-reference/market-data/public-get_instruments), 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_data` and “Rate Limits”
    (default credits for requests that do not touch the matching engine),
    [docs.deribit.com/articles/rate-limits](https://docs.deribit.com/articles/rate-limits), accessed on October 22,
    2026.
[^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](https://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 $k$
    grow, [docs.uniswap.org](https://docs.uniswap.org/contracts/v2/concepts/advanced-topics/fees), accessed on
    October 22, 2026.
[^5]: ethereum.org, “Maximal extractable value (MEV)”: searchers, builders, proposer-builder separation and
    sandwiches, [ethereum.org](https://ethereum.org/en/developers/docs/mev/), accessed on October 22, 2026. The
    reference paper on transaction reordering on DEXs is Daian et al., “Flash Boys 2.0”
    ([arXiv:1904.05234](https://arxiv.org/abs/1904.05234)).
[^6]: Flashbots, “Flashbots Protect”: transactions are sent to a private mempool, hidden from frontrunning and
    sandwich bots, [docs.flashbots.net](https://docs.flashbots.net/flashbots-protect/overview), accessed on October
    22, 2026.

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

   - A. 192,000 USD in dollars
   - B. 3 XBQ
   - C. 0.75 XBQ, worth 192,000 USD at that price
   - D. 1 XBQ, the call's maximum

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

   - A. Because its payoff in coin, (K − S)/S, grows without limit when the price falls, while the coin collateral loses value in dollars
   - B. Because its premium is lower than the call's
   - C. Because inverse puts cannot be closed before expiry
   - D. Because they settle in dollars

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

   - A. Selling volatility is a safe income of 9.5 points
   - B. The index is miscalculated
   - C. The premium only exists on XEQ
   - 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

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

   - A. It finds the 54 sandwiches and nothing else
   - B. It finds the 54 and another 54 false positives: an arbitrageur's round trips with no victim in between
   - C. It finds none
   - D. It finds half

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

   - A. It is a significant signal at 1%
   - B. It proves that exchange inflows anticipate nothing
   - C. That the planted signal was zero
   - D. It cannot be distinguished from the controls (change in addresses and in transactions), which give correlations of the same magnitude by chance

   *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

### Research

- [Uses of Volatility and Current Volatility - VIX1D](https://pythonparatrading.com/en/uses-of-volatility/) · Research-0003 · 5 min

### Documentation

- [Deribit: public/get_book_summary_by_currency (option chain fields)](https://docs.deribit.com/api-reference/market-data/public-get_book_summary_by_currency) · docs.deribit.com
- [Deribit: public/get_volatility_index_data (DVOL, candles and continuation)](https://docs.deribit.com/api-reference/market-data/public-get_volatility_index_data) · docs.deribit.com
- [Uniswap v2: how the constant product and its fees work](https://docs.uniswap.org/contracts/v2/concepts/protocol-overview/how-uniswap-works) · docs.uniswap.org
- [ethereum.org: maximal extractable value (MEV), searchers, builders and sandwiches](https://ethereum.org/en/developers/docs/mev/) · ethereum.org

### Books

- [Daian et al. (2019): Flash Boys 2.0, frontrunning and transaction reordering on DEXs](https://arxiv.org/abs/1904.05234) · arxiv.org

### Data

- [Level 3 data: weekly inverse options of boreal (Parquet)](https://pythonparatrading.com/curso/datos/nivel-3/cripto3_opciones.parquet) · Download · cripto3_opciones.parquet

Previous lesson: [3.7.2 · Funding arbitrage, basis with futures and cross-exchange arbitrage](https://pythonparatrading.com/en/python-trading-course/level-3/funding-basis-cross-exchange-arbitrage/)

Next lesson: [3.7.4 · ccxt for trading on multiple exchanges](https://pythonparatrading.com/en/python-trading-course/level-3/ccxt-multi-exchange-trading/)
