DEV Community

Greenhaven365
Greenhaven365

Posted on AI-assisted

Estimate crypto futures slippage from a public order-book API (Python)

A one-tick spread is not a fill. On a crypto perpetual, a marketable order walks the displayed book until its size is consumed, and the average fill (VWAP) can sit several basis points away from mid even when the best bid and best ask are a single tick apart. This tutorial pulls a public depth snapshot for OKX BTC-USDT-SWAP (no API key), converts contract size to BTC with the live face value, and walks both sides for a set of declared sizes.

The script estimates slippage from visible depth at one timestamp. It does not reserve liquidity, it does not see hidden orders, and it is not a promise of a live fill.

Not financial advice. This is a programming / market-structure exercise. Derivatives trading can liquidate your margin. Verify contract units and order-book fields against the venue you use.

What we will compute

For a marketable buy, walk the ask side from best ask upward until the requested BTC size is filled (or the snapshot is exhausted). For a marketable sell, walk the bids downward. Then:

mid          = (best_bid + best_ask) / 2
vwap         = sum(price × filled_btc) / filled_btc
slip_vs_mid  = (vwap − mid) / mid     (in basis points)
slip_vs_best = (vwap − best) / best   (in basis points)
Enter fullscreen mode Exit fullscreen mode

where best is the best ask for a buy and the best bid for a sell. A buy that walks up prints a positive slip versus mid; a sell that walks down prints a negative slip versus mid. Both are costs relative to mid if you read the sign as “away from mid.”

Two unit choices are easy to get wrong, and this script makes them explicit:

  • Size in the book is contracts, not BTC. On this instrument ctVal is 0.01 BTC and ctMult is 1, so BTC at a level is contracts × 0.01.
  • The depth request is capped. sz=400 asks for up to 400 price levels per side, not for the entire book. Depth figures at wide basis-point bands can be truncated by that cap.

A market order that fills at several prices is normal book mechanics; a short conceptual companion is at greenhaven365.com/crypto-futures-market-order-multiple-prices/. Why a tight spread still does not describe size is at greenhaven365.com/why-crypto-futures-spread-does-not-show-depth/.

Fetch the instrument and the book

import json
import urllib.request
from datetime import datetime, timezone
from zoneinfo import ZoneInfo

INST = "BTC-USDT-SWAP"
UA = {"User-Agent": "slippage-demo/1.0"}
LOCAL_TZ = ZoneInfo("Asia/Shanghai")
DEPTH_SZ = 400
SIZES_BTC = (0.10, 0.50, 1.0, 5.0, 10.0, 25.0, 50.0)
BANDS_BPS = (1, 2, 5, 10, 25, 50)


def get_payload(url: str) -> list:
    req = urllib.request.Request(url, headers=UA)
    with urllib.request.urlopen(req, timeout=20) as resp:
        payload = json.loads(resp.read().decode())
    if str(payload.get("code")) != "0":
        raise RuntimeError(payload)
    return payload["data"]


def parse_ts(ms: str | int) -> datetime:
    return datetime.fromtimestamp(int(ms) / 1000, tz=timezone.utc).astimezone(LOCAL_TZ)


def load_book() -> tuple[float, list, list, datetime]:
    inst = get_payload(
        "https://www.okx.com/api/v5/public/instruments"
        f"?instType=SWAP&instId={INST}"
    )[0]
    if inst.get("ctType") != "linear" or inst.get("ctValCcy") != "BTC":
        raise RuntimeError(f"unexpected spec: {inst}")
    ct_val = float(inst["ctVal"])
    ct_mult = float(inst["ctMult"])
    book = get_payload(
        f"https://www.okx.com/api/v5/market/books?instId={INST}&sz={DEPTH_SZ}"
    )[0]

    def levels(raw: list) -> list[tuple[float, float, float, int]]:
        # each row: [price, size_contracts, deprecated, n_orders]
        out = []
        for row in raw:
            px = float(row[0])
            contracts = float(row[1])
            btc = contracts * ct_val * ct_mult
            n_orders = int(float(row[3]))
            out.append((px, contracts, btc, n_orders))
        return out

    asks = levels(book["asks"])  # increasing price
    bids = levels(book["bids"])  # decreasing price
    when = parse_ts(book["ts"])
    print(
        f"{INST}  ctVal={ct_val} BTC  ctMult={ct_mult}  "
        f"book ts={when.isoformat()}  "
        f"levels bid={len(bids)} ask={len(asks)}  (cap {DEPTH_SZ})"
    )
    return ct_val, bids, asks, when
