---
title: "3.7.1 Perpetuals: funding, mark price and liquidations"
description: "Funding rebuilt with Binance's formula, index and mark versus last price, and a liquidation engine, with the wick hours of four exchanges."
url: "https://pythonparatrading.com/en/python-trading-course/level-3/perpetual-futures-funding-mark-price-liquidations/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-3/perpetual-futures-funding-mark-price-liquidations.png"
---

# 3.7.1 · Perpetuals: funding mechanics, mark price and liquidations

> A perpetual has three prices: the index of the spot markets, the mark computed by the exchange and the last price of the order book. Funding comes from the first and the third; liquidation, from the second. This lesson rebuilds the three pieces on four synthetic exchanges and measures what happens in the hours when the last price breaks away from the mark.

- 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: 70 min
- Prerequisites: [1.2.6 · Crypto: spot, perpetuals, funding and custody](https://pythonparatrading.com/en/python-trading-course/level-1/crypto-spot-perpetuals-and-funding/), [2.3.7 · Crypto-specific strategies: momentum, funding arbitrage and basis](https://pythonparatrading.com/en/python-trading-course/level-2/crypto-momentum-funding-basis-strategies/), [2.4.7 · Cost modeling: commissions, bid-ask spread, impact, financing and securities borrowing](https://pythonparatrading.com/en/python-trading-course/level-2/backtest-transaction-cost-modeling/)

## By the end of this lesson

- Rebuild the published funding with Binance's formula (average premium, interest of 0.01% per period, ±0.05% clamp and each exchange's cap) and explain where the premium comes from.
- Distinguish index, mark and last price, and explain why the index is a median of spot prices and the mark does not follow a brief wick in the order book.
- Compute the liquidation price and the bankruptcy price of an isolated position from the maintenance condition, and describe the insurance fund and ADL cascade.
- Measure on 1-hour bars how many leveraged positions the mark liquidates, how many the last price would liquidate and how many are seen with daily closes only.
- Recover from aggregated data the rule with which the engine liquidates open interest in a sell-off.

## Three prices for a contract that never expires

Lesson 1.2.6 introduced the perpetual: a future without expiry that is anchored to spot with funding payments every
8 hours. Lesson 2.3.7 measured its funding and the cash-and-carry that collects it. This one goes down a level, to the
mechanics that decide how much is paid and when a position is forcibly closed, and for that it is necessary to separate
three prices that look like one on a trading screen:

- **Index.** A weighted average (or median) of the spot prices of several exchanges. It is not traded: the
  perpetual's exchange computes it from external sources.
- **Last price.** That of the last trade in the perpetual's order book. It is the one seen on the candlestick chart.
- **Mark.** A reference price that the exchange computes from the index and the perpetual's premium. It is used to
  value unrealized PnL and to decide liquidation.

```mermaid
flowchart TB
  S1[alfa spot] --> IX[Index]
  S2[boreal spot] --> IX
  S3[cenit spot] --> IX
  LB[Perp order book] --> UL[Last price]
  LB --> PR[Premium]
  IX --> PR
  PR --> FU[8 h funding]
  IX --> MK[Mark]
  PR --> MK
  MK --> LQ[Liquidation]
  MK --> PN[Unrealized PnL]
```

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

The data is **synthetic**: two fictitious assets, XBQ (mimics BTC) and XEQ (mimics ETH), on four fictitious
exchanges, from 2020 to 2025. `alfa` mimics Binance, `boreal` Deribit, `cenit` BitMEX and `dedalo` FTX, which goes
bankrupt in November 2022 (lesson 3.7.2). Funding follows the formula published by Binance, with caps specific to each
exchange; the liquidation wicks, the dislocations between exchanges and the liquidation engine's rule are planted with
known parameters. The rules of the real exchanges come from their documentation (in the resources); the figures, from
the data.

</aside>

```python
import numpy as np
import pandas as pd

fu = pd.read_parquet("cripto3_funding.parquet")
cd = pd.read_parquet("cripto3_diario.parquet")
ex = pd.read_csv("cripto3_exchanges.csv")
print(fu.columns.tolist())
print(fu.groupby("exchange")["ts"].agg(["min", "max", "size"]))
print(ex.loc[ex["evento"] != "ficha", ["ts_utc", "evento"]]
      .to_string(index=False))
```
```output
['ts', 'exchange', 'activo', 'precio_indice', 'precio_mark', 'ultimo', 'prima_media', 'tasa_funding', 'interes_abierto_usd']
                               min                       max   size
exchange
alfa     2020-01-01 08:00:00+00:00 2025-12-31 16:00:00+00:00  13150
boreal   2020-01-01 08:00:00+00:00 2025-12-31 16:00:00+00:00  13150
cenit    2020-01-01 08:00:00+00:00 2025-12-31 16:00:00+00:00  13150
dedalo   2020-01-01 08:00:00+00:00 2022-11-11 08:00:00+00:00   6272
          ts_utc                evento
2022-11-06 00:00               rumores
2022-11-08 12:00 retiradas_suspendidas
2022-11-11 14:00      cese_negociacion
2022-11-11 23:00               quiebra
```

`cripto3_funding.parquet` has one row per exchange, asset and 8-hour period (00:00, 08:00 and 16:00 UTC): the index,
the mark and the last price at that instant, the period's average premium, the funding rate and the open interest.
There are 13,150 rows per exchange (two assets over six years) and 6,272 on `dedalo`, which stops trading on November
11, 2022.

The index in the data is the **median** of the available spot prices; `dedalo` drops out of it when withdrawals are
suspended. Binance documents a somewhat different index, a weighted average of several exchanges, but with the same
underlying idea: if a source deviates more than 3% from the median of all of them, its price is clipped to 1.03 or
0.97 times the median, and a source that has not updated in five minutes gets a weight of zero[^1]. The daily close
lets us check why it matters:

[^1]: Binance, “What Are Mark Price and Price Index in USDS-Margined Futures?”: index as a weighted average of the spot
prices of several exchanges, outliers clipped to ±3% of the median (±1% for some symbols) and zero weight for sources
not updated in five minutes (link in the resources).

