Costs and slippage
Lesson 13 · about 12 min
Every trade costs money three ways: the commission your broker charges, the spread between bid and ask that you cross to get filled, and the slippage between the price you saw and the price you got. A vectorized backtest ignores all three by default. This lesson adds them in ten lines and shows why a strategy's turnover, not its win rate, is what decides whether costs are survivable.
The three costs
Commission is explicit. US stock brokers charge from zero to about half a cent per share; futures are a few dollars per contract per side; crypto exchanges charge 0.02% to 0.1% of notional per side; forex is usually built into the spread.
Spread is paid whenever you take liquidity with a market order. A stock quoted 100.00 bid / 100.02 ask costs you one cent per share to buy at the ask, equivalently half the spread on each side of a round trip. For liquid large caps that is about 1 basis point (0.01%); for small caps, illiquid crypto or exotic forex pairs it can be 20 to 100 basis points.
Slippage is the difference between the price at decision time and the fill. It comes from latency, from your order moving the market, and from the price moving before the order arrives. It is larger at the open, around news, and for larger orders.
For a backtest, the honest approach is to lump the three into a single cost per unit of turnover, expressed in basis points of notional, and to make that number a parameter you can change.
A cost per unit traded
Recall turnover from Module 4: position.diff().abs() is the fraction of equity traded on each bar. If the total cost is c basis points of notional per side, the drag on that bar's return is turnover × c / 10,000.
import numpy as np
import pandas as pd
from src.data import synthetic_ohlcv
from src.indicators import sma
def ma_crossover_signal(close, fast=20, slow=50):
f, s = sma(close, fast), sma(close, slow)
signal = (f > s).astype(float)
signal[s.isna()] = np.nan
return signal
def backtest_with_costs(close: pd.Series, signal: pd.Series, cost_bps: float = 0.0) -> pd.DataFrame:
"""Close-to-close backtest charging cost_bps of notional on every unit of turnover."""
asset_ret = close.pct_change()
position = signal.shift(1).fillna(0.0)
turnover = position.diff().abs().fillna(0.0)
gross = (position * asset_ret).fillna(0.0)
cost = turnover * cost_bps / 10_000.0
net = gross - cost
return pd.DataFrame(
{"position": position, "turnover": turnover, "gross": gross, "cost": cost, "net": net,
"equity": (1 + net).cumprod()}
)
bars = synthetic_ohlcv(1500, seed=42)
sig = ma_crossover_signal(bars["close"])
for bps in [0, 5, 10, 25, 50]:
bt = backtest_with_costs(bars["close"], sig, cost_bps=bps)
print(f"cost {bps:>3} bps: final equity {bt['equity'].iloc[-1]:.3f}, "
f"total cost drag {bt['cost'].sum():.2%}")
Because the cost is charged on turnover, a bar with no trade costs nothing and a full flip from +1 to −1 costs twice as much as an entry. The costs compound out of equity exactly as returns compound into it, which is the correct treatment.
What number to use
There is no universal answer, but there are defensible defaults for daily bars with market orders of modest size:
| Instrument | Round-trip cost, bps | Per side, bps |
|---|---|---|
| Large-cap US stocks / major ETFs | 2 to 5 | 1 to 2.5 |
| Small-cap US stocks | 20 to 60 | 10 to 30 |
| Liquid index futures | 1 to 3 | 0.5 to 1.5 |
| Major forex pairs | 1 to 3 | 0.5 to 1.5 |
| Large-cap crypto on a major exchange | 10 to 30 | 5 to 15 |
| Small-cap crypto | 50 to 200 | 25 to 100 |
Run every backtest at your best-guess cost and at double it. If the strategy survives double, it has some margin for your estimate being wrong. If it dies between the two, you do not have a strategy; you have a cost estimate.
Key idea: Charge every unit of turnover a fixed number of basis points, keep that number as a parameter, and always report results at your best estimate and at twice it. A strategy that lives only between those two numbers is not one you can trade.
Turnover is the multiplier
The drag from costs equals average turnover per year times cost per unit. Two strategies with identical gross returns and different turnover are not equally good.
def annual_turnover(position: pd.Series, bars_per_year: int = 252) -> float:
years = len(position) / bars_per_year
return float(position.diff().abs().sum() / years)
fast_sig = ma_crossover_signal(bars["close"], fast=5, slow=10)
slow_sig = ma_crossover_signal(bars["close"], fast=50, slow=200)
for name, s in [("5/10", fast_sig), ("50/200", slow_sig)]:
pos = s.shift(1).fillna(0.0)
t = annual_turnover(pos)
print(f"{name:>7}: turnover {t:5.1f} units/year -> at 10 bps that is {t * 10 / 10_000:.2%} per year")
A 5/10 crossover on daily bars trades dozens of times a year; a 50/200 trades a handful. At the same cost per trade the fast version pays many times more, and the extra gross return it would need to justify that is rarely there. This is why most retail systems that survive costs are slow.
Modelling spread and slippage separately
Sometimes you want the pieces separate: a commission in dollars per share plus slippage as a fraction of the bar's range.
def slippage_from_range(bars: pd.DataFrame, fraction: float = 0.1) -> pd.Series:
"""Slippage per unit of notional as a fraction of the bar's high-low range."""
return fraction * (bars["high"] - bars["low"]) / bars["close"]
slip = slippage_from_range(bars, fraction=0.1)
print(f"range-based slippage: median {slip.median() * 1e4:.1f} bps, 95th pct {slip.quantile(0.95) * 1e4:.1f} bps")
Range-based slippage rises automatically on volatile days, which is when real slippage rises too. To use it, replace the constant cost_bps / 10_000 with commission_rate + slip on each bar. This is more realistic and also more parameters to defend; for daily strategies the flat basis-point model is usually enough.
The costs you still are not charging
- Financing. Holding a leveraged or short position costs interest; holding perpetual futures costs or earns funding. On daily bars over long holds this can exceed trading costs.
- Market impact. Larger orders move the price. Irrelevant at small size, decisive at large size.
- Taxes. Vary by jurisdiction; a high-turnover strategy in a taxable account can lose most of its edge here.
None of these are modelled in this course, which is fine as long as you know they are missing and your size is small.
Try it: Take the 20/50 crossover and find, by trial, the cost in basis points at which its final equity falls below buy-and-hold on the synthetic data. Then do the same for the 5/10 version. The ratio between those two break-even costs is roughly the ratio of their turnovers; confirm it.
Recap
- Commission, spread and slippage combine into a cost per unit of turnover in basis points of notional.
- Charge it as
turnover × bps / 10,000subtracted from each bar's gross return. - Report every result at the best-guess cost and at double it.
- Annual turnover times cost is the drag; slow strategies survive costs, fast ones rarely do.
- Financing, impact and taxes are still unmodelled; keep size small until they are.
See it drawn
Original diagrams for the ideas on this page. Illustrative, not real market data.