Backtesting·Realism·Intermediate

Slippage Model

Add market impact and slippage models to your backtest — fixed, percentage-based, and volume-proportional approaches covered.

slippagemarket impactexecution

Backtesting Realism — Slippage Model


1. Dependency Installation

[23]
!pip install pandas numpy plotly
Requirement 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

[24]
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 pio

3. 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:

CauseDescription
Market impactLarge orders consume multiple price levels in the order book
LatencyPrice moves between signal generation and order arrival at the exchange
SpreadThe bid-ask spread means market buys fill at the ask, above mid-price
VolatilityDuring 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:

ModelDefinitionUse Case
Fixed percentageAlways slippage_pct worse than signal priceConservative worst-case
Random uniformRandom draw in [0, slippage_pct]Mid-case estimate
Volatility-scaledslippage = k × ATRMost realistic — adapts to market conditions
Volume-impactslippage = k × (order_size / avg_volume)Large position modeling

This notebook implements all four models and compares their equity curve impact.


4. Data Generation

[25]
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

[26]
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_multiplier calibrates 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

[27]
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

[31]
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')