```python
x = cd[cd["activo"] == "XBQ"]
sp = x.pivot(index="fecha", columns="exchange",
             values="cierre_spot")
abiertas = x.pivot(index="fecha", columns="exchange",
                   values="retiradas_abiertas")
cuentan = sp.where(abiertas == True)       # dedalo drops out on suspension
indice = cuentan.median(axis=1)
media_4 = sp.mean(axis=1)                   # naive average, with dedalo
dias = ["2020-03-12", "2022-11-08", "2022-11-09", "2022-11-10",
        "2025-10-10"]
tabla = 100 * np.log(sp.loc[dias].div(indice[dias], axis=0))
tabla["media_4"] = 100 * np.log(media_4[dias] / indice[dias])
tabla.round(2)
```
```output
exchange    alfa  boreal  cenit  dedalo  media_4
fecha
2020-03-12 -0.03    0.03  -5.45    0.07    -1.32
2022-11-08  0.03   -0.01   0.00   -1.74    -0.42
2022-11-09 -0.00    0.01   0.00  -10.44    -2.51
2022-11-10  0.00   -0.03   0.04  -16.95    -3.97
2025-10-10 -3.96    0.00   0.08     NaN    -1.28
```

The table gives, in %, the distance of each spot price from the index at the close (23:00 UTC) of five days. On March
12, 2020 the `cenit` spot trades 5.45% below the others (a planted four-hour dislocation); on October 10, 2025, the
`alfa` one, 3.96%. From November 8 to 10, 2022, `dedalo` sinks to a 16.95% discount with withdrawals suspended: its
balances can no longer be taken out and its price stops being a market price. The median does not notice any of the
three episodes. A simple average of the spot prices would have moved 1.32%, 3.97% and 1.28%: with an index like that,
a dislocation on a single exchange would move the mark, the PnL and the liquidations of all the others.

## Funding as the data computes it

The funding formula in the data is the one Binance publishes for its linear perpetuals, which lesson 1.2.6 rebuilt for
a single coin:

$$
F = P + \operatorname{clamp}(I - P,\ -0.05\,\%,\ 0.05\,\%)
$$

with $I = 0.01\,\%$ per 8-hour period (0.03% daily) and $P$ the period's average premium. If $F > 0$ longs pay shorts;
if it is negative, the other way around. What lesson 1.2.6 did not see is where $P$ comes from. Binance does not
measure the premium with the last price, which can move with a single small trade, but with **impact prices**: the
average price at which a fixed notional would execute against the order book, the *impact margin notional*, which is
200 USDT divided by the initial margin at maximum leverage[^2]. The premium at each instant is

$$
P_t = \frac{\max(0,\ \text{impact bid} - \text{index}) - \max(0,\ \text{index} - \text{impact ask})}{\text{index}}
$$

[^2]: Binance, “Introduction to Binance Futures Funding Rates”: premium with impact prices, one sample every 5
seconds, time-weighted average of the period with increasing weights (1, 2, 3… n) over the 5,760 samples of 8 hours,
default interval of 8 hours (00:00, 08:00 and 16:00 UTC), amount = notional (mark × size) × rate, and caps of ±0.75
times the maintenance rate on the main symbols (±2% on the rest). The caps in the data are specific to each fictitious
exchange.

```python
# example order book of the perpetual: (price, quantity in XBQ)
bids = [(64990, 0.15), (64985, 0.10), (64975, 0.20), (64960, 0.40)]
asks = [(65010, 0.12), (65020, 0.10), (65035, 0.25), (65050, 0.50)]
indice_ej = 64950.0
nocional_impacto = 200 / (1 / 125)   # 200 USDT / margin at 125x


def precio_impacto(niveles, nocional):
    """Average price of executing `nocional` USDT against the book."""
    resto, coste, cant = nocional, 0.0, 0.0
    for p, q in niveles:
        tomo = min(q, resto / p)
        coste, cant = coste + tomo * p, cant + tomo
        resto -= tomo * p
        if resto <= 1e-9:
            return coste / cant
    raise ValueError("the order book does not cover the impact notional")


ib = precio_impacto(bids, nocional_impacto)
ia = precio_impacto(asks, nocional_impacto)
prima = (max(0, ib - indice_ej) - max(0, indice_ej - ia)) / indice_ej
print(f"impact notional: {nocional_impacto:,.0f} USDT")
print(f"impact bid {ib:,.2f}  impact ask {ia:,.2f}")
print(f"premium: {1e4 * prima:.2f} bp")
```
```output
impact notional: 25,000 USDT
impact bid 64,983.45  impact ask 65,023.30
premium: 5.15 bp
```

With a maximum leverage of 125x (initial margin of 0.8%), the impact notional is 25,000 USDT. The impact bid, what one
would collect per unit selling that notional, is 64,983.45, and it sits above the index: the perpetual's entire order
book trades rich and the premium is +5.15 basis points. If the index were between the impact bid and the impact ask,
the premium would be zero, even if the last price moved: the **entire** order book has to shift, not a single trade.

The period's average is not simple either: recent samples weigh more, so that funding reacts sooner to a change in the
premium at the end of the period.

```python
n = 5760                          # one sample every 5 s over 8 hours
p = np.full(n, 2e-4)              # 2 bp for 7 hours…
p[-n // 8:] = 12e-4               # …and 12 bp in the last hour
w = np.arange(1, n + 1)
print(f"simple average: {1e4 * p.mean():.2f} bp")
print(f"weighted average 1..n: {1e4 * (w * p).sum() / w.sum():.2f} bp")
```
```output
simple average: 3.25 bp
weighted average 1..n: 4.34 bp
```

One hour of premium at 12 basis points after seven at 2 gives a simple average of 3.25 and a weighted one of 4.34. The
difference can decide whether the period falls inside or outside the band in which funding is fixed. The course data
simplifies here: `prima_media` is the simple average of the period's eight hourly premiums. With that column, the rule
is checked on the four exchanges at once, each with its own cap:

```python
I, RECORTE = 0.0001, 0.0005
TOPE = {"alfa": 0.0075, "boreal": 0.005, "cenit": 0.00375,
        "dedalo": 0.005}
P = fu["prima_media"]
tope = fu["exchange"].map(TOPE)
calc = (P + (I - P).clip(-RECORTE, RECORTE)).clip(-tope, tope)
print("maximum difference:", (calc.round(6) - fu["tasa_funding"])
      .abs().max())
tasa = fu["tasa_funding"]
resumen = fu.assign(
    base=tasa == I, negativo=tasa < 0,
    en_tope=tasa.abs() >= tope - 1e-12,
).groupby(["exchange", "activo"]).agg(
    base_pct=("base", "mean"), neg_pct=("negativo", "mean"),
    minimo_pb=("tasa_funding", "min"),
    maximo_pb=("tasa_funding", "max"), en_tope=("en_tope", "sum"))
resumen[["base_pct", "neg_pct"]] *= 100
resumen[["minimo_pb", "maximo_pb"]] *= 1e4
resumen.round(1)
```
```output
maximum difference: 0.0
                 base_pct  neg_pct  minimo_pb  maximo_pb  en_tope
exchange activo
alfa     XBQ         77.6      5.6       -5.8       20.2        0
         XEQ         69.3      7.4       -6.5       29.2        0
boreal   XBQ         78.0      4.1       -5.2       22.2        0
         XEQ         70.7      5.7       -6.2       29.6        0
cenit    XBQ         75.8      8.2       -7.7       21.7        0
         XEQ         68.8      8.9       -7.5       28.5        0
dedalo   XBQ         70.3      3.9      -50.0       20.6        1
         XEQ         56.9      5.1      -50.0       29.8        2
```

All 45,722 rates match the formula without error. The figure at the top draws the rule with the XBQ periods on
`alfa`: while the average premium is between −4 and +6 basis points, the clamp does not act and $P + (I - P)$ is exactly
$I$; outside the band, funding is the premium minus 5 basis points above, or plus 5 below. 77.6% of XBQ periods and
69.3% of XEQ ones pay that exact basis point. 5.6% and 7.4% are negative. `cenit` has more negative periods (8.2% and
8.9%) because its perpetual trades, on average, slightly cheaper.

The caps only act on `dedalo`: three periods after the suspension of withdrawals, with the premium collapsed, pay the
minimum of −0.50%, −50 basis points in eight hours (lesson 3.7.2). On the other exchanges funding does not get close to
its cap; the maximum, between 20 and 30 basis points, comes in the weeks of euphoria.

```python
alfa = fu[fu["exchange"] == "alfa"]
pagado = (100 * alfa.groupby(["activo", alfa["ts"].dt.year])
          ["tasa_funding"].sum()).unstack().round(1)
print("funding paid by a long, % of notional:")
print(pagado.to_string())
xbq = alfa[alfa["activo"] == "XBQ"].set_index("ts")
print(f"XBQ funding skewness: {xbq['tasa_funding'].skew():.1f}")
```
```output
funding paid by a long, % of notional:
ts      2020  2021  2022  2023  2024  2025
activo
XBQ     25.6  24.4  10.4  11.8  11.2   9.7
XEQ     52.9  38.3  11.7  12.4  10.9   9.4
XBQ funding skewness: 3.7
```

An XBQ long on the `alfa` perpetual paid 25.6% of its notional in 2020 and 24.4% in 2021, and between 9.7% and 11.8%
in the following years, close to the floor of almost 11% set by the $I$ term. On XEQ, 52.9% in 2020. The distribution
is very skewed (3.7): almost always the band's basis point, and every now and then weeks of very high funding. That 11%
floor is not a market price: it is a convention of the formula, and it is the reason why a leveraged long on a
perpetual is expensive to hold for months (lesson 1.2.6) and why the cash-and-carry collects something even in calm
markets (lesson 3.7.2).

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

Funding is paid on the notional at the mark at the instant of payment, not on the entry notional. Whoever holds the
position open at 00:00, 08:00 or 16:00 UTC pays or collects the whole period, even if it was opened a minute before;
whoever closes it a minute before, nothing. In a backtest, funding is applied at those exact times and with the
position at that instant.

</aside>

## Mark price: the price that decides

Binance defines the mark of its linear perpetuals as the median of three prices[^3]: the index adjusted by the last
funding in proportion to the time left until the next payment (price 1), the index plus the 30-second moving average
of the difference between the order book midpoint and the index (price 2), and the contract's last price. A median of
three ignores the extreme, and the 30-second average dilutes what is short-lived:

[^3]: Binance, “What Are Mark Price and Price Index in USDS-Margined Futures?”: mark = median(price 1, price 2,
contract price); under extreme conditions it may use only price 2. The mark is used for liquidations and unrealized
PnL; realized PnL, with executed prices.

```python
def mark_binance(indice, medio, ultimo, funding, horas_al_pago):
    """Median(P1, P2, last) with the 30 s average of the basis."""
    p1 = indice * (1 + funding * horas_al_pago / 8)
    base = pd.Series(medio - indice).rolling(30, min_periods=1).mean()
    p2 = indice + base.to_numpy()
    return np.median(np.vstack([p1, p2, ultimo]), axis=0)


ind = np.full(600, 100_000.0)                # 10 minutes, 1 per s
casos = [("5 s wick", 5), ("5 min dislocation", 300)]
for nombre, dura in casos:
    medio = ind * 1.0002
    medio[120:120 + dura] *= 0.96            # the order book falls 4%
    ultimo = medio.copy()
    mk = mark_binance(ind, medio, ultimo, 0.0001, 4)
    print(f"{nombre}: last {100 * (ultimo.min() / 1e5 - 1):+.2f}%,"
          f" mark {100 * (mk.min() / 1e5 - 1):+.2f}%")
```
```output
5 s wick: last -3.98%, mark -0.65%
5 min dislocation: last -3.98%, mark -3.98%
```

With a five-second decline in the order book, the last price falls 3.98% and the mark, 0.65%: the 30-second average
only picks up a sixth and the median discards the extreme. With a five-minute decline, the mark falls as much as the
last price: the average has already absorbed it entirely and two of the three prices coincide. The mark protects
against wicks, not against an order book that really shifts. Only the index, which comes from other exchanges,
protects against that.

