Modeling Slippage

In live trading, orders rarely fill at the exact price a backtest assumes. Factors like spreads, latency, and the market impact of your own order push fill prices in an adverse direction. This difference is called slippage. A backtest that ignores it will overstate a strategy’s true performance.

This notebook demonstrates PyBroker’s three built-in slippage models added in v2. It also introduces the SlippageModel base class, which you can use to write your own custom models.

A Baseline Strategy

To see the effect of each model, we reuse the dip-buying strategy from Backtesting a Strategy. The rule is simple: buy when the latest close drops below the previous day’s low. We allocate 25% of the portfolio to the position with calc_target_shares and hold it for 3 bars via hold_bars. Because this strategy trades frequently, small per-fill costs will compound into a noticeable difference in total return.

[1]:
import pybroker
from pybroker import Strategy, YFinance

pybroker.enable_data_source_cache("slippage")


def buy_low(ctx):
    # If shares were already purchased and are currently being held, then
    # return.
    if ctx.long_pos():
        return
    # If the latest close price is less than the previous day's low price,
    # then place a buy order.
    if ctx.bars >= 2 and ctx.close[-1] < ctx.low[-2]:
        # Buy a number of shares that is equal to 25% of the portfolio.
        ctx.buy_shares = ctx.calc_target_shares(0.25)
        # Hold the position for 3 bars before liquidating.
        ctx.hold_bars = 3


symbols = ["F", "BAC", "T"]
strategy = Strategy(YFinance(), start_date="1/1/2021", end_date="1/1/2026")
strategy.add_execution(buy_low, symbols)

Now, we run the baseline backtest with no slippage. Every order fills at the the midpoint between the bar’s low and high prices (PriceType.MIDDLE) by default:

[2]:
result = strategy.backtest()
print(f"Total return: {result.metrics.total_return_pct:.2f}%")
result.orders.head()
Backtesting: 2021-01-01 00:00:00 to 2026-01-01 00:00:00

Loading bar data...
[*********************100%***********************]  3 of 3 completed
Loaded bar data: 0:00:01

Test split: 2021-01-04 00:00:00 to 2025-12-31 00:00:00
100% (1255 of 1255) |####################| Elapsed Time: 0:00:00 Time:  0:00:00

Finished backtest: 0:00:01
Total return: 18.02%
[2]:
type symbol date created order_type intent shares limit_price market_price fill_price fees
id
1 buy BAC 2021-01-11 2021-01-08 market buy_to_open 768 NaN 32.52 32.52 0.0
2 buy T 2021-01-11 2021-01-08 market buy_to_open 1140 NaN 21.79 21.79 0.0
3 sell BAC 2021-01-14 NaT stop_bar sell_to_close 768 NaN 33.89 33.89 0.0
4 sell T 2021-01-14 NaT stop_bar sell_to_close 1140 NaN 22.02 22.02 0.0
5 buy BAC 2021-01-19 2021-01-15 market buy_to_open 767 NaN 32.90 32.90 0.0

Fixed Slippage

The FixedSlippageModel applies a fixed, adverse price adjustment measured in basis points (where 1 basis point equals 0.01%). Buy prices are increased by bps, while sell prices are decreased. Passing bps=0 disables the adjustment entirely.

Because the remaining examples run several more backtests, we will also disable logging with disable_logging to keep the output short. Next, we attach the model to a Strategy with set_slippage_model:

[3]:
from pybroker import FixedSlippageModel

pybroker.disable_logging()

strategy.set_slippage_model(FixedSlippageModel(bps=10))
result = strategy.backtest()
print(f"Total return: {result.metrics.total_return_pct:.2f}%")
result.orders[result.orders["symbol"] == "T"].head()
Total return: -8.66%
[3]:
type symbol date created order_type intent shares limit_price market_price fill_price fees
id
2 buy T 2021-01-11 2021-01-08 market buy_to_open 1140 NaN 21.79 21.81 0.0
4 sell T 2021-01-14 NaT stop_bar sell_to_close 1140 NaN 22.02 22.00 0.0
11 buy T 2021-01-29 2021-01-28 market buy_to_open 1128 NaN 21.76 21.78 0.0
14 sell T 2021-02-03 NaT stop_bar sell_to_close 1128 NaN 21.58 21.56 0.0
17 buy T 2021-02-09 2021-02-08 market buy_to_open 1158 NaN 21.65 21.67 0.0

Volatility Slippage

Because slippage tends to grow as volatility rises, the VolatilitySlippageModel ties its adverse price adjustment directly to market movement. It scales the slippage using the fill bar’s Average True Range (ATR) (see atr), moving the fill price against your order by scale * ATR. The ATR is calculated over the atr_period leading up to the fill bar, and any fills during the warmup period (before a full ATR window is established) remain unadjusted:

