AlgoPro UniversityCourse home
Part 5 · Lesson

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

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.

pythonAdding costs to the vectorised backtest
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.

Equity curve — before vs after costs
Same strategy, same signals. Gross (violet) ignores trading friction; net (blue) charges 6 bps per position change. The gap is pure cost, and it compounds. This is why a costless backtest lies. Illustrative values.

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.

pythonSensitivity: how net return decays as costs rise
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.