Enter fullscreen mode Exit fullscreen mode

get_payload sends a User-Agent and refuses a non-zero code before indexing data. The f prefixes on the instrument and book URLs matter: without them, {INST} and {DEPTH_SZ} are sent literally.

Walk the book and print slippage

def walk(levels, target_btc: float, mid: float, best: float) -> dict:
    remaining = target_btc
    notional = 0.0
    filled = 0.0
    levels_used = 0
    last_px = None
    for px, _contracts, btc, _n in levels:
        if remaining <= 0:
            break
        take = min(btc, remaining)
        notional += take * px
        filled += take
        remaining -= take
        levels_used += 1
        last_px = px
    vwap = notional / filled if filled else None
    return {
        "filled_btc": filled,
        "unfilled_btc": remaining,
        "exhausted": remaining > 1e-12,
        "vwap": vwap,
        "last_px": last_px,
        "levels_used": levels_used,
        "slip_vs_mid_bps": (vwap - mid) / mid * 1e4 if vwap else None,
        "slip_vs_best_bps": (vwap - best) / best * 1e4 if vwap else None,
    }


def depth_within(levels, mid: float, bps: float, is_ask: bool) -> float:
    cap = mid * (1 + bps / 1e4) if is_ask else mid * (1 - bps / 1e4)
    total = 0.0
    for px, _c, btc, _n in levels:
        if is_ask and px > cap + 1e-12:
            break
        if (not is_ask) and px < cap - 1e-12:
            break
        total += btc
    return total


def report() -> None:
    _ct_val, bids, asks, _when = load_book()
    best_bid, best_ask = bids[0][0], asks[0][0]
    mid = (best_bid + best_ask) / 2.0
    spread_bps = (best_ask - best_bid) / mid * 1e4
    print(
        f"best bid={best_bid}  best ask={best_ask}  mid={mid:.2f}  "
        f"spread={best_ask - best_bid:.1f} ({spread_bps:.4f} bps)"
    )
    print(
        f"top bid depth={bids[0][2]:.4f} BTC "
        f"({bids[0][1]} cts, {bids[0][3]} orders)  "
        f"top ask depth={asks[0][2]:.4f} BTC "
        f"({asks[0][1]} cts, {asks[0][3]} orders)"
    )
    print(
        f"{'side':<4} {'size':>7} {'filled':>8} {'vwap':>10} {'last':>10} "
        f"{'lvls':>5} {'vs_mid':>9} {'vs_best':>9}  exhausted"
    )
    for size in SIZES_BTC:
        for side, levels, best in (("buy", asks, best_ask), ("sell", bids, best_bid)):
            r = walk(levels, size, mid, best)
            print(
                f"{side:<4} {size:7.2f} {r['filled_btc']:8.4f} "
                f"{r['vwap']:10.2f} {r['last_px']:10.1f} {r['levels_used']:5d} "
                f"{r['slip_vs_mid_bps']:8.4f} {r['slip_vs_best_bps']:8.4f}  "
                f"{r['exhausted']}"
            )
    print("Displayed BTC depth within X bps of mid (truncated by the 400-level cap):")
    for bps in BANDS_BPS:
        d_bid = depth_within(bids, mid, bps, is_ask=False)
        d_ask = depth_within(asks, mid, bps, is_ask=True)
        print(f"  {bps:5.1f} bps   bid {d_bid:10.4f}   ask {d_ask:10.4f}")


if __name__ == "__main__":
    report()
Enter fullscreen mode Exit fullscreen mode

On 15 November 2026 at 01:41:40 (Asia/Shanghai), a live run printed:

