How Much Slippage Should You Use in a Backtest? A Python & QuantConnect Experiment

Last updated: September 27, 2026
Original experiment by TradingBotsSimplified · SPY · January 2022–December 2025

A trading strategy can look profitable in a backtest and still fail once execution costs are introduced. One of the easiest costs to underestimate is slippage: the difference between the price your backtest assumes and the price at which an order may actually execute.

But how much slippage should you use?

There is no universal number. Slippage depends on the asset, liquidity, order size, order type, time of day, volatility, and execution method.

Instead of choosing an arbitrary setting, I ran a controlled QuantConnect experiment to measure how a simple trading strategy changed as modeled slippage increased from 0 to 20 basis points per fill.

The result was striking: a strategy that returned 20.49% with zero additional modeled slippage was still profitable at 2 bps, but became unprofitable by 5 bps.

The short answer

For this experiment, the strategy’s modeled break-even point was approximately 2.69 basis points of slippage per fill.

That does not mean 2.69 bps is the correct slippage assumption for SPY, or for trading strategies generally.

It means that this particular strategy, with this position size, turnover, period, and execution logic, could absorb roughly 2.69 bps per execution before its backtested profit disappeared.

The broader lesson is more important:

The right slippage assumption is strategy-specific. A backtest should be stress-tested across multiple execution-cost assumptions rather than relying on a single arbitrary number.

What is slippage in a backtest?

Slippage is an adjustment to an order’s simulated execution price.

For example, imagine a strategy sends a market buy order when SPY is trading around $500.

With a 5-bps adverse slippage assumption:

5 bps = 0.05%

So the simulated execution would be approximately:

$500 × 1.0005 = $500.25

For a sell order, the adjustment works in the opposite direction.

That may look insignificant on one trade. Across hundreds or thousands of executions, however, small differences can compound into a substantial reduction in performance.

QuantConnect’s ConstantSlippageModel applies a constant percentage adjustment to orders, while NullSlippageModel applies zero additional slippage. See the official slippage-model documentation.

The experiment

I deliberately used a simple, non-proprietary SPY strategy so the test would focus on execution costs rather than strategy complexity.

SettingExperiment
AssetSPY
PeriodJanuary 1, 2022 through December 31, 2025
Starting capital$100,000
Resolution1 minute
Brokerage modelInteractive Brokers margin
Position size100 shares, fixed
EntryBuy 10 minutes before market close when the latest completed daily close is above its 200-day SMA
ExitSell 10 minutes after the following market open
Order typeMarket orders
Slippage tests0, 1, 2, 5, 10 and 20 bps per fill

The code and trading logic remained unchanged between tests. The only variable changed was the configured slippage assumption.

QuantConnect’s Interactive Brokers brokerage model supports US equities, applies its Interactive Brokers fee model, and uses its equity buying-power rules. For equities in a margin account, the brokerage model allows 2× leverage.

The results

The first five runs remained directly comparable: all produced 721 signals and 721 completed round trips.

Modeled slippage per fillEnding equityNet profitNet returnModeled slippage cost
0 bps$120,490.00+$20,490.00+20.490%$0
1 bp$112,861.31+$12,861.31+12.861%$7,628.69
2 bps$105,232.61+$5,232.61+5.233%$15,257.39
5 bps$82,346.53-$17,653.47-17.653%$38,143.47
10 bps$44,203.06-$55,796.94-55.797%$76,286.94

The zero-slippage baseline finished at $120,490, while the 1-bp run finished at $112,861.31. Both executed the same 721 round trips.

The 20-bps run is reported separately below. It completed only 438 round trips after insufficient buying power prevented later entries, so its return is not directly comparable with the 0–10 bps runs.

Screenshot: 0 bps baseline

QuantConnect backtest Crawling Red Orange Eagle — 0 bps modeled slippage, with backtest name and equity curve visible.
0 bps modeled slippage: The strategy returned 20.49% over the test period.

Screenshot: 5 bps

QuantConnect backtest Measured Violet Pig — 5 bps modeled slippage, with backtest name and equity curve visible.
5 bps modeled slippage: The same strategy fell to -17.65%.

One basis point cost about $7,629

The strategy generated approximately $76.29 million of gross turnover over the test period.

One basis point is:

0.01% = 0.0001

So the approximate cost of adding 1 bp to every fill is:

$76,287,064 × 0.0001 ≈ $7,628.71

That is almost exactly what the backtest produced.

