# Transaction Costs in Backtesting: The Fee and Slippage Line That Changes the Verdict

AI agents: this is one page from QuantIDE's site, in Markdown.
Canonical HTML: https://www.quantide.io/guides/transaction-costs-backtesting-fees-slippage
Full index of Markdown pages: https://www.quantide.io/llms.txt
Full text of recent articles: https://www.quantide.io/llms-full.txt

---
*2026-11-07 · QuantIDE Team · 7 min read*

The same crossover loses 0.68% at zero cost and 13.18% at 50 basis points a trade. Costs are a per-trade tax, so turnover is the multiplier — here is the full runnable code and what it actually found.

## In brief

- The same 10/50 crossover loses 0.68% over five years at zero cost and 13.18% at 50 basis points a trade — only the cost assumption changed.
- Turnover is the multiplier: at 25 bp a trade the fast 5/20 strategy pays 19.50% in costs versus 4.25% for the slow 20/100, though the two are nearly identical at zero cost.
- Charge commission and slippage on every trade from the first run, and stress-test at double your assumed cost — a strategy that dies there was renting its edge from your fills.

## The short answer

A backtest run at zero cost is a backtest of a market that does not exist. Every real trade pays twice: a visible commission to the broker, and slippage — the gap between the price on the screen when you decide and the price you actually get. Charge both on every trade, from the first run, and the verdict on a strategy can flip. In the runnable demo below, the same moving-average crossover loses 0.68% over five years at zero cost and 13.18% at 50 basis points per trade. Nothing about the strategy changed. Only the cost assumption did.

The rule this guide teaches is simple: costs are a per-trade tax, so their damage scales with how often you trade. A slow strategy that changes position 17 times in five years barely notices 25 basis points a trade. A fast one that changes position 78 times pays 19.50% of its capital in costs alone. If you take one number away from this guide, take the trade count — it is the multiplier on every cost assumption you make.

## What counts as a transaction cost

Three things, and only the first shows up on a statement:

- **Commission.** What the broker charges per trade. This one is knowable in advance: it is printed on the broker's schedule. Use the real number, not a round one.
- **Slippage.** The difference between the price you expected and the price you got. Market orders cross the spread, so part of slippage is the bid-ask spread itself; the rest is the market moving between your decision and your fill. Larry Harris's *Trading and Exchanges* is the standard reference for how these mechanics work — spreads, order types, and why fills differ from quotes.
- **Market impact.** Your own order moving the price against you. Negligible for a small order in a liquid name, decisive for a large order in a thin one. The demo below models the first two and not the third, a limit stated in the final section.

One more cost belongs in the same conversation even though it is not charged per trade: the overfitting tax. Bailey, Borwein, López de Prado and Zhu showed that selecting a strategy because it performed best on a given dataset systematically overstates its performance — the more variations you try, the worse the overstatement. Costs and overfitting compound: a strategy tuned to look good before costs usually looks worse after them, because the tuning never saw the bill. The full end-to-end process — fixed data, one rule, costs from the first run, judgement on unseen data — is in [the QuantIDE backtesting guide](/guides/how-to-backtest-a-trading-strategy-in-python), and the validation step that catches overfitting is in [the walk-forward guide](/guides/walk-forward-validation-systematic-strategies).

## The code

The script below runs a 10/50 moving-average crossover on five years of seeded synthetic prices, at a ladder of per-trade costs from 0 to 50 basis points, then compares a fast (5/20) and a slow (20/100) version of the same rule at 25 basis points. It uses only pandas and numpy, and the data is synthetic and seeded, so you can run it exactly as written and get the same numbers. The data is a demonstration, not a real asset — no edge is assumed anywhere in it.

