Slippage Model
Add market impact and slippage models to your backtest — fixed, percentage-based, and volume-proportional approaches covered.
Backtesting Realism — Slippage Model
1. Dependency Installation
!pip install pandas numpy plotlyRequirement already satisfied: pandas in /usr/local/lib/python3.12/dist-packages (2.2.2) Requirement already satisfied: numpy in /usr/local/lib/python3.12/dist-packages (2.0.2) Requirement already satisfied: plotly in /usr/local/lib/python3.12/dist-packages (5.24.1) Requirement already satisfied: python-dateutil>=2.8.2 in /usr/local/lib/python3.12/dist-packages (from pandas) (2.9.0.post0) Requirement already satisfied: pytz>=2020.1 in /usr/local/lib/python3.12/dist-packages (from pandas) (2025.2) Requirement already satisfied: tzdata>=2022.7 in /usr/local/lib/python3.12/dist-packages (from pandas) (2026.1) Requirement already satisfied: tenacity>=6.2.0 in /usr/local/lib/python3.12/dist-packages (from plotly) (9.1.4) Requirement already satisfied: packaging in /usr/local/lib/python3.12/dist-packages (from plotly) (26.1) Requirement already satisfied: six>=1.5 in /usr/local/lib/python3.12/dist-packages (from python-dateutil>=2.8.2->pandas) (1.17.0)
2. Library Imports
import warnings
warnings.filterwarnings("ignore")
import pandas as pd
import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from IPython.display import display
import plotly.io as pio3. What Is Slippage?
Slippage is the difference between the price at which a trade is intended to be executed and the price at which it is actually filled.
Causes of slippage:
| Cause | Description |
|---|---|
| Market impact | Large orders consume multiple price levels in the order book |
| Latency | Price moves between signal generation and order arrival at the exchange |
| Spread | The bid-ask spread means market buys fill at the ask, above mid-price |
| Volatility | During rapid price moves, the order book thins and slippage increases |
Direction of slippage: Slippage always works against the trader. A market buy fills at a price higher than the last traded price (ask side). A market sell fills at a price lower (bid side). This is not random — it is a structural property of how order books work.
Slippage models:
| Model | Definition | Use Case |
|---|---|---|
| Fixed percentage | Always slippage_pct worse than signal price | Conservative worst-case |
| Random uniform | Random draw in [0, slippage_pct] | Mid-case estimate |
| Volatility-scaled | slippage = k × ATR | Most realistic — adapts to market conditions |
| Volume-impact | slippage = k × (order_size / avg_volume) | Large position modeling |
This notebook implements all four models and compares their equity curve impact.
4. Data Generation
def generate_data(periods: int) -> pd.DataFrame:
start_date = pd.to_datetime("2024-01-01 00:00:00+00:00")
datetime_index = pd.date_range(start_date, periods=periods, freq="1min", tz="UTC")
price_data = []; last_close = 42000
volatility_scale = 0.005; wick_scale = 0.002
for _ in range(periods):
open_price = last_close + np.random.normal(0, last_close * volatility_scale * 0.1)
close_price = open_price + np.random.normal(0, last_close * volatility_scale)
body_high = max(open_price, close_price)
body_low = min(open_price, close_price)
high_price = max(body_high + abs(np.random.normal(0, last_close * wick_scale)),
open_price, close_price)
low_price = min(body_low - abs(np.random.normal(0, last_close * wick_scale)),
open_price, close_price)
if high_price < low_price:
high_price, low_price = low_price, high_price
price_data.append({
"open": max(1, int(open_price)),
"high": max(1, int(high_price)),
"low": max(1, int(low_price)),
"close": max(1, int(close_price)),
})
last_close = close_price
df = pd.DataFrame(price_data, index=datetime_index)
df.index.name = "datetime"
df["volume"] = np.random.uniform(100.0, 500.0, periods)
df["datetime"] = df.index.to_series()
return df.reset_index(drop=True)
df = generate_data(500)
display(df.head())| open | high | low | close | volume | datetime | |
|---|---|---|---|---|---|---|
| 0 | 42011 | 42384 | 41968 | 42257 | 440.723798 | 2024-01-01 00:00:00+00:00 |
| 1 | 42266 | 42373 | 42208 | 42315 | 371.055897 | 2024-01-01 00:01:00+00:00 |
| 2 | 42311 | 42565 | 42180 | 42564 | 242.458933 | 2024-01-01 00:02:00+00:00 |
| 3 | 42567 | 42921 | 42506 | 42803 | 341.271297 | 2024-01-01 00:03:00+00:00 |
| 4 | 42796 | 42925 | 42437 | 42488 | 335.535849 | 2024-01-01 00:04:00+00:00 |
5. Slippage Models
def compute_atr(df: pd.DataFrame, window: int = 14) -> pd.Series:
tr = pd.concat([
df["high"] - df["low"],
(df["high"] - df["close"].shift(1)).abs(),
(df["low"] - df["close"].shift(1)).abs(),
], axis=1).max(axis=1)
return tr.rolling(window).mean()
def apply_slippage(
price: float,
direction: int,
model: str = "fixed",
slippage_pct: float = 0.0003,
atr: float = None,
atr_multiplier: float = 0.1,
avg_volume: float = None,
order_size: float = None,
volume_impact_k: float = 0.001,
) -> float:
"""
Compute the fill price after applying the specified slippage model.
Parameters
----------
direction : +1 for buy (fills higher), −1 for sell (fills lower).
model : One of 'fixed', 'random', 'atr_scaled', 'volume_impact'.
slippage_pct : Base slippage percentage for 'fixed' and 'random' models.
atr : Current ATR value (required for 'atr_scaled').
atr_multiplier : Fraction of ATR applied as slippage (e.g., 0.1 = 10% of ATR).
avg_volume : Rolling average volume (required for 'volume_impact').
order_size : Size of the order in base currency (required for 'volume_impact').
volume_impact_k : Scaling coefficient for the volume-impact model.
Returns
-------
float : Adjusted fill price after slippage.
"""
if model == "fixed":
slip = slippage_pct
elif model == "random":
slip = np.random.uniform(0, slippage_pct)
elif model == "atr_scaled":
if atr is None or np.isnan(atr):
slip = slippage_pct
else:
slip = (atr * atr_multiplier) / price
elif model == "volume_impact":
if avg_volume is None or avg_volume == 0 or order_size is None:
slip = slippage_pct
else:
slip = volume_impact_k * (order_size / avg_volume)
else:
raise ValueError(f"Unknown slippage model: {model}")
# Direction determines sign: buys fill higher, sells fill lower
return price * (1 + direction * slip)Explanation:
- Fixed model: Applies a constant percentage slippage on every trade — a conservative upper bound. Overestimates slippage in liquid conditions, underestimates during volatile periods.
- Random model: Draws uniformly between 0 and
slippage_pct— produces a mid-case estimate with natural variation across trades. - ATR-scaled model: The most realistic for a single asset. ATR naturally measures current liquidity conditions — during high-ATR periods the spread widens and slippage increases proportionally. The
atr_multipliercalibrates how much of ATR is attributed to slippage. - Volume-impact model: Models market impact — the larger the order relative to typical volume, the more the order book is consumed and the worse the fill price. Essential for position-sizing analysis on large orders.
6. Backtest with Slippage Comparison
import pandas as pd
def backtest_slippage_comparison(
df: pd.DataFrame,
initial_capital: float = 10_000.0,
fee_pct: float = 0.0005,
slippage_pct: float = 0.0003,
) -> pd.DataFrame:
df = df.copy().sort_values("datetime", ignore_index=True)
df["fast_ma"] = df["close"].rolling(10).mean()
df["slow_ma"] = df["close"].rolling(30).mean()
df["atr"] = compute_atr(df, window=14)
df["avg_volume"]= df["volume"].rolling(20).mean()
results = {}
for model in ["none", "fixed", "random", "atr_scaled"]:
cash = initial_capital
pos = 0.0
equity_curve = []
for idx, row in df.iterrows():
if pd.isna(row["fast_ma"]) or pd.isna(row["slow_ma"]):
equity_curve.append(cash + pos * row["close"])
continue
sig = 1 if row["fast_ma"] > row["slow_ma"] else 0
price = row["close"]
if sig == 1 and pos == 0:
if model == "none":
fill = price
else:
fill = apply_slippage(
price=price, direction=+1, model=model,
slippage_pct=slippage_pct,
atr=row["atr"], atr_multiplier=0.1,
)
units = (cash * 0.95) / fill
cash -= units * fill * (1 + fee_pct)
pos = units
elif sig == 0 and pos > 0:
if model == "none":
fill = price
else:
fill = apply_slippage(
price=price, direction=-1, model=model,
slippage_pct=slippage_pct,
atr=row["atr"], atr_multiplier=0.1,
)
cash += pos * fill * (1 - fee_pct)
pos = 0.0
equity_curve.append(cash + pos * price)
results[f"equity_{model}"] = equity_curve
for col, vals in results.items():
df[col] = vals
return df
df_slip = backtest_slippage_comparison(df)
for model in ["none", "fixed", "random", "atr_scaled"]:
col = f"equity_{model}"
final = df_slip[col].iloc[-1]
ret = (final / 10_000 - 1) * 100
print(f"{model:12s} : Final ${final:,.2f} ({ret:+.2f}%)")none : Final $9,640.46 (-3.60%) fixed : Final $9,582.92 (-4.17%) random : Final $9,611.65 (-3.88%) atr_scaled : Final $9,499.58 (-5.00%)
Explanation: The comparison runs the same MA crossover strategy four times — once without slippage and once per slippage model. The differences in final equity quantify the cost of each slippage assumption. The ATR-scaled model typically produces the most conservative (realistic) result during volatile synthetic data because ATR is large relative to the absolute price level.
7. Visualization
pio.renderers.default = 'colab'
fig = make_subplots(rows=1, cols=1, shared_xaxes=True, vertical_spacing=0.02)
colors = {'none': 'gray', 'fixed': 'red', 'random': 'orange', 'atr_scaled': 'blue'}
for model in ['none', 'fixed', 'random', 'atr_scaled']:
col = f"equity_{model}"
fig.add_trace(go.Scatter(
x=df_slip["datetime"],
y=df_slip[col],
mode='lines',
name=f'{model.replace("_", " ").title()} Slippage',
line=dict(color=colors[model])
), row=1, col=1)
fig.update_layout(
title_text='<b>Slippage Model Comparison — Equity Curve Impact</b>',
height=600,
xaxis_rangeslider_visible=False,
hovermode='x unified'
)
fig.update_yaxes(title_text='Equity', row=1, col=1)
fig.update_xaxes(title_text='Date', row=1, col=1)
fig.show(renderer='colab')