How much slippage should a crypto backtest assume? Measure it for your order size
Take the slippage figure from the tape, by order size, instead of guessing one number. Look at the bad fills, not just the median.
Why it happens
The usual answer is a guess: 1 bp, 5 bps, "the spread". The cost of a taker order depends on its size compared with the trades around it, and it changes from minute to minute. The quickest honest answer is to measure it from the trades themselves: place the same order at a few hundred random moments in a day and replay each one against the prints that followed.
Read the 90th percentile as well as the median. Over a few seconds, price drifts in your favour about as often as against you, so the median can sit close to zero. The cost a strategy feels is in the tail, and the tail widens as the order grows. Measure on several days, including a volatile one, before trusting the number.
Fix it with plain pandas
import numpy as np
rng = np.random.default_rng(0)
buys = ticks[ticks["side"] == 1]
px, q = buys["price"].to_numpy(), buys["qty"].to_numpy()
starts = rng.integers(1, len(buys) - 5000, size=300) # 300 random moments in the day
for size in (0.1, 1, 5, 25): # order size in BTC
costs = []
for i in starts:
p, s = px[i:i + 5000], q[i:i + 5000]
before = np.concatenate([[0.0], np.cumsum(s)[:-1]])
used = np.where(before < size, s, 0.0) # whole prints until the size is covered
costs.append(((p * used).sum() / used.sum() / px[i - 1] - 1) * 1e4)
print(f"{size:>5} BTC buy: median {np.median(costs):.1f} bps, 90th pct {np.percentile(costs, 90):.1f} bps")
Or with Deptho
Deptho replays a whole grid of order sizes at once. Feed the measured figure into the backtest's cost attribution, and the tearsheet shows slippage next to fees and funding:
import numpy as np
import pandas as pd
import deptho as dp
t = ticks.index[np.random.default_rng(0).integers(100, len(ticks) - 100, size=300)]
grid = pd.concat([pd.DataFrame({"side": 1, "qty": size}, index=t) for size in (0.1, 1, 5, 25)])
cost = dp.slippage_bps(dp.simulate_taker_fills(ticks, grid))
print(cost.groupby("qty")["slippage_bps"].describe(percentiles=[0.5, 0.9]))
# Then backtest with the number you measured for your size, not a guess. Use the mean:
# a flat per-trade cost has to carry the tail, and the median can sit near zero.
pnl = dp.pnl_from_positions(prices, positions, funding=funding, fee_bps=5,
slippage_bps=cost.loc[cost["qty"] == 1, "slippage_bps"].mean())
Working with files bigger than RAM? Deptho Pro memory-maps them.