Realistic costs & slippage
A costless backtest always lies. Add commission, spread and slippage to the vectorised engine and watch a 'great' strategy turn ordinary — or dead.
Here is the most important lesson in this entire part: a backtest with zero costs is not optimistic, it is wrong. Every time you enter or exit a position you pay the spread, you pay slippage, and you pay commission. For a strategy that trades often, those costs are not a rounding error — they are frequently the difference between a printing press and a slow bleed.
The three costs, and how to model each
- Commission — the broker's fee, per trade or per share. Easy to model: a fixed fraction of notional traded.
- Spread — the gap between bid and ask. A market order to buy fills at the ask and to sell at the bid, so a round trip crosses the spread once. You pay roughly half the spread on each side.
- Slippage — the difference between the price you *expected* and the price you *got*, from the market moving between decision and fill. It grows with order size and market volatility, and shrinks with liquidity.
In a vectorised backtest we bundle these into a single cost per unit of turnover, expressed in basis points (1 bp = 0.01%). The key insight is that cost is paid on *changes* in position, not on holding it. If you are long today and long tomorrow, you paid nothing to stay long. You only pay when the position moves.
Turnover: the quantity costs attach to
Turnover on day *t* is the absolute change in position: |position_t − position_{t-1}|. For our 0/1 MA-cross that is 1 on the days we flip and 0 otherwise. Multiply turnover by the cost rate and subtract it from the strategy return — that is the whole model.
class="c"># Cost assumptions, in basis points of traded notional (per side)
COMMISSION_BPS = class="n">1.0 class="c"># class="n">1 bp broker fee
SPREAD_BPS = class="n">3.0 class="c"># ~half-spread paid crossing the book
SLIPPAGE_BPS = class="n">2.0 class="c"># average adverse move before the fill
cost_per_turnover = (COMMISSION_BPS + SPREAD_BPS + SLIPPAGE_BPS) / 1e4 class="c"># -> class="n">0.0006
class="c"># Turnover: how much the position changed today (>class="n">0 only when we trade)
turnover = position.diff().abs().fillna(position.abs())
class="c"># Costs are charged on the days we actually trade
costs = turnover * cost_per_turnover
class="c"># Net strategy return = gross return minus the cost of trading
gross_ret = position * market_ret
net_ret = gross_ret - costs
equity_gross = (class="n">1 + gross_ret).cumprod()
equity_net = (class="n">1 + net_ret).cumprod()
n_trades = int((turnover > class="n">0).sum())
print(fclass="s">"Trades: {n_trades}")
print(fclass="s">"Gross total return: {equity_gross.iloc[-class="n">1] - class="n">1:.class="n">1%}")
print(fclass="s">"Net total return: {equity_net.iloc[-class="n">1] - class="n">1:.class="n">1%}")
print(fclass="s">"Cost drag: {(equity_gross.iloc[-class="n">1] - equity_net.iloc[-class="n">1]):.class="n">1%}")Run this and the two equity curves fan apart over time. Every trade shaves a little off, and because the gap compounds, a strategy that trades a few hundred times can lose a startling fraction of its paper profit to friction alone.
The frequency tax
Costs are a tax on trading frequency. A slow strategy that flips positions a dozen times a year barely notices 6 bps. A fast one that flips daily pays that 6 bps 250 times, and the drag can exceed 15% a year before the strategy earns a cent. This is why "does the edge per trade exceed the cost per trade?" is the first question you ask of any signal.
for bps in [class="n">0, class="n">2, class="n">5, class="n">10, class="n">20]:
rate = bps / 1e4
net = position * market_ret - turnover * rate
eq = (class="n">1 + net).cumprod().iloc[-class="n">1] - class="n">1
print(f"{bps:>class="n">3} bps/trade -> net total return {eq:>class="n">7.1%}")Costs are the reason vectorised backtests, done honestly, kill most ideas. But there is a whole class of strategies the vectorised approach cannot even *express* — anything with a stop-loss or dynamic sizing. For those, we need a different kind of engine.