Skip to content
GetProfitable
Search

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,000 subtracted 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.

Slippage on a market orderA buy order clears four price levels, so the average price paid is worse than the price first quoted.Buy 1,000 shares at marketpricesell orders resting (bar length = size)20.04300 shares20.03200 shares20.01200 shares20.00300 sharesnothing resting at 20.02order sweeps up the bookaverage fill 20.02SLIPPAGE0.02 a share$20.00 in totalintended 20.00Each level fills at its own price; the average is what you really paid.
Slippage on a market order. You click at 20.00, but only 300 shares are resting there, so the rest of the order fills at 20.01, 20.03 and 20.04. The average price paid is 20.02, and that two-cent gap is slippage.
Bid-ask spread in an order bookSell orders stacked above buy orders with a gap between the best of each.SELLERS (asks)50.0690050.051,40050.0460050.011,10050.002,30049.99800spread = 0.03BUYERS (bids)
The bid-ask spread. Buy orders sit below, sell orders above, and the gap between the best bid (50.01) and best ask (50.04) is the spread you pay to cross. Bar length shows the size resting at each price.
A fast and a slow moving average crossingA jagged price line with two smoother average lines through it; the fast average dips below the slow one on the left and cuts back above it in the middle, where a circle marks the crossing.pricefast averageslow averagefast crosses belowfast crosses abovethe slow averageAverages of recent closes; the fast one reacts sooner than the slow one.
Fast and slow moving averages crossing. A moving average is the average of the last few closing prices, redrawn each period. An average over fewer periods turns sooner than one over many, so the two lines cross whenever the recent pace of the market changes.