Slippage Model
Add market impact and execution slippage models to backtests using fixed spread assumptions, percentage-of-price slippage, and volume-proportional impact approaches calibrated to different market liquidity and volatility conditions.
Backtesting Realism — Slippage Model
1. Dependency Installation
This section ensures all necessary Python libraries for the project are installed. These libraries, such as pandas for data manipulation, numpy for numerical operations, and plotly for interactive visualizations, are crucial for the backtesting and slippage modeling analysis that follows.
!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.2) 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.2) 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
This section imports all required Python libraries and modules. It configures warnings to be ignored for a cleaner output and sets up plotly for rendering interactive graphs within the Colab environment. These imports prepare the notebook for data processing, financial calculations, and visualization.
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 | Adapts to current market volatility |
| Volume-impact | slippage = k × (order value / average dollar volume) | Large position / market-impact modeling |
This notebook implements all four models and compares their equity curve impact on a simple moving-average crossover strategy.
Note on this version: a few correctness issues in the original draft are fixed here — a unit mismatch in the volume-impact model, an environment-specific (Colab-only) chart renderer, a stray debug string in the printed summary, and synthetic price volatility that was about 5–10× higher than real 1-minute crypto volatility (which made the "realism" claim in the title inaccurate). Each fix is called out where it occurs.
4. Data Generation
This section defines a function to generate synthetic financial time series data. This simulated data includes open, high, low, close prices, and volume, which are essential for backtesting trading strategies and modeling slippage under controlled conditions. The generate_data function creates a realistic dataset to test the slippage models without relying on external data sources.
def generate_data(periods: int, volatility_scale: float = 0.0012, wick_scale: float = 0.0006,
seed: int = None) -> pd.DataFrame:
"""
Generate synthetic OHLCV data via a simple random walk.
volatility_scale : std of close-to-close returns per bar, as a fraction of price.
~0.0012 (0.12%) approximates realistic 1-minute crypto volatility.
wick_scale : additional std for high/low wicks beyond the open/close body.
"""
if seed is not None:
np.random.seed(seed)
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.0
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, seed=42)
display(df.head())
print(f"1-minute return std: {df['close'].pct_change().std()*100:.3f}% "
f"(realistic range for crypto is roughly 0.05%-0.15%)")| open | high | low | close | volume | datetime | |
|---|---|---|---|---|---|---|
| 0 | 42002 | 42018 | 41957 | 41995 | 262.842596 | 2024-01-01 00:00:00+00:00 |
| 1 | 41994 | 42034 | 41963 | 41982 | 126.403938 | 2024-01-01 00:01:00+00:00 |
| 2 | 41980 | 42019 | 41968 | 42007 | 239.528214 | 2024-01-01 00:02:00+00:00 |
| 3 | 42008 | 42052 | 41898 | 41912 | 144.399240 | 2024-01-01 00:03:00+00:00 |
| 4 | 41907 | 41945 | 41871 | 41923 | 423.294084 | 2024-01-01 00:04:00+00:00 |
1-minute return std: 0.122% (realistic range for crypto is roughly 0.05%-0.15%)
5. Slippage Models
This section introduces and implements various slippage models, including fixed, random, ATR-scaled, and volume-impact. These models quantify the difference between expected and actual trade execution prices under different market conditions, providing a crucial component for realistic backtesting of trading strategies.
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, in price units (required for 'atr_scaled').
atr_multiplier : Fraction of ATR applied as slippage (e.g., 0.1 = 10% of ATR).
avg_volume : Rolling average traded volume, in asset units (required for 'volume_impact').
order_size : Size of the order in quote currency / dollars (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) or price == 0:
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 or np.isnan(avg_volume):
slip = slippage_pct
else:
dollar_volume = avg_volume * price # FIX: convert units volume -> dollar volume
slip = volume_impact_k * (order_size / dollar_volume)
else:
raise ValueError(f"Unknown slippage model: {model}")
# Direction determines sign: buys fill higher, sells fill lower
return price * (1 + direction * slip)def compute_atr(df: pd.DataFrame, window: int = 14) -> pd.Series:
"""Simple-moving-average True Range (a lightweight stand-in for Wilder's
smoothed ATR — close enough for illustrating volatility-scaled slippage,
but note it is not the textbook Wilder ATR)."""
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 backtest_slippage_comparison(
df: pd.DataFrame,
initial_capital: float = 10_000.0,
fee_pct: float = 0.0005,
slippage_pct: float = 0.0003,
) -> tuple[pd.DataFrame, dict]:
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 = {}
n_trades = {}
order_size_fraction = 0.05 # 5% of capital per entry, used for volume-impact sizing
for model in ["none", "fixed", "random", "atr_scaled", "volume_impact"]:
cash = initial_capital
pos = 0.0
equity_curve = []
trades = 0
for _, 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:
fill = price if model == "none" else apply_slippage(
price=price, direction=+1, model=model,
slippage_pct=slippage_pct,
atr=row["atr"], atr_multiplier=0.1,
avg_volume=row["avg_volume"], order_size=initial_capital * order_size_fraction,
)
units = (cash * 0.95) / fill
cash -= units * fill * (1 + fee_pct)
pos = units
trades += 1
elif sig == 0 and pos > 0:
fill = price if model == "none" else apply_slippage(
price=price, direction=-1, model=model,
slippage_pct=slippage_pct,
atr=row["atr"], atr_multiplier=0.1,
avg_volume=row["avg_volume"], order_size=initial_capital * order_size_fraction,
)
cash += pos * fill * (1 - fee_pct)
pos = 0.0
trades += 1
equity_curve.append(cash + pos * price)
results[f"equity_{model}"] = equity_curve
n_trades[model] = trades
for col, vals in results.items():
df[col] = vals
return df, n_tradesExplanation:
- 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— a mid-case estimate with natural variation across trades. - ATR-scaled model: ties slippage to current volatility. During high-ATR periods the spread typically widens and slippage increases proportionally.
atr_multipliercalibrates how much of ATR is attributed to slippage. - Volume-impact model: models market impact — the larger the order relative to typical dollar volume traded, the more the order book is consumed and the worse the fill. With the unit fix above, this now scales correctly with position size rather than producing a coincidental, unit-mismatched number.
6. Backtest with Slippage Comparison
This section implements a backtesting framework to compare the impact of different slippage models on a simple moving average crossover trading strategy. It calculates equity curves for scenarios with no slippage, fixed slippage, random slippage, and ATR-scaled slippage, allowing for a direct assessment of how each model affects overall strategy performance.
df_slip, n_trades = backtest_slippage_comparison(df)
print("Trade counts (should be identical across models if only fill price varies):")
print(n_trades)
print()
summary_rows = []
for model in ["none", "fixed", "random", "atr_scaled", "volume_impact"]:
col = f"equity_{model}"
final = df_slip[col].iloc[-1]
ret = (final / 10_000 - 1) * 100
summary_rows.append((model, final, ret))
print(f"{model:14s}: Final ${final:,.2f} ({ret:+.2f}%)")Trade counts (should be identical across models if only fill price varies):
{'none': 13, 'fixed': 13, 'random': 13, 'atr_scaled': 13, 'volume_impact': 13}
none : Final $10,406.04 (+4.06%)
fixed : Final $10,367.53 (+3.68%)
random : Final $10,389.62 (+3.90%)
atr_scaled : Final $10,381.39 (+3.81%)
volume_impact : Final $10,406.03 (+4.06%)
Explanation: the comparison runs the same MA-crossover strategy five times — once without slippage and once per slippage model — on the identical price path, so trade counts match and any difference in final equity is attributable purely to fill-price assumptions. The relative ranking of "fixed" vs "random" vs "atr_scaled" vs "volume_impact" depends on the chosen parameters (slippage_pct, atr_multiplier, volume_impact_k) and the realized volatility/volume path of this particular run — it is not a universal law that any one model is always the most conservative. Re-running with a different seed, or stress-testing across multiple seeds (Section 8 below), is the honest way to characterize how each model behaves on average rather than asserting it from a single sample path.
7. Visualization
This section plots the five equity curves on one chart for direct visual comparison. Note that fig.show() is called with no renderer argument, so it auto-detects the runtime environment instead of being locked to Google Colab.
fig = make_subplots(rows=1, cols=1)
colors = {'none': '#888888', 'fixed': '#E4572E', 'random': '#F3A712',
'atr_scaled': '#2E86AB', 'volume_impact': '#3DA35D'}
for model in ['none', 'fixed', 'random', 'atr_scaled', 'volume_impact']:
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=550,
xaxis_rangeslider_visible=False,
hovermode='x unified',
template='plotly_white',
)
fig.update_yaxes(title_text='Equity ($)', row=1, col=1)
fig.update_xaxes(title_text='Time', row=1, col=1)
fig.show()8. Cost of Slippage (vs. no-slippage baseline)
The equity-curve chart above shows absolute equity, where small differences late in the curve can be visually hard to read. This section isolates exactly what each model "costs" in dollars and in percentage points relative to the frictionless baseline — a more direct answer to "how much does each slippage assumption cost me".
baseline = df_slip["equity_none"].iloc[-1]
cost_rows = []
for model in ["fixed", "random", "atr_scaled", "volume_impact"]:
final = df_slip[f"equity_{model}"].iloc[-1]
dollar_cost = baseline - final
pct_cost = dollar_cost / baseline * 100
cost_rows.append((model, dollar_cost, pct_cost))
cost_df = pd.DataFrame(cost_rows, columns=["model", "dollar_cost", "pct_cost"])
display(cost_df)
fig2 = go.Figure(go.Bar(
x=cost_df["model"].str.replace("_", " ").str.title(),
y=cost_df["pct_cost"],
marker_color=[colors[m] for m in cost_df["model"]],
text=[f"{v:.2f}%" for v in cost_df["pct_cost"]],
textposition="outside",
))
fig2.update_layout(
title_text="<b>Slippage Cost vs. No-Slippage Baseline</b>",
yaxis_title="Cost (% of baseline final equity)",
template="plotly_white",
height=450,
)
fig2.show()| model | dollar_cost | pct_cost | |
|---|---|---|---|
| 0 | fixed | 38.512463 | 0.370097 |
| 1 | random | 16.420985 | 0.157802 |
| 2 | atr_scaled | 24.651602 | 0.236897 |
| 3 | volume_impact | 0.005090 | 0.000049 |
Reading this chart: each bar is the percentage of the no-slippage final equity that was given up purely to fill-price assumptions, holding the strategy and price path fixed. This isolates the friction cost cleanly, which is the actual point of a slippage-sensitivity study — and it's a more direct visual answer than reading small gaps between five overlapping lines.