The data uses a simpler version: mark = index × (1 + smoothed premium), with each hour's premium averaged with the
previous hour's. Since the mark starts from the index, a dislocation of an exchange's spot and perpetual moves its last price
and not its mark:

```python
fu["mark_indice_pb"] = 1e4 * np.log(fu["precio_mark"]
                                    / fu["precio_indice"])
fu["ultimo_mark_pb"] = 1e4 * np.log(fu["ultimo"] / fu["precio_mark"])
vivos = fu[fu["exchange"] != "dedalo"]
print(vivos.groupby("exchange")[["mark_indice_pb", "ultimo_mark_pb"]]
      .agg(["std", "min", "max"]).round(1))
peores = vivos.nsmallest(2, "ultimo_mark_pb")
print(peores[["ts", "exchange", "activo", "ultimo_mark_pb",
              "mark_indice_pb"]].round({"ultimo_mark_pb": 1,
                                        "mark_indice_pb": 1})
      .to_string(index=False))
```
```output
         mark_indice_pb             ultimo_mark_pb
                    std   min   max            std    min   max
exchange
alfa                5.8 -13.5  36.1            3.6  -19.2  16.9
boreal              5.8 -11.4  36.1            3.6  -16.7  17.4
cenit               5.8 -12.9  34.8            8.3 -660.8  16.5
                       ts exchange activo  ultimo_mark_pb  mark_indice_pb
2020-03-13 00:00:00+00:00    cenit    XEQ          -660.8            -4.5
2020-03-13 00:00:00+00:00    cenit    XBQ          -548.3            -5.3
```

On the three exchanges that do not go bankrupt, the mark deviates from the index with a standard deviation of 5.8
basis points and never more than 36: it is the perpetual's premium, the same one that drives funding. The last price
deviates from the mark somewhat less (a standard deviation of 3.6 basis points on `alfa` and `boreal`), except on
`cenit`, where the standard deviation rises to 8.3 because of a single instant: on March 13, 2020 at 00:00, in the
middle of the dislocation, its XEQ last price is 660.8 basis points (6.6%) below the mark, and the mark, only 4.5 below
the index. A 20x XEQ long on `cenit` would have seen on the chart a decline that liquidates its position; the engine,
which looks at the mark, does not liquidate it because of that.

## The liquidation engine

Binance describes the liquidation condition like this: the position is liquidated when its collateral, the initial
margin plus realized and unrealized PnL (at the mark), falls below the **maintenance margin**[^4]. For an isolated
linear position of quantity $q$ opened at $E$, with margin $q E / L$ and maintenance rate $m$, the liquidation mark of
a long comes from equating collateral and maintenance:

$$
\frac{qE}{L} + q\,(M - E) = m\,q\,M \quad\Rightarrow\quad M_{liq} = E\,\frac{1 - 1/L}{1 - m}
$$

and that of a short, $E\,(1 + 1/L)/(1 + m)$. The **bankruptcy price** is the one at which the collateral reaches zero:
$E\,(1 - 1/L)$ for the long. Between the two lies the maintenance margin, which is what the engine has to close the
position without losses for anyone.

[^4]: Binance, “Binance Futures Liquidation Protocols”: collateral = initial margin + realized PnL + unrealized PnL <
maintenance margin; cancellation of open orders; an IOC order to reduce the shortfall; liquidation fee for Binance
unless the position goes bankrupt; bankrupt positions pass to the insurance fund and, if it is not enough, to
auto-deleveraging (ADL). Each exchange's maintenance rate also depends on the size of the position, in tiers; the
lesson uses a fixed 0.5%.

```python
def precio_liquidacion(entrada, apal, mmr=0.005, lado="largo"):
    """Linear isolated margin: collateral = maintenance margin.
    No commissions or funding; mmr = maintenance rate."""
    if lado == "largo":
        return entrada * (1 - 1 / apal) / (1 - mmr)
    return entrada * (1 + 1 / apal) / (1 + mmr)


filas = []
for L in (3, 5, 10, 20, 50, 100):
    filas.append({
        "apal": L,
        "liquidacion_pct": 100 * (precio_liquidacion(1, L) - 1),
        "aprox_1.2.6_pct": 100 * (-1 / L + 0.005),
        "quiebra_pct": -100 / L,
    })
pd.DataFrame(filas).set_index("apal").round(2)
```
```output
      liquidacion_pct  aprox_1.2.6_pct  quiebra_pct
apal
3              -33.00           -32.83       -33.33
5              -19.60           -19.50       -20.00
10              -9.55            -9.50       -10.00
20              -4.52            -4.50        -5.00
50              -1.51            -1.50        -2.00
100             -0.50            -0.50        -1.00
```

The approximation from lesson 1.2.6, $E\,(1 - 1/L + m)$, is almost identical to the exact one: at 10x, −9.50% versus
−9.55%. What the table teaches is how thin the margin is: at 10x bankruptcy is at −10% and liquidation half a point
before; at 100x, liquidation comes with a decline of 0.50%, which XBQ does in many hours of the year. The liquidation
order is executed in an order book that, at that moment, is usually falling. If it fills better than the bankruptcy
price, the exchange collects its fee; if it fills worse, the difference is a loss someone has to pay:

```mermaid
flowchart TB
  A[Mark at P_liq] --> B[Cancels orders]
  B --> C[IOC order]
  C --> D{Does it fill?}
  D -- yes --> E[Liquidation fee]
  D -- no --> F[Insurance fund]
  F --> G{Is it enough?}
  G -- no --> H[ADL]
```

The **insurance fund** absorbs bankrupt positions. If it is not enough, **ADL** closes opposite positions of other
clients, the ones that are winning: in a violent sell-off, someone who was short with profits can see their position
closed without having asked. For a hedged strategy (lesson 3.7.2), ADL is a concrete risk: it leaves one leg without
the other. With **cross margin**, by contrast, the whole account balance backs all positions: it holds up longer, but
one bad position can take the capital of the others.

## The wick hours

The last price and the mark separate above all in cascading sell-offs: liquidations sweep the order book, the last
price prints a low far below the rest of the market for seconds and comes back. The 1-hour bars of the `alfa`
perpetual carry both prices:

```python
h1 = pd.read_parquet("cripto3_1h.parquet")
b = h1[h1["activo"] == "XBQ"].set_index("ts")
ap_mark = b["mark_cierre"].shift(1)          # the opening mark
caida_ultimo = 100 * (b["minimo"] / b["apertura"] - 1)
caida_mark = 100 * (b["mark_minimo"] / ap_mark - 1)
mecha = 100 * np.log(b["mark_minimo"] / b["minimo"])
velas = pd.DataFrame({"caida_ultimo": caida_ultimo,
                      "caida_mark": caida_mark, "mecha": mecha})
print(f"hours with a last-price wick > 3%: {(mecha > 3).sum()}"
      f" of {len(b)}")
velas[velas["mecha"] > 3].round(1)
```
```output
hours with a last-price wick > 3%: 14 of 52608
                           caida_ultimo  caida_mark  mecha
ts
2020-03-12 10:00:00+00:00         -13.6        -9.5    4.6
2020-03-12 23:00:00+00:00         -18.7       -14.8    4.7
2020-03-13 02:00:00+00:00         -15.3       -11.3    4.6
2021-05-19 12:00:00+00:00         -17.0       -13.1    4.6
2021-05-19 13:00:00+00:00         -11.9        -7.7    4.6
2022-05-09 20:00:00+00:00         -11.0        -6.8    4.7
2022-06-13 03:00:00+00:00         -12.7        -8.6    4.6
2022-11-08 15:00:00+00:00         -11.9        -7.7    4.6
2022-11-09 19:00:00+00:00         -13.6        -9.5    4.6
2024-08-05 01:00:00+00:00         -12.7        -8.6    4.5
2025-10-10 21:00:00+00:00         -15.4       -11.4    4.6
2025-10-10 22:00:00+00:00          -0.2        -0.2    4.0
2025-10-10 23:00:00+00:00          -0.1        -0.1    4.0
2025-10-11 00:00:00+00:00          -0.1        -0.1    4.0
```

Out of 52,608 hours, only 14 have the last price's low more than 3% below the mark's. Eleven are the planted candles:
the mark falls between 6.8% and 14.8% within the hour and the last price, about 4 points more. The other three, from
October 10, 2025 at 22:00 to the 11th at 00:00, are something else: the `alfa` dislocation, with its order book 4%
below the index for hours, with hardly any movement within each hour. A wick lasts seconds; a dislocation, hours. It is
the same difference that separated the two cases of Binance's mark.

*Fig. — Hourly range of the last price and of the mark of alfa's XBQ perpetual on 8 November 2022, and liquidation price of a 10x long opened at 00:00.* Hourly vertical bars, a thick one for the last price and a thin one for the mark. Price rises to 22,400 in the morning and falls at 15:00 to a mark of 20,041. In that hour, highlighted, the last price drops to 19,140, below the dashed liquidation line of the 10x long (19,780); the mark does not reach it, so the long is not liquidated.

The figure follows November 8, 2022 hour by hour. A 10x long opened at 00:00 at 21,868.1 has its liquidation at 19,780
(the dashed line). At 15:00 the mark falls to 20,041 and does not touch it; the last price drops to 19,140 and crosses
it. With the last price, the long would have been liquidated; with the mark, it stays alive. To measure how much this
changes in general, an experiment: open a long every day at 00:00 UTC and hold it for 24 hours, with different
leverage levels, and count on how many days it is liquidated depending on the price used.

```python
b = b.assign(dia=b.index.floor("D"))
g = b.groupby("dia")
entrada = g["apertura"].first()
min_mark, min_ultimo = g["mark_minimo"].min(), g["minimo"].min()
cierre_dia = g["cierre"].last()
filas, solo_ultimo = [], {}
for L in (5, 10, 20, 50):
    pl = precio_liquidacion(entrada, L)
    por_mark, por_ultimo = min_mark <= pl, min_ultimo <= pl
    filas.append({"apal": L, "por_mark": por_mark.sum(),
                  "por_ultimo": por_ultimo.sum(),
                  "con_cierre_diario": (cierre_dia <= pl).sum()})
    solo_ultimo[L] = por_ultimo & ~por_mark
print(f"{len(entrada)} one-day longs, opened at 00:00 UTC")
print(pd.DataFrame(filas).set_index("apal").to_string())
for L in (10, 20):
    print(f"{L}x, liquidated by the last price and not by the mark:",
          [d.strftime("%Y-%m-%d") for d in
           solo_ultimo[L][solo_ultimo[L]].index])
```
```output
2192 one-day longs, opened at 00:00 UTC
      por_mark  por_ultimo  con_cierre_diario
apal
5            2           2                  1
10          13          15                  9
20         195         210                103
50        1069        1117                571
10x, liquidated by the last price and not by the mark: ['2022-11-08', '2024-08-05']
20x, liquidated by the last price and not by the mark: ['2021-06-23', '2021-07-06', '2021-11-24', '2022-01-06', '2022-03-15', '2022-07-16', '2022-07-23', '2022-08-23', '2022-11-09', '2023-01-09', '2024-01-11', '2024-03-02', '2025-02-09', '2025-02-18', '2025-04-03', '2025-06-19']
```

Three readings. First: leverage rules. At 5x, two one-day longs out of 2,192 are liquidated; at 10x, 13; at 20x, 195,
one day in eleven; at 50x, almost half. Second: the last price would liquidate somewhat more than the mark (15 versus
13 at 10x), and at 10x the two days of difference are exactly two of the planted candles, November 8, 2022 and August
5, 2024. At 20x the list is longer and made of ordinary days: the mark in the data is smoother than the last price in
any hour, not only in the wicks. Third, the most important for a backtest: **with daily closes you see 9 of the 13
liquidations at 10x and 103 of the 195 at 20x**. Days that fall and recover before the close liquidate all the same,
and leave no trace at the close.

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

A backtest of a leveraged strategy with perpetuals has to check liquidation with the **intraday low (or high) of the
mark**, not with the close or with the last price of the candles, and apply the loss of the entire margin when it
happens. If there are only last-price candles, their low somewhat exaggerates liquidations; if there are only closes,
it hides them. The first is conservative; the second, an error.

</aside>

## Aggregated liquidations: the cascade seen from outside

