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:
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.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) ** 2multiplied 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%