The net profit changed as follows:

  • 0 bps: $20,490.00
  • 1 bp: $12,861.31
  • Difference: $7,628.69

The 1-bp backtest therefore provides a useful sanity check: the performance change matches the modeled execution cost almost dollar-for-dollar. This also illustrates why turnover matters so much.

A strategy may trade only 100 shares at a time, but if it repeatedly enters and exits positions, cumulative notional turnover can become enormous relative to initial capital.

Where did the strategy break even?

The zero-slippage strategy generated:

$20,490 of profit

Each additional basis point cost approximately:

$7,628.71

So:

$20,490 ÷ $7,628.71 ≈ 2.69 bps

The modeled break-even slippage was therefore approximately:

2.69 bps per fill

Another way to interpret this is that a round trip contains both an entry and an exit. A strategy with only a small expected profit per trade can therefore lose its entire edge from execution costs that appear tiny when viewed one order at a time.

This is why asking only whether a strategy is profitable at 0 bps can be misleading.

A more useful question is:

How much adverse execution can this strategy tolerate before its edge disappears?

Why the 0-bps fills still weren't always identical to the reference price

An interesting detail appeared in the fill audit.

Even when the configured slippage model was 0 bps, the recorded order-submission reference price and simulated fill price were occasionally slightly different.

That is not a contradiction.

QuantConnect’s equity fill model fills market buys using a best-effort ask price plus slippage and market sells using a best-effort bid price minus slippage.

In other words, the fill model and the slippage model are separate components. See QuantConnect’s equity fill-model documentation.

So setting:

security.set_slippage_model(
    NullSlippageModel.INSTANCE
)

means zero additional modeled slippage.

It does not mean every market order must execute at the exact last price observed by the algorithm.

That distinction matters when interpreting backtest execution.

What happened at 20 bps?

The 20-bps test produced a different outcome.

Instead of completing all 721 trades, the account lost enough capital that the fixed 100-share SPY entries eventually required more buying power than was available.

The first rejected entry occurred on September 13, 2024. QuantConnect reported that the trade required approximately $28,092.50 of initial margin while only about $27,985 of free margin remained.

The algorithm continued producing signals, but later entry orders were rejected.

The final statistics were:

Metric20 bps per fill — changed trade sequence
Ending equity$27,985.07
Net return-72.015%
Signals721
Completed round trips438
Intended entries rejected283
Gross turnover$41,124,536.18
Recorded slippage cost$82,248.93
First insufficient-buying-power rejectionSeptember 13, 2024

Only 438 of the intended 721 round trips were completed. The other 283 intended entries were rejected because available buying power became insufficient.

For that reason, the -72.015% result should not be compared directly with the 0–10 bps results as though all six runs executed an identical trade sequence.

Instead, the 20-bps test demonstrates something different:

Extremely adverse execution assumptions can reduce capital enough to change whether future trades can be placed at all.

Screenshot: 20 bps

QuantConnect backtest Retrospective Orange Dinosaur — 20 bps modeled slippage, with backtest name and equity curve visible.
20 bps modeled slippage: Losses reduced available buying power enough that later fixed-size entries could no longer execute.

So how much slippage should you use?

There is no single answer such as “always use 2 bps” or “always use 5 bps.”

A better process is:

  1. Measure actual execution data if you have it. Compare live order-submission prices against actual fills. Paper fills can help test the workflow, but they do not establish real execution costs.
  2. Model the instrument you actually trade. A liquid ETF, small-cap stock, futures contract and option can have very different execution characteristics.
  3. Consider order size. Slippage for 100 shares is not necessarily representative of 10,000 shares.
  4. Consider order type and timing. Market orders around the open, close or volatile news events may behave differently from orders submitted during quieter periods.
  5. Stress-test several assumptions. Instead of trusting one slippage number, test what happens at increasingly adverse execution levels.

The goal is not to find a slippage assumption that makes a backtest look good.

The goal is to understand how sensitive the strategy is to execution costs.

For how these tests fit into the deployment process, see Backtesting vs Paper Trading vs Live Trading.

Slippage can matter more than the strategy's headline return

The baseline backtest produced a respectable-looking result:

+20.49%

But that number by itself hid an important weakness.

The strategy generated over $76 million of turnover while starting with just $100,000.

Because of that turnover, every additional basis point of modeled slippage removed roughly $7,629 from the final result.

At 2 bps, the strategy remained profitable.

At 5 bps, it was clearly unprofitable.

Nothing about the trading signal changed.