Public aggregators publish liquidations by hour, exchange and side, without the detail of each position.
`cripto3_liquidaciones.parquet` mimics that format (only hours with at least 250,000 USD):

```python
lq = pd.read_parquet("cripto3_liquidaciones.parquet")
dia = lq.set_index("ts")["importe_usd"].resample("D").sum() / 1e6
print(f"daily median: {dia.median():,.0f} M USD")
print((dia.nlargest(5) / 1e3).round(2).to_string())
top = lq[lq["ts"].dt.floor("D") == dia.idxmax()]
print(top.groupby("lado")["importe_usd"].sum().div(1e9).round(2))
```
```output
daily median: 63 M USD
ts
2025-10-10 00:00:00+00:00    3.08
2021-05-19 00:00:00+00:00    2.52
2025-02-13 00:00:00+00:00    1.57
2025-02-08 00:00:00+00:00    1.44
2022-05-09 00:00:00+00:00    1.28
lado
corto    0.13
largo    2.95
Name: importe_usd, dtype: float64
```

A normal day liquidates about 63 million USD across the four exchanges; the worst, October 10, 2025, 3,080 million,
96% of them longs (2.95 billion versus 0.13 of shorts). It is followed by May 19, 2021 and two days in February 2025.
Cascades are asymmetric because positioning is: in the data, the leveraged majority is usually long, and the engine's
rule liquidates more when the perpetual's premium is high (euphoria).

That rule is planted: the fraction of long open interest liquidated in an hour is
$\min(0.25,\ 1.5\,(c - 0.6\,\%)^{1.5} \times \text{load})$, with $c$ the mark's decline in the hour and a load that
rises with the premium. Can it be recovered with what an outsider would see, the aggregated liquidations, the mark of
the candles and the open interest every 8 hours?

```python
lar = lq[(lq["exchange"] == "alfa") & (lq["activo"] == "XBQ")
         & (lq["lado"] == "largo")].set_index("ts")["importe_usd"]
oi = (fu[(fu["exchange"] == "alfa") & (fu["activo"] == "XBQ")]
      .set_index("ts")[["interes_abierto_usd", "prima_media"]]
      .reindex(b.index, method="ffill"))
caida = np.log(ap_mark / b["mark_minimo"])
d = pd.DataFrame({"caida": caida, "liq": lar.reindex(b.index),
                  "oi": oi["interes_abierto_usd"],
                  "prima": oi["prima_media"]}).dropna(
                      subset=["caida", "oi"])
d["liq"] = d["liq"].fillna(0)
d["tramo"] = pd.cut(100 * d["caida"], [0, 0.4, 0.6, 1, 2, 4, 30])
print(d.groupby("tramo", observed=True).agg(
    horas=("liq", "size"),
    con_liq_pct=("liq", lambda s: 100 * (s > 0).mean()),
    fraccion_pb=("liq", "sum")).assign(
        fraccion_pb=lambda t: 1e4 * t["fraccion_pb"]
        / d.groupby("tramo", observed=True)["oi"].sum()).round(2))
s = d[(d["liq"] > 0) & (d["caida"] > 0.008) & (d["prima"] < I)]
pend, cte = np.polyfit(np.log(s["caida"] - 0.006),
                       np.log(s["liq"] / s["oi"]), 1)
print(f"exponent {pend:.2f}, constant {np.exp(cte):.2f}"
      f" ({len(s)} hours)")
```
```output
             horas  con_liq_pct  fraccion_pb
tramo
(0.0, 0.4]   37590         0.00         0.00
(0.4, 0.6]    7695         0.00         0.00
(0.6, 1.0]    5191        62.72         1.12
(1.0, 2.0]    1836       100.00         9.90
(2.0, 4.0]     233       100.00        43.16
(4.0, 30.0]     33       100.00       210.21
exponent 1.47, constant 1.29 (2176 hours)
```

By bins of the mark's decline, the threshold appears on its own: in the 45,285 hours with declines of up to 0.6% there
is not a single long liquidation on `alfa`. Between 0.6% and 1%, 62.7% of the hours have liquidations (the rest fall
below the published minimum of 250,000 USD) and 1.1 basis points of open interest are liquidated; with declines of
more than 4%, 2.1%. A log regression on the hours with liquidations, declines of more than 0.8% and a low premium (load
close to 1) gives an exponent of 1.47, versus the planted 1.5, and a constant of 1.29 versus 1.5. The constant comes
out low mainly because of a detail of the data: the published open interest is that of the last 8-hour fixing, before
the wave and therefore larger than that of the hour; the ratio ends up below the real one.

*Fig. — Fraction of long open interest liquidated in each hour against the mark drop within that hour (alfa, XBQ, 2020-2025), on log scales.* Point cloud on log axes. On the left, a highlighted vertical line at a 0.6% drop: below it there are no liquidations. From there the fraction liquidated rises with the drop, from thousandths of a point at 1% drops to several points beyond 5%, around the planted rule, drawn dashed; points sit somewhat below it.

The figure shows the cloud on a log scale with the planted rule dashed: nothing to the left of the threshold and a
fraction that grows faster than the decline. That convexity is the cascade: each liquidation sells into an order book
that is already falling, which pushes the mark a little lower and reaches the liquidation prices of the next ones. Open
interest records it:

```python
a8 = (fu[(fu["exchange"] == "alfa") & (fu["activo"] == "XBQ")]
      .set_index("ts"))
tramo = a8.loc["2021-05-19 08:00":"2021-05-21 16:00"]
pd.DataFrame({
    "oi_millones_usd": tramo["interes_abierto_usd"] / 1e9 * 1e3,
    "indice": tramo["precio_indice"],
    "oi_miles_xbq": tramo["interes_abierto_usd"]
    / tramo["precio_indice"] / 1e3,
}).round(1)
```
```output
                           oi_millones_usd   indice  oi_miles_xbq
ts
2021-05-19 08:00:00+00:00           9555.1  53271.8         179.4
2021-05-19 16:00:00+00:00           7096.1  42586.7         166.6
2021-05-20 00:00:00+00:00           7011.7  43130.2         162.6
2021-05-20 08:00:00+00:00           7223.7  43444.3         166.3
2021-05-20 16:00:00+00:00           7159.9  43973.2         162.8
2021-05-21 00:00:00+00:00           7342.9  44368.9         165.5
2021-05-21 08:00:00+00:00           7324.8  44188.5         165.8
2021-05-21 16:00:00+00:00           7379.5  44977.8         164.1
```

