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.