```python
import numpy as np
import pandas as pd

rng = np.random.default_rng(7)
N_DAYS = 1260  # ~5 years of trading days
dates = pd.bdate_range("2020-01-02", periods=N_DAYS)
rets = rng.normal(0.0004, 0.012, N_DAYS)
prices = pd.Series(100 * np.exp(np.cumsum(rets)), index=dates, name="close")

def ma_signal(close, fast, slow):
    fast_ma = close.rolling(fast).mean()
    slow_ma = close.rolling(slow).mean()
    return (fast_ma > slow_ma).astype(int).shift(1).fillna(0)

def run(close, fast, slow, cost_bps):
    pos = ma_signal(close, fast, slow)
    gross = pos * close.pct_change().fillna(0)
    # Each position change pays cost_bps on the traded notional.
    trades = pos.diff().abs().fillna(0)
    cost = trades * (cost_bps / 10_000)
    return gross - cost, int(trades.sum()), float(cost.sum() * 100)

def sharpe(r):
    return 0.0 if r.std() == 0 else r.mean() / r.std() * np.sqrt(252)

def max_dd(r):
    cum = (1 + r).cumprod()
    return (cum / cum.cummax() - 1).min()

print("Cost ladder - 10/50 crossover, 5 years, seeded synthetic data:")
print(f"{'cost/trade':>11} {'trades':>6} {'total ret':>10} {'Sharpe':>7} {'max DD':>8} {'cost paid':>10}")
for bps in [0, 5, 10, 25, 50]:
    r, n, paid = run(prices, 10, 50, bps)
    print(f"{bps:>10}bp {n:>6} {r.sum()*100:>9.2f}% {sharpe(r):>7.2f} {max_dd(r)*100:>7.2f}% {paid:>9.2f}%")

print("\nTurnover comparison at 25 bps/trade:")
for label, f, s in [("fast 5/20", 5, 20), ("slow 20/100", 20, 100)]:
    r, n, paid = run(prices, f, s, 25)
    print(f"{label:>14} {n:>6} {r.sum()*100:>9.2f}% {sharpe(r):>7.2f} {max_dd(r)*100:>7.2f}% {paid:>9.2f}%")

print("\nSame two strategies at 0 bps (costs explain the gap):")
for label, f, s in [("fast 5/20", 5, 20), ("slow 20/100", 20, 100)]:
    r, n, paid = run(prices, f, s, 0)
    print(f"{label:>14} {n:>6} {r.sum()*100:>9.2f}% {sharpe(r):>7.2f} {max_dd(r)*100:>7.2f}%")
```

Three details in the code are doing the work. First, the signal is shifted by one day, so a position is taken on the day *after* the averages cross — the strategy never trades on information from the same day's close. Second, `trades` counts every position change, entry and exit alike, and each one pays the full cost: a round trip costs twice the per-trade figure. Third, costs are subtracted from returns day by day, so they compound through the equity curve instead of being netted off at the end — the drawdown figures include the drag, as they should.

## What the run actually found

The cost ladder, for the 10/50 crossover over five years:

| Cost per trade | Position changes | Total return | Sharpe | Max drawdown | Cost paid |
| --- | --- | --- | --- | --- | --- |
| 0 bp | 25 | -0.68% | -0.01 | -21.06% | 0.00% |
| 5 bp | 25 | -1.93% | -0.03 | -21.30% | 1.25% |
| 10 bp | 25 | -3.18% | -0.05 | -21.53% | 2.50% |
| 25 bp | 25 | -6.93% | -0.12 | -22.24% | 6.25% |
| 50 bp | 25 | -13.18% | -0.23 | -24.34% | 12.50% |

Read the last column first. The arithmetic is exact: 25 position changes at 50 basis points each is 12.50% of capital paid away, and the total return falls by exactly that amount, from -0.68% to -13.18%. Costs are not a rounding error on this strategy; at 50 basis points they are the whole result. Note also that the drawdown deepens as costs rise — the strategy is not just poorer, it is harder to sit through.

Then the turnover comparison, both strategies at 25 basis points a trade:

| Strategy | Position changes | Total return | Sharpe | Max drawdown | Cost paid |
| --- | --- | --- | --- | --- | --- |
| Fast 5/20 | 78 | -32.03% | -0.54 | -33.00% | 19.50% |
| Slow 20/100 | 17 | -16.06% | -0.29 | -30.14% | 4.25% |

At zero cost the two strategies are nearly indistinguishable: -12.53% and a Sharpe of -0.21 for the fast one, -11.81% and a Sharpe of -0.21 for the slow one. The 16-point gap between them at 25 basis points is almost entirely the cost column — 19.50% paid versus 4.25%, a difference of 15.25 points. The fast strategy is not a worse strategy on this data. It is a more expensive one to run, and the backtest at zero cost had no way to say so. That is the sentence to remember: **turnover is the multiplier on your cost assumption, and a backtest without costs cannot rank strategies by turnover at all.** None of these numbers are expected returns, and none of this is investment advice — the data is synthetic and neither version has an edge on it.