Between 08:00 and 16:00 on May 19, 2021, XBQ open interest on `alfa` falls from 9,555 to 7,096 million USD, 25.7%. But
the index falls 20.1% over the same interval, and open interest in USD mixes the two things: measured in coins, it goes
from 179.4 to 166.6 thousand XBQ, 7.1% less. That is the part of the positions that was closed, almost all of it
forcibly. A “deleveraging” indicator built with open interest in USD would confuse a price decline with a closing of
positions.

## What changes in a perpetuals backtest

- **Funding at its times.** It is paid at 00:00, 08:00 and 16:00 UTC on the notional at that instant's mark, with
  that instant's position. Other exchanges use other schedules and other caps, and Binance documents that it may move
  some contracts to 1- or 4-hour intervals at times of volatility.
- **Premium with impact prices.** The premium that drives funding is not that of the last price: the entire order
  book has to shift. Estimating funding with the difference between last price and index gives a noisy signal.
- **Liquidation with the intraday mark.** Neither with the close, which hides almost half of the liquidations, nor
  with the last price, which exaggerates them in the wicks. And with the loss of the entire margin, without recovery.
- **Multi-exchange index.** The mark and the PnL of an exchange depend on the spot prices of others; a local
  dislocation is not offset by the mark, and a decline in the index liquidates even if the exchange's own order book
  has not moved.
- **Open interest in coins.** In USD it mixes price and positions.
- **The exchange is a counterparty.** Insurance fund, ADL and, in the extreme, bankruptcy (lesson 3.7.2).

## Exercises

1. Repeat the experiment of the one-day longs with **shorts**: how many does the mark liquidate and how many the last
   price at 5x, 10x, 20x and 50x? Why is the difference between the two prices smaller than for longs?

**Solution.**

```python
filas = []
for L in (5, 10, 20, 50):
    pl = precio_liquidacion(entrada, L, lado="corto")
    filas.append({"apal": L,
                  "por_mark": (g["mark_maximo"].max() >= pl).sum(),
                  "por_ultimo": (g["maximo"].max() >= pl).sum()})
pd.DataFrame(filas).set_index("apal")
```
```output
      por_mark  por_ultimo
apal
5            0           0
10          22          22
20         239         254
50        1191        1232
```

At 10x, 22 shorts are liquidated with both prices; at 20x, 239 with the mark and 254 with the last price, and at 50x,
1,191 and 1,232. More shorts than longs are liquidated at 10x and 20x because over these six years XBQ rises much more
than it falls. The difference between mark and last price is proportionally smaller because the planted wicks are all
to the downside (long liquidation cascades); to the upside, the last price only separates from the mark by the normal
difference in each hour's range.

2. A long of 1 XEQ on the `alfa` perpetual, opened in the first period of 2021 with 3x leverage and held all year.
   Compute the funding paid in USD as a percentage of the initial margin and compare it with the simple sum of the
   rates. Why do they not match?

**Solution.**

```python
xeq = alfa[(alfa["activo"] == "XEQ")
           & (alfa["ts"].dt.year == 2021)].set_index("ts")
cantidad = 1.0                                  # 1 XEQ of notional
pagos = cantidad * xeq["precio_mark"] * xeq["tasa_funding"]
nominal_ini = xeq["precio_mark"].iloc[0]
margen = nominal_ini / 3
print(f"payments: {pagos.sum():,.1f} USD per XEQ; initial margin "
      f"{margen:,.1f} USD ({100 * pagos.sum() / margen:.0f}%)")
print(f"simple sum of rates: {100 * xeq['tasa_funding'].sum():.1f}%")
```
```output
payments: 913.9 USD per XEQ; initial margin 240.9 USD (379%)
simple sum of rates: 38.3%
```

The long pays 913.9 USD of funding over the year, 379% of the initial margin of 240.9 USD. The sum of rates is 38.3%,
which at 3x would be 115% of the margin. The difference is the notional: it is paid on each period's mark, and XEQ
multiplied during 2021, so the payments in the second half of the year, with the price several times higher, weigh
much more than those of January. At the same time, the position gained from the price rise; funding was the cost of
holding it with leverage instead of buying spot.

3. You have to hedge a spot portfolio with a short on the perpetual and you are torn between isolated and cross margin
   on the short leg. Explain what happens in each case if XBQ rises 50% in a week, and what risk ADL adds to the hedge
   in a sell-off.

**Solution.**

With isolated margin, the short leg only has its own margin: at 3x, its liquidation is around +33% (the lesson's
formula for shorts gives +32.7%), so a 50% rise closes it; the spot portfolio is left unhedged right after the rise
and the short leg's loss is crystallized. With cross margin, the account balance backs the position and liquidation
comes later, but only if that balance is in the same account and counts as collateral (spot does not always count, or
counts with a haircut); and one bad position can drag down the others. In both cases the defense is to top up margin
in time, for example by selling part of the spot to move cash to the short leg. ADL acts the other way around: in a
sharp sell-off, with many bankrupt longs and the insurance fund depleted, the engine closes winning positions on the
other side, and a hedging short is exactly one of them. The spot portfolio is left unhedged in the middle of the
sell-off without anyone having decided it.

## Self-assessment

1. The average premium of an 8-hour period is +4 basis points and the exchange uses Binance's formula with an interest of 0.01% per period. What funding is paid?

   - A. +4 basis points: funding is the premium
   - B. +5 basis points: premium plus interest
   - C. 0: the premium is smaller than the clamp
   - D. +1 basis point: the premium is inside the −4 to +6 bp band, and the clamp returns exactly the interest

   *Answer D: +1 basis point: the premium is inside the −4 to +6 bp band, and the clamp returns exactly the interest.* F = P + clamp(I − P, ±0.05%). With P = 0.04% and I = 0.01%, I − P = −0.03%, which does not exceed the clamp; F = 0.04% − 0.03% = 0.01%. In the data, 77.6% of XBQ periods on alfa pay exactly that basis point.

