The price you do not get
A backtest trades at the price in its data; a real order is filled somewhere else. Slippage is that difference: you buy a little higher than the price you saw, and sell a little lower. It has two sources. The spread: a market order buys at the ask and sells at the bid, so you pay half the spread each way relative to the mid price. And market impact: a large order uses up the best quotes and pushes the price against itself.
def fill_price(mid, side, slippage_bps):
if side == "buy":
return mid * (1 + slippage_bps / 10000)
if side == "sell":
return mid * (1 - slippage_bps / 10000)
raise ValueError("side must be 'buy' or 'sell'")
print(round(fill_price(50.0, "buy", 4), 4)) # 50.02 pays more
print(round(fill_price(50.0, "sell", 4), 4)) # 49.98 receives less# Crossing the spread: a market buy pays the ask, a market sell receives the bid
bid, ask = 49.98, 50.02
mid = (bid + ask) / 2
half_spread_bps = (ask - bid) / 2 / mid * 10000
print(mid, round(half_spread_bps, 2)) # 50.0 4.0 each way, relative to the mid price
print(round((ask - mid) / mid * 10000, 2), round((mid - bid) / mid * 10000, 2)) # 4.0 4.0Measuring it against a benchmark
To measure slippage you need a reference: the arrival price, the mid price when the order was decided. The shortfall is signed so that positive always means it cost you: for a buy, fill - arrival; for a sell, arrival - fill, in basis points of the arrival price. The same fill that is bad for a buyer is good for a seller, which is why the sign depends on the side. Large orders are filled in pieces, so the number to report is the volume-weighted average fill price.
import numpy as np
def slippage_bps(fill, arrival, side):
if side == "buy":
return (fill - arrival) / arrival * 10000
if side == "sell":
return (arrival - fill) / arrival * 10000
raise ValueError("side must be 'buy' or 'sell'")
def average_fill(prices, quantities):
prices, quantities = np.asarray(prices, dtype=float), np.asarray(quantities, dtype=float)
return float(np.sum(prices * quantities) / np.sum(quantities))
# Buying 600 shares in three pieces; the price at arrival was 50.00
avg = average_fill([50.0, 50.1, 50.3], [100, 200, 300])
print(round(avg, 4)) # 50.1833
print(round(slippage_bps(avg, 50.0, "buy"), 2)) # 36.67 bps: it cost us
print(round(slippage_bps(avg, 50.0, "sell"), 2)) # -36.67: the same fills would have helped a sellerImpact grows with size
The spread is a fixed charge; impact depends on how much you trade. A well-tested rule of thumb is the square-root law: the expected impact as a fraction of the price is about k * volatility * sqrt(quantity / ADV), where ADV is the average daily volume and k a constant near 1. Quadrupling the order only doubles the impact, so splitting a big order into pieces over time helps, but not as much as you would hope.
import math
def sqrt_impact_bps(volatility, quantity, adv, k=1.0):
return k * volatility * math.sqrt(quantity / adv) * 10000
# A stock with 2% daily volatility and 1,000,000 shares of average daily volume
for shares in (10_000, 50_000, 200_000, 800_000):
print(shares, round(sqrt_impact_bps(0.02, shares, 1_000_000), 1), "bps")
# 10,000: 20.0, 50,000: 44.7, 200,000: 89.4, 800,000: 178.9- A buy fills above the mid price and a sell below:
mid * (1 +/- bps / 10000); the spread alone costs half its width each way. - Measure against the arrival price, signed so positive means cost; partial fills are combined with a volume-weighted average.
- Impact follows the square-root law
k * volatility * sqrt(quantity / ADV): four times the size, twice the impact.
Next up: realistic order fills, simulating how orders really get filled against an order book and bar data.
Lab · what's graded
- Price the fill with
fill_price25 pts - Measure the shortfall with
slippage_bps25 pts - Blend partial fills with
average_fill25 pts - Forecast the impact with
sqrt_impact_bps25 pts