Guides

Your crypto backtest's fill prices are too optimistic. Here's how to check them

Filling at the signal bar's close assumes you traded at a price that was already printed. Replay the orders against the trades that followed instead.

Why it happens

Most backtests fill a market order at the close of the bar that produced the signal. That price was printed before your order existed. A taker order pays whatever the next sellers were asking (for a buy), and on a fast move that is often worse. A flat slippage figure doesn't fix it: it charges a quiet Sunday the same as a sharp sell-off, and a 0.1 BTC order the same as a 25 BTC one.

A better check uses the tape. Binance's public archive has every trade on every USD-M perpetual, with the aggressor side. For each order, take the trades that came after it on the same side and fill against them until the size is covered. The volume-weighted price of those prints estimates what the order would have paid. It is still optimistic in one way: it assumes you'd have got the prices other takers got, and it doesn't model order book depth or queue position.

Fix it with plain pandas

import numpy as np

# ticks: every trade with price, qty and aggressor side (+1 buy, -1 sell)
# orders: your backtest's market orders, side (+1/-1) and qty, indexed by time
def fill_from_prints(ticks, t, side, qty):
    after = ticks[(ticks.index >= t) & (ticks["side"] == side)]
    px, q = after["price"].to_numpy(), after["qty"].to_numpy()
    before = np.concatenate([[0.0], np.cumsum(q)[:-1]])   # size filled before each print
    used = np.where(before < qty, q, 0.0)                   # whole prints until the size is covered
    return (px * used).sum() / used.sum()

pos = ticks.index.searchsorted(orders.index, side="left") - 1
ref = ticks["price"].to_numpy()[pos]                        # last trade before the order's millisecond
fill = np.array([fill_from_prints(ticks, t, s, q)
                 for t, s, q in zip(orders.index, orders["side"], orders["qty"])])
slip_bps = orders["side"] * (fill - ref) / ref * 1e4
print(slip_bps.describe())

Or with Deptho

The loop above re-scans the day for every order, which is fine for a few hundred orders and slow beyond that. dp.simulate_taker_fills runs the same replay without the loop, takes the reference price from the last print before each order, and dp.slippage_bps turns the result into a cost per order:

import deptho as dp

fills = dp.simulate_taker_fills(ticks, orders, fill_model="trade_prints")
print(dp.slippage_bps(fills)["slippage_bps"].describe())

Working with files bigger than RAM? Deptho Pro memory-maps them.

Related