2. In the lesson's example, the perpetual's order book falls 4% for 5 seconds and comes back. Why does Binance's mark fall only 0.65%?

   - A. Because the mark uses the day's closing price
   - B. Because it is the median of the funding-adjusted index, the index plus the 30-second average of the basis, and the last price: two of the three barely move in 5 seconds
   - C. Because the exchange cancels the trades in the wick
   - D. Because the mark is computed every 8 hours

   *Answer B: Because it is the median of the funding-adjusted index, the index plus the 30-second average of the basis, and the last price: two of the three barely move in 5 seconds.* The 30-second average dilutes 5 seconds of decline to a sixth, and the median of three prices ignores the extreme. If the order book decline lasts five minutes, the 30-second average picks it up entirely and the mark falls as much as the last price: only the index, built from the spot prices of other exchanges, protects against a sustained dislocation.

3. A 10x isolated long with a maintenance rate of 0.5%. How far from entry is it liquidated, and where is its bankruptcy price?

   - A. It is liquidated at −10% and goes bankrupt at −20%
   - B. It is liquidated at −0.5% and goes bankrupt at −10%
   - C. It is liquidated at −9.55% and goes bankrupt at −10%: between the two lies the maintenance margin with which the engine closes the position
   - D. Both at −10%

   *Answer C: It is liquidated at −9.55% and goes bankrupt at −10%: between the two lies the maintenance margin with which the engine closes the position.* Liquidation comes when the collateral (margin plus PnL at the mark) equals the maintenance margin: M = E·(1 − 1/L)/(1 − mmr), −9.55%. The bankruptcy price, where the collateral is zero, is at −1/L = −10%. If the liquidation order executes worse than the bankruptcy price, the insurance fund pays the gap.

4. With one-day 10x longs, the mark liquidates 13 out of 2,192 and the last price would liquidate 15. What is special about the two days of difference?

   - A. They are two planted candles in which the last price prints a low 4.5% below the mark's: cascading liquidation wicks
   - B. They are days without data
   - C. They are weekends
   - D. They are days with negative funding

   *Answer A: They are two planted candles in which the last price prints a low 4.5% below the mark's: cascading liquidation wicks.* 2022-11-08 and 2024-08-05 have planted candles: the mark falls 7.7% and 8.6% within the hour and the last price, 11.9% and 12.7%. At 10x, only the wick of the last price crosses the liquidation price. That is why exchanges liquidate with the mark.

5. A backtest checks the liquidations of a 10x long using only each day's close. What does it find?

   - A. Fewer: 9 of the 13 that the mark liquidates hour by hour; in the other four the price recovers before the close
   - B. The same as with hourly data
   - C. More, because the close is more volatile
   - D. None, because the crypto market does not close

   *Answer A: Fewer: 9 of the 13 that the mark liquidates hour by hour; in the other four the price recovers before the close.* A liquidation happens at the mark's intraday low, not at the close. A day that falls 12% and closes 6% down liquidates the 10x and does not look like it at the close; at 20x the difference is 195 versus 103 days.

## Key points

- The funding in the data follows Binance's formula: F = P + clamp(0.01% − P, ±0.05%), with P the average premium of the previous 8 hours and each exchange's cap. It is rebuilt without error on the four exchanges.
- While the premium is between −4 and +6 basis points, funding is exactly 0.01% (77.6% of XBQ periods on alfa): almost 11% a year paid by the long in calm markets; in 2020 and 2021 it paid 25.6% and 24.4%.
- The index is a median (or a capped average) of the spot prices of several exchanges: a 5.45% dislocation on cenit does not move it; a naive average of the four spot prices would have moved 1.32%.
- The mark decides liquidation and unrealized PnL. A mark built from averages and medians ignores a wick of seconds, but not a sustained dislocation of the exchange's own order book: only the index absorbs that.
- With isolated margin, liquidation comes at E·(1 − 1/L)/(1 − mmr) and bankruptcy at E·(1 − 1/L); the gap between execution and bankruptcy is paid by the insurance fund and, if it is not enough, by ADL.
- Liquidations are measured with the intraday mark: daily closes hide almost half. From outside, aggregated liquidations reveal the engine's rule: nothing below a 0.6% decline and a fraction that grows with an exponent of ≈ 1.5.

## Resources

### Research

- [Limit Order Book - Aggregated Market Liquidity](https://pythonparatrading.com/en/limit-order-book-and-market-liquidity/) · Research-0008 · 11 min

### Documentation

- [Binance: Introduction to Binance Futures Funding Rates (formula, impact premium, intervals and caps)](https://www.binance.com/en/support/faq/introduction-to-binance-futures-funding-rates-360033525031) · binance.com
- [Binance: What Are Mark Price and Price Index in USDS-Margined Futures?](https://www.binance.com/en/support/faq/what-is-the-mark-price-and-price-index-360033525071) · binance.com
- [Binance Futures Liquidation Protocols (maintenance, insurance fund and ADL)](https://www.binance.com/en/support/faq/liquidation-protocols-360033525271) · binance.com
- [He, Manela, Ross and von Wachter: Fundamentals of Perpetual Futures (arXiv 2212.06888)](https://arxiv.org/abs/2212.06888) · arxiv.org

### Data

- [Level 3 data: funding, index and mark every 8 hours on four exchanges (Parquet)](https://pythonparatrading.com/curso/datos/nivel-3/cripto3_funding.parquet) · Download · cripto3_funding.parquet
- [Level 3 data: 1-hour bars of the alfa perpetual with last price and mark (Parquet)](https://pythonparatrading.com/curso/datos/nivel-3/cripto3_1h.parquet) · Download · cripto3_1h.parquet
- [Level 3 data: liquidations aggregated by hour, exchange and side (Parquet)](https://pythonparatrading.com/curso/datos/nivel-3/cripto3_liquidaciones.parquet) · Download · cripto3_liquidaciones.parquet

Previous lesson: [3.6.4 · Options backtesting: data, fills and event adjustments](https://pythonparatrading.com/en/python-trading-course/level-3/options-backtesting/)

Next 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/)