BTC-USDT-SWAP  ctVal=0.01 BTC  ctMult=1.0  book ts=2026-11-15T01:41:40.759000+08:00  levels bid=400 ask=400  (cap 400)
best bid=65874.2  best ask=65874.3  mid=65874.25  spread=0.1 (0.0152 bps)
top bid depth=3.3968 BTC (339.68 cts, 24 orders)  top ask depth=0.9975 BTC (99.75 cts, 10 orders)
side    size   filled       vwap       last  lvls    vs_mid   vs_best  exhausted
buy     0.10   0.1000   65874.30    65874.3     1    0.0076    0.0000  False
sell    0.10   0.1000   65874.20    65874.2     1   -0.0076    0.0000  False
buy     0.50   0.5000   65874.30    65874.3     1    0.0076    0.0000  False
sell    0.50   0.5000   65874.20    65874.2     1   -0.0076    0.0000  False
buy     1.00   1.0000   65874.30    65877.4    12    0.0079    0.0003  False
sell    1.00   1.0000   65874.20    65874.2     1   -0.0076    0.0000  False
buy     5.00   5.0000   65881.91    65887.3    73    1.1623    1.1547  False
sell    5.00   5.0000   65874.17    65874.1     2   -0.0125   -0.0049  False
buy    10.00  10.0000   65884.64    65887.4    74    1.5777    1.5701  False
sell   10.00  10.0000   65872.96    65869.4    32   -0.1953   -0.1877  False
buy    25.00  25.0000   65886.95    65889.2    89    1.9278    1.9202  False
sell   25.00  25.0000   65869.65    65866.0    50   -0.6984   -0.6909  False
buy    50.00  50.0000   65889.63    65897.0   143    2.3344    2.3268  False
sell   50.00  50.0000   65865.52    65857.9   104   -1.3246   -1.3170  False
Displayed BTC depth within X bps of mid (truncated by the 400-level cap):
    1.0 bps   bid    15.2596   ask     1.1047
    2.0 bps   bid    42.8455   ask    12.1259
    5.0 bps   bid    95.9370   ask    71.1524
   10.0 bps   bid   166.0377   ask   135.2186
   25.0 bps   bid   166.0377   ask   135.2186
   50.0 bps   bid   166.0377   ask   135.2186
Enter fullscreen mode Exit fullscreen mode

A few readings of this snapshot, none of them a rule about later books:

  • The spread was 0.1 USDT, 0.0152 bps of mid — a single tick. Sizes of 0.10 and 0.50 BTC bought or sold entirely at the inside, so slippage versus mid was just half the spread.
  • A 1 BTC buy needed 12 ask levels because the top ask held only 0.9975 BTC. The VWAP barely moved (0.0079 bps versus mid). The last price touched, 65,877.4, looks worse than the average, which is the usual gap between a worst print and a size-weighted fill.
  • A 5 BTC buy walked 73 levels and printed 1.16 bps of slip versus mid — about seventy-five times the spread. The same-size sell walked only two levels and printed 0.01 bps. In this minute the bid side was heavier near mid; that asymmetry can reverse on the next request.
  • Displayed depth stopped increasing between the 10 bp and 25 bp bands. That is the 400-level cap, not a claim that the venue has no book beyond 10 bps. Do not quote the 25 bp or 50 bp columns as measured depth.

What this script does not cover

Do not treat the VWAP as a guaranteed fill. The book moves, the request is delayed, and hidden or reserved liquidity is invisible here. Fees are excluded. The walk is a single snapshot, not a queue-position model.

Do not switch the size unit mid-script, and do not compare this linear book to an inverse contract without converting face value. If you lower DEPTH_SZ, say so in the depth table, because truncation arrives sooner.

Useful follow-ups: pull books twice a few seconds apart and compare VWAPs for the same size; or walk a documented impact notional and compare it to the venue’s premium-index definition, without treating that number as a trading signal.

Sources: OKX public API public/instruments and market/books for BTC-USDT-SWAP, retrieved 2026-11-15 01:41 (Asia/Shanghai).

AI disclosure: This article was created with AI assistance (drafting/editing). The numeric snapshot was checked against a live public API response on the date above. Re-run the script and verify the venue’s current contract specifications before relying on any figure.

Disclaimer: Not financial advice. Educational / developer content only.

Top comments (0)