[4]:
from pybroker import VolatilitySlippageModel

strategy.set_slippage_model(VolatilitySlippageModel(atr_period=14, scale=0.1))
result = strategy.backtest()
print(f"Total return: {result.metrics.total_return_pct:.2f}%")
result.orders.head()
Total return: -39.55%
[4]:
type symbol date created order_type intent shares limit_price market_price fill_price fees
id
1 buy BAC 2021-01-11 2021-01-08 market buy_to_open 768 NaN 32.52 32.52 0.0
2 buy T 2021-01-11 2021-01-08 market buy_to_open 1140 NaN 21.79 21.79 0.0
3 sell BAC 2021-01-14 NaT stop_bar sell_to_close 768 NaN 33.89 33.89 0.0
4 sell T 2021-01-14 NaT stop_bar sell_to_close 1140 NaN 22.02 22.02 0.0
5 buy BAC 2021-01-19 2021-01-15 market buy_to_open 767 NaN 32.90 32.90 0.0

Volume Slippage

The VolumeSlippageModel accounts for limited market liquidity by applying two mechanics:

  1. Volume limit: The number of filled shares is capped at a percentage of the bar’s total volume (volume_limit * volume). Any shares exceeding this limit are canceled rather than carried over to the next bar.

  2. Price impact: The execution price moves against your order based on its size relative to the market. This adverse adjustment is calculated as price_impact * (filled_shares / volume) ** 2 multiplied by the initial fill price.

A $100,000 account will rarely hit these limits when trading liquid large caps. For example, a 25% allocation would just be a rounding error in Ford’s daily volume. However, that same allocation can be a significant portion of the day’s trading in thinly traded small caps. Without a volume model, the backtest unrealistically assumes the entire order fills at the quoted price:

[5]:
from pybroker import VolumeSlippageModel

smallcaps = Strategy(YFinance(), start_date="1/1/2021", end_date="1/1/2026")
smallcaps.add_execution(buy_low, ["ESCA", "BSET", "HURC"])
result = smallcaps.backtest()
print(f"Return without a volume model: {result.metrics.total_return_pct:.2f}%")

smallcaps.set_slippage_model(
    VolumeSlippageModel(price_impact=0.1, volume_limit=0.025)
)
result = smallcaps.backtest()
print(f"Return with a volume model: {result.metrics.total_return_pct:.2f}%")
result.orders.head()
Return without a volume model: 16.67%
Return with a volume model: 12.46%
[5]:
type symbol date created order_type intent shares limit_price market_price fill_price fees
id
1 buy HURC 2021-01-06 2021-01-05 market buy_to_open 874 NaN 30.30 30.30 0.0
2 sell HURC 2021-01-11 NaT stop_bar sell_to_close 874 NaN 30.28 30.28 0.0
3 buy ESCA 2021-01-11 2021-01-08 market buy_to_open 677 NaN 21.79 21.79 0.0
4 buy BSET 2021-01-12 2021-01-11 market buy_to_open 1268 NaN 20.39 20.39 0.0
5 sell ESCA 2021-01-14 NaT stop_bar sell_to_close 677 NaN 22.85 22.85 0.0

Writing a Custom Slippage Model

To create your own model, subclass SlippageModel and override the apply_slippage method. This method takes a SlippageContext object containing the order’s side ("buy" or "sell"), symbol, shares, and the initial fill_price. Your method must then return a tuple with the adjusted (shares, fill_price).

The following example shows a model that applies a random amount of adverse slippage to every fill:

[6]:
from decimal import Decimal

import numpy as np
from pybroker import SlippageContext, SlippageModel


class RandomSlippageModel(SlippageModel):
    """Applies random adverse slippage of up to ``max_bps`` per fill."""

    def __init__(self, max_bps: float = 10, seed: int = 42):
        self.max_bps = max_bps
        self._rng = np.random.default_rng(seed)

    def apply_slippage(self, ctx: SlippageContext) -> tuple[Decimal, Decimal]:
        bps = self._rng.uniform(0, self.max_bps)
        adjustment = ctx.fill_price * Decimal(str(bps / 10_000))
        if ctx.side == "buy":
            fill_price = ctx.fill_price + adjustment
        else:
            fill_price = ctx.fill_price - adjustment
        return ctx.shares, fill_price


strategy.set_slippage_model(RandomSlippageModel(max_bps=10, seed=42))
result = strategy.backtest()
print(f"Total return: {result.metrics.total_return_pct:.2f}%")
Total return: 3.89%