## How to set your own numbers

Four habits, in order:

1. **Use the broker's real schedule.** Commission is the only cost you can know exactly. Look it up, per trade, for the instrument and the order size you will actually use, and put that number in the backtest — not 0, not a round 10 basis points because it looks tidy.
2. **Estimate slippage from the spread, then double it.** Half the bid-ask spread is a defensible starting point for a market order in a liquid name. If the strategy only survives at the optimistic number, it does not survive.
3. **Run the ladder, not a single number.** Costs of 0, 10, 25 and 50 basis points, as above. A strategy whose verdict changes between 10 and 25 basis points is a strategy whose verdict depends on your fill quality — which is the thing you control least.
4. **Budget in basis points per year, not per trade.** Multiply the per-trade cost by the expected number of position changes per year. A strategy trading 100 times a year at 10 basis points pays 10% a year before it earns anything — compare that against a realistic hope for a systematic strategy, in the 15–20% a year range.

## Where this still fails you

Honesty about the limits is part of running the test correctly:

- **Market impact is not modelled.** The script charges the same cost whether the order is tiny or enormous. If your size would move the market, per-trade costs understate the truth, and they understate it more the faster you trade.
- **Slippage is assumed constant.** Real fills are worse in fast markets and around news — exactly when a crossover strategy is most likely to be trading. A single cost number smooths away the worst days.
- **Daily bars hide the trading day.** Fills, gaps and intraday stops are all smoothed away, as in [the end-to-end backtesting guide](/guides/how-to-backtest-a-trading-strategy-in-python). A strategy whose behaviour depends on what happens inside the day needs intraday data and a harder test.
- **The split protects you once.** This demo runs on one seeded series. Run the same cost ladder on your own data, and validate it out of sample with [walk-forward validation](/guides/walk-forward-validation-systematic-strategies) before the numbers earn any trust.

## Frequently asked questions

### How many basis points should I assume for slippage?

Start from half the bid-ask spread of the instrument you trade, measured at the times of day you would actually trade it. For a liquid ETF that is often a few basis points; for a thin small-cap it can be far more. Then run the ladder above and check whether your conclusion survives doubling the assumption. If it does not, the conclusion was about your fill, not your strategy.

### Do transaction costs matter for a slow strategy?

Less, but they are never zero. The slow 20/100 crossover in this demo changed position 17 times in five years and still paid 4.25% at 25 basis points a trade. Costs matter less for slow strategies because turnover is the multiplier — but a slow strategy with a thin edge can still be entirely consumed by them.

### Should I include costs when I am just testing an idea?

Yes, from the first run. Adding them later means every idea you screened at zero cost was screened on a ranking that costs would have changed — the fast, high-turnover ideas look best exactly when costs are ignored, and they are the ones costs hurt most.

## What to do next

Take a strategy you have already backtested and add the cost line from the script above, with your broker's real commission and a slippage assumption you would be embarrassed to call optimistic. Run the ladder. If the verdict survives 25 basis points, you have a strategy worth validating — run it through [walk-forward validation](/guides/walk-forward-validation-systematic-strategies) next. If it does not survive, you have learned it in an afternoon of compute instead of a quarter of live trading, and the fix is usually fewer trades, not a better indicator.

When you want the same process without wiring up the environment yourself, QuantIDE ships with this stack already configured, including cost modelling on every backtest. See how it compares on [the QuantConnect alternative page](/quantconnect-alternative).


## Sources

- [Bailey, Borwein, López de Prado, Zhu — The Probability of Backtest Overfitting](https://papers.ssrn.com/sol3/papers.cfm?abstract_id=2326253)
- [Harris — Trading and Exchanges: Market Microstructure for Practitioners (WorldCat)](https://search.worldcat.org/title/Trading-and-exchanges-:-market-microstructure-for-practitioners/oclc/49415674)
- [QuantIDE — How to Backtest a Trading Strategy in Python, End to End](https://www.quantide.io/guides/how-to-backtest-a-trading-strategy-in-python)
- [QuantIDE — Walk-Forward Validation: The Test That Keeps a Backtest Honest](https://www.quantide.io/guides/walk-forward-validation-systematic-strategies)

*Educational content, not investment advice. Trading involves risk of loss.*