Only the execution assumption changed.

That is precisely why transaction-cost modeling should be part of strategy evaluation rather than something added after a backtest already looks attractive.

Does this mean SPY has 5 bps of slippage?

No.

This experiment did not attempt to estimate the real-world slippage of a 100-share SPY market order.

The 1, 2, 5, 10 and 20-bps values were stress-test assumptions.

Their purpose was to measure the strategy’s sensitivity to execution costs.

A separate study would be required to estimate actual expected SPY slippage using real order and fill data, ideally segmented by order size, volatility, spread and time of day.

That distinction is important:

Stress testing asks: “What happens if execution is this bad?”

Calibration asks: “How bad is execution actually likely to be?”

They are related questions, but they are not the same question.

Final takeaway

The most useful output of a slippage test is not a universal number.

It is a break-even threshold.

For this SPY experiment:

  • 0 bps: +20.49%
  • 1 bp: +12.86%
  • 2 bps: +5.23%
  • Approximate break-even: 2.69 bps per fill
  • 5 bps: -17.65%
  • 10 bps: -55.80%
  • 20 bps: capital deterioration eventually prevented the strategy from maintaining its fixed 100-share position size

A strategy whose edge disappears after only a few basis points of adverse execution deserves much more scrutiny before live deployment.

Backtesting tells you what would have happened under a set of assumptions.

Slippage testing tells you how fragile those assumptions are.

Reproduce the QuantConnect experiment

The complete Python algorithm below is the source used for the final slippage experiment. Paste it into a QuantConnect Python project to reproduce the scenarios.

Change only self.slippage_bps = 20 to one of 0, 1, 2, 5, 10, or 20. Every other experiment setting and trading rule should remain unchanged.

self.slippage_bps = 20

# ...

if self.slippage_bps == 0:
    security.set_slippage_model(
        NullSlippageModel.INSTANCE
    )
else:
    security.set_slippage_model(
        ConstantSlippageModel(
            self.slippage_bps / 10_000.0
        )
    )
from AlgorithmImports import *


class SlippageSensitivityExperiment(QCAlgorithm):

    def initialize(self):

        # =========================================================
        # BACKTEST SETTINGS
        # =========================================================
        self.set_start_date(2022, 1, 1)
        self.set_end_date(2025, 12, 31)
        self.set_cash(100_000)

        self.initial_cash = 100_000
        self.target_shares = 100

        # =========================================================
        # CHANGE ONLY THIS NUMBER BETWEEN FINAL BACKTESTS
        #
        # Test:
        # 0, 1, 2, 5, 10, 20
        # =========================================================
        self.slippage_bps = 20

        # =========================================================
        # BROKERAGE MODEL
        # =========================================================
        self.set_brokerage_model(
            BrokerageName.INTERACTIVE_BROKERS_BROKERAGE,
            AccountType.MARGIN
        )

        security = self.add_equity(
            "SPY",
            Resolution.MINUTE,
            data_normalization_mode=DataNormalizationMode.RAW
        )

        self.symbol = security.symbol

        # =========================================================
        # SLIPPAGE MODEL
        # =========================================================
        if self.slippage_bps == 0:
            security.set_slippage_model(
                NullSlippageModel.INSTANCE
            )
        else:
            security.set_slippage_model(
                ConstantSlippageModel(
                    self.slippage_bps / 10_000.0
                )
            )

        # =========================================================
        # 200-DAY TREND FILTER
        # =========================================================
        self.sma_200 = SimpleMovingAverage(200)
        self.previous_daily_close = None

        history = self.history(
            self.symbol,
            220,
            Resolution.DAILY
        )

        if not history.empty:
            closes = history["close"]

            for index, close in closes.items():

                if isinstance(index, tuple):
                    time = index[-1]
                else:
                    time = index

                close = float(close)

                self.sma_200.update(
                    time,
                    close
                )

                self.previous_daily_close = close

        # Update the SMA only after a complete daily bar.
        self.consolidate(
            self.symbol,
            Resolution.DAILY,
            self.on_daily_bar
        )

        # =========================================================
        # EXPERIMENT TRACKING
        # =========================================================
        self.total_turnover = 0.0
        self.estimated_slippage_cost = 0.0

        self.round_trips = 0
        self.signal_days = 0

        # Print first 12 fills for auditing.
        self.audit_fills_remaining = 12

        # =========================================================
        # EXIT:
        # 10 minutes after market open
        # =========================================================
        self.schedule.on(
            self.date_rules.every_day(
                self.symbol
            ),
            self.time_rules.after_market_open(
                self.symbol,
                10
            ),
            self.exit_position
        )

        # =========================================================
        # ENTRY:
        # 10 minutes before market close
        # =========================================================
        self.schedule.on(
            self.date_rules.every_day(
                self.symbol
            ),
            self.time_rules.before_market_close(
                self.symbol,
                10
            ),
            self.enter_position
        )

    # =============================================================
    # DAILY DATA UPDATE
    # =============================================================

    def on_daily_bar(self, bar: TradeBar):

        close = float(
            bar.close
        )

        self.sma_200.update(
            bar.end_time,
            close
        )

        self.previous_daily_close = close

    # =============================================================
    # ENTRY LOGIC
    # =============================================================

    def enter_position(self):

        # ---------------------------------------------------------
        # Do not open a new overnight position on the final
        # backtest date because there is no following session
        # available for the normal exit.
        # ---------------------------------------------------------
        if (
            self.time.year == 2025
            and self.time.month == 12
            and self.time.day == 31
        ):
            return

        if not self.sma_200.is_ready:
            return

        if self.previous_daily_close is None:
            return

        if self.transactions.get_open_orders(
            self.symbol
        ):
            return

        if self.portfolio[
            self.symbol
        ].invested:
            return

        sma_value = float(
            self.sma_200.current.value
        )

        # ---------------------------------------------------------
        # TREND FILTER
        #
        # Enter only when the latest completed daily close
        # is above the 200-day SMA.
        # ---------------------------------------------------------
        if (
            self.previous_daily_close
            <= sma_value
        ):
            return

        self.signal_days += 1

        self.submit_audited_market_order(
            self.target_shares,
            "ENTRY"
        )

    # =============================================================
    # EXIT LOGIC
    # =============================================================

    def exit_position(self):

        if self.transactions.get_open_orders(
            self.symbol
        ):
            return

        quantity = self.portfolio[
            self.symbol
        ].quantity

        if quantity <= 0:
            return

        self.submit_audited_market_order(
            -quantity,
            "EXIT"
        )

    # =============================================================
    # MARKET ORDER WITH REFERENCE PRICE
    # =============================================================

    def submit_audited_market_order(
        self,
        quantity,
        reason
    ):

        if quantity == 0:
            return

        reference_price = float(
            self.securities[
                self.symbol
            ].price
        )

        tag = (
            f"{reason}"
            f"|REF={reference_price:.8f}"
        )

        self.market_order(
            self.symbol,
            quantity,
            tag=tag
        )

    # =============================================================
    # ORDER EVENT / FILL AUDIT
    # =============================================================

    def on_order_event(
        self,
        order_event: OrderEvent
    ):

        if (
            order_event.status
            != OrderStatus.FILLED
        ):
            return

        fill_quantity = float(
            order_event.fill_quantity
        )

        fill_price = float(
            order_event.fill_price
        )

        # ---------------------------------------------------------
        # GROSS TURNOVER
        # ---------------------------------------------------------
        self.total_turnover += (
            abs(fill_quantity)
            * fill_price
        )

        # Each sell closes one long round trip.
        if fill_quantity < 0:
            self.round_trips += 1

        # ---------------------------------------------------------
        # RETRIEVE REFERENCE PRICE FROM ORDER TAG
        # ---------------------------------------------------------
        order = (
            self.transactions
            .get_order_by_id(
                order_event.order_id
            )
        )

        if order is None:
            return

        reference_price = None

        if order.tag:

            for part in order.tag.split("|"):

                if part.startswith("REF="):

                    reference_price = float(
                        part.replace(
                            "REF=",
                            ""
                        )
                    )

                    break

        if reference_price is None:
            return

        # ---------------------------------------------------------
        # OBSERVED DIFFERENCE:
        # submission reference price vs fill price
        # ---------------------------------------------------------
        if fill_quantity > 0:

            side = "BUY"

            adverse_difference = (
                fill_price
                - reference_price
            )

        else:

            side = "SELL"

            adverse_difference = (
                reference_price
                - fill_price
            )

        if reference_price > 0:

            observed_adverse_bps = (
                adverse_difference
                / reference_price
                * 10_000
            )

        else:

            observed_adverse_bps = 0

        # ---------------------------------------------------------
        # THEORETICAL COST FROM CONFIGURED SLIPPAGE MODEL
        # ---------------------------------------------------------
        theoretical_slippage_cost = (
            abs(fill_quantity)
            * reference_price
            * (
                self.slippage_bps
                / 10_000.0
            )
        )

        self.estimated_slippage_cost += (
            theoretical_slippage_cost
        )

        # ---------------------------------------------------------
        # PRINT FIRST 12 FILLS
        # ---------------------------------------------------------
        if self.audit_fills_remaining > 0:

            self.debug(
                f"AUDIT | "
                f"{self.time} | "
                f"{side} | "
                f"Qty={fill_quantity:.0f} | "
                f"Reference={reference_price:.4f} | "
                f"Fill={fill_price:.4f} | "
                f"Observed adverse="
                f"{observed_adverse_bps:.3f} bps | "
                f"Configured="
                f"{self.slippage_bps:g} bps"
            )

            self.audit_fills_remaining -= 1

    # =============================================================
    # FINAL STATISTICS
    # =============================================================

    def on_end_of_algorithm(self):

        final_value = float(
            self.portfolio.total_portfolio_value
        )

        net_profit_dollars = (
            final_value
            - self.initial_cash
        )

        net_profit_percent = (
            net_profit_dollars
            / self.initial_cash
            * 100
        )

        if self.round_trips > 0:

            average_net_profit = (
                net_profit_dollars
                / self.round_trips
            )

        else:

            average_net_profit = 0

        turnover_multiple = (
            self.total_turnover
            / self.initial_cash
        )

        # ---------------------------------------------------------
        # CUSTOM SUMMARY STATISTICS
        # ---------------------------------------------------------
        self.set_summary_statistic(
            "Configured Slippage",
            f"{self.slippage_bps:g} bps"
        )

        self.set_summary_statistic(
            "Signal Days",
            str(self.signal_days)
        )

        self.set_summary_statistic(
            "Round Trips",
            str(self.round_trips)
        )

        self.set_summary_statistic(
            "Gross Turnover",
            f"${self.total_turnover:,.2f}"
        )

        self.set_summary_statistic(
            "Turnover / Initial Capital",
            f"{turnover_multiple:.2f}x"
        )

        self.set_summary_statistic(
            "Estimated Slippage Cost",
            f"${self.estimated_slippage_cost:,.2f}"
        )

        self.set_summary_statistic(
            "Avg Net P&L / Round Trip",
            f"${average_net_profit:,.2f}"
        )

        # ---------------------------------------------------------
        # FINAL LOG
        # ---------------------------------------------------------
        self.debug(
            f"FINAL SUMMARY | "
            f"Slippage="
            f"{self.slippage_bps:g} bps | "
            f"FinalValue="
            f"${final_value:,.2f} | "
            f"NetProfit="
            f"${net_profit_dollars:,.2f} | "
            f"NetReturn="
            f"{net_profit_percent:.3f}% | "
            f"SignalDays="
            f"{self.signal_days} | "
            f"RoundTrips="
            f"{self.round_trips} | "
            f"Turnover="
            f"${self.total_turnover:,.2f} | "
            f"EstimatedSlippageCost="
            f"${self.estimated_slippage_cost:,.2f}"
        )

FAQ

What is a realistic slippage assumption for backtesting?

There is no universal realistic slippage value. It depends on liquidity, order size, asset class, spread, volatility, time of day and execution method. If possible, calibrate the assumption using actual fills and then stress-test values above that estimate.

Is 1 basis point of slippage significant?

It can be. In this experiment, 1 bp per fill reduced four-year backtest profit by approximately $7,629 because the strategy generated more than $76 million in cumulative turnover.

Should slippage be applied to both entries and exits?

If both orders can experience adverse execution, then both sides should be modeled. A round trip contains an entry and an exit, so small per-fill costs can become much larger at the trade level.

Can slippage make a profitable backtest unprofitable?

Yes. In this experiment, the same strategy returned +20.49% at 0 bps, remained profitable at 2 bps, and fell to -17.65% at 5 bps.

Does zero slippage mean the fill price equals the last market price?

Not necessarily. Fill modeling and slippage modeling are separate. QuantConnect’s equity fill model can use bid/ask information to determine a market-order fill even when the additional slippage model is set to zero.

TAKE THE NEXT STEP

Test the assumptions behind your backtest.

Learn to build and evaluate your own trading systems through a structured course on Python, QuantConnect and backtesting.

Keep building

Explore more experiments in Insights, or use the Python and QuantConnect tutorials to turn trading ideas into code.