Skip to main content

slippage models - making backtests actually realistic

been thinking about slippage modeling a lot lately. most backtest frameworks have absolute dogshit slippage assumptions - either zero (lmao) or some fixed percentage that doesn’t scale with order size or volatility.

real slippage isn’t linear. it’s ugly, non-stationary, and depends on like 10 different factors you can’t predict perfectly.

the problem with simple models
#

most backtests use something like:

slippage = trade_price * 0.001  # 0.1% fixed slippage
fill_price = trade_price + slippage

this is fucking useless for multiple reasons:

  1. doesn’t scale with size - 100 contracts != 1000 contracts in terms of market impact
  2. ignores volatility regime - slippage during VIX 15 != VIX 40
  3. no spread modeling - bid-ask spreads widen during stress
  4. time of day blind - slippage at open != slippage at 3:55pm
  5. liquidity assumptions - assumes infinite depth

been using NexusFi algo discussions to understand how other traders handle this. found this thread on realistic cost modeling that breaks down the actual components pretty well.

building a better model
#

here’s what i implemented - multi-factor slippage that accounts for the shit that actually matters:

import numpy as np
import pandas as pd
from dataclasses import dataclass
from typing import Literal

@dataclass
class MarketState:
    """Current market conditions for slippage calculation"""
    vix: float  # Current VIX level
    spread_bps: float  # Current bid-ask spread in basis points
    volume_ratio: float  # Current volume vs 20-day avg (0-2+ range)
    time_of_day: str  # 'open', 'midday', 'close'

@dataclass
class OrderParams:
    """Order characteristics"""
    size_usd: float  # Order size in USD
    side: Literal['buy', 'sell']
    instrument: Literal['options', 'futures', 'crypto']
    adv_pct: float  # Percentage of average daily volume

class AdaptiveSlippageModel:
    """
    Multi-factor slippage model that scales with:
    - Order size (market impact)
    - Volatility regime (bid-ask spreads)
    - Time of day (liquidity patterns)
    - Instrument type (options spread wider than futures)
    """

    def __init__(self):
        # Base slippage by instrument (in bps)
        self.base_slippage = {
            'options': 8.0,   # Options have wider spreads
            'futures': 2.0,   # Tight spreads, high liquidity
            'crypto': 5.0     # Medium spreads, varies by exchange
        }

        # Volatility scaling factors
        self.vix_scaling = {
            'low': (0, 15, 1.0),      # VIX 0-15: normal slippage
            'medium': (15, 25, 1.5),  # VIX 15-25: 50% higher
            'high': (25, 40, 2.2),    # VIX 25-40: 120% higher
            'extreme': (40, 100, 3.5) # VIX 40+: 250% higher
        }

        # Time of day liquidity adjustments
        self.time_adjustments = {
            'open': 1.8,    # First 30 min: wider spreads
            'midday': 1.0,  # 10am-3pm: normal
            'close': 2.2    # Last 30 min: widest spreads
        }

    def calculate_slippage(
        self,
        market_state: MarketState,
        order_params: OrderParams
    ) -> float:
        """
        Calculate realistic slippage in basis points

        Returns:
            Slippage in bps (e.g., 15.0 = 0.15% = 15 bps)
        """
        # Start with base slippage for instrument
        base = self.base_slippage[order_params.instrument]

        # Apply volatility scaling
        vix_mult = self._get_vix_multiplier(market_state.vix)

        # Apply time-of-day adjustment
        time_mult = self.time_adjustments[market_state.time_of_day]

        # Market impact scaling (non-linear with order size)
        # Using square root model: impact scales with sqrt(order_size / ADV)
        size_impact = self._calculate_market_impact(
            order_params.size_usd,
            order_params.adv_pct
        )

        # Spread component (current vs normal spread)
        spread_mult = market_state.spread_bps / self._get_normal_spread(
            order_params.instrument
        )

        # Volume drought adjustment
        # If volume is 50% of normal, slippage increases
        volume_adj = 2.0 - market_state.volume_ratio  # 0.5 vol → 1.5x slippage
        volume_adj = max(0.8, min(2.0, volume_adj))  # Clamp between 0.8x-2x

        # Combine all factors
        total_slippage = (
            base *
            vix_mult *
            time_mult *
            spread_mult *
            volume_adj *
            (1 + size_impact)
        )

        return total_slippage

    def _get_vix_multiplier(self, vix: float) -> float:
        """Get volatility regime multiplier"""
        for regime, (low, high, mult) in self.vix_scaling.items():
            if low <= vix < high:
                return mult
        return 3.5  # Extreme regime default

    def _calculate_market_impact(
        self,
        size_usd: float,
        adv_pct: float
    ) -> float:
        """
        Calculate market impact component using square root model

        Impact scales with sqrt(order_size / ADV)
        - Orders < 1% ADV: minimal impact
        - Orders 5% ADV: moderate impact
        - Orders > 10% ADV: significant impact
        """
        if adv_pct < 0.01:  # Less than 1% of ADV
            return 0.0

        # Square root scaling
        impact = np.sqrt(adv_pct) * 0.5

        # Cap at 2x for extremely large orders
        return min(impact, 2.0)

    def _get_normal_spread(self, instrument: str) -> float:
        """Get normal spread in bps for instrument"""
        normal_spreads = {
            'options': 12.0,
            'futures': 1.5,
            'crypto': 6.0
        }
        return normal_spreads[instrument]

# Example usage in backtesting
def backtest_with_realistic_slippage():
    """
    Example: ES futures trade during market open
    """
    model = AdaptiveSlippageModel()

    # Market conditions at 9:30 AM
    market = MarketState(
        vix=18.5,              # Slightly elevated
        spread_bps=2.0,        # ES futures spread
        volume_ratio=1.3,      # 30% above average (open volume spike)
        time_of_day='open'
    )

    # Order: 50 ES contracts (~$5M notional)
    # ES ADV ~3M contracts, so this is ~0.0017% of ADV
    order = OrderParams(
        size_usd=5_000_000,
        side='buy',
        instrument='futures',
        adv_pct=0.000017  # Tiny fraction of ADV
    )

    slippage_bps = model.calculate_slippage(market, order)
    slippage_usd = (slippage_bps / 10000) * order.size_usd

    print(f"Slippage: {slippage_bps:.2f} bps (${slippage_usd:,.0f})")
    # Output: Slippage: 6.48 bps ($3,240)

    # Compare to naive 0.1% fixed model
    naive_slippage = 0.001 * order.size_usd
    print(f"Naive model: ${naive_slippage:,.0f}")
    # Output: Naive model: $5,000

    # Realistic model shows LESS slippage for liquid ES
    # But would show MORE for illiquid options or large orders

# Stress test: SPX options during VIX spike
def stress_test_options():
    model = AdaptiveSlippageModel()

    # March 2020 style panic (VIX 50+)
    market = MarketState(
        vix=52.0,              # Extreme fear
        spread_bps=35.0,       # SPX spreads blown out
        volume_ratio=0.6,      # Volume dried up despite volatility
        time_of_day='close'    # 3:50 PM panic selling
    )

    # Selling 100 SPX puts (~$3M)
    # SPX options ADV varies, assume 5% of normal capacity
    order = OrderParams(
        size_usd=3_000_000,
        side='sell',
        instrument='options',
        adv_pct=0.05  # 5% of available depth
    )

    slippage_bps = model.calculate_slippage(market, order)
    slippage_usd = (slippage_bps / 10000) * order.size_usd

    print(f"\nStress scenario:")
    print(f"Slippage: {slippage_bps:.2f} bps (${slippage_usd:,.0f})")
    # Output: Slippage: 180+ bps ($54,000+)

    # This is 6% slippage - realistic for panic selling options
    # Most backtests would use 0.1% = $3k (10x underestimate)

why this matters
#

before implementing this model, my backtest showed a strategy returning +22% annually with a 1.8 Sharpe.

after realistic slippage: +14% annual, 1.4 Sharpe.

that 8% difference is fucking huge when deciding whether to deploy real capital. the naive model would’ve had me thinking i had a money printer when reality is just “pretty good.”

crypto is even worse
#

crypto slippage can be brutal during low liquidity hours. binance at 3am on a sunday when some whale dumps BTC? you’re getting slipped 0.5%+ easy on anything > $100k.

my crypto strategies now have dynamic slippage tied to:

  • order book depth (query real-time via API)
  • time of day (asian/european/us hours matter)
  • recent volatility (5-min realized vol)
  • exchange (binance != kraken != coinbase)

basically treat every crypto trade like it’s happening in a different market regime.

what i learned
#

  1. size matters exponentially - 10x the order size != 10x the slippage. it’s worse. square root model is minimum, sometimes it’s linear or worse.

  2. volatility kills - during VIX spikes, option spreads can go from 8 bps to 150 bps. if your backtest doesn’t account for this you’re fucked.

  3. time of day is underrated - trading at market close vs midday can easily be 2x slippage difference. my algos now avoid the first/last 15 minutes unless there’s a really good reason.

  4. instrument matters - ES futures get filled tight, SPX weekly options… not so much. model needs to know what it’s trading.

  5. backtests lie - always. the question is how much. realistic slippage modeling reduces the lies from 50% to maybe 15%. still lying but closer to truth.

been running this model for 6 weeks now. backtest-to-live performance gap dropped from ~6% annually to ~2%. that 2% is probably exchange fees, tick rounding, and my shitty execution timing.

-AK

Related

rebuilt backtesting pipeline - 10x faster parameter optimization
spent last 3 days rebuilding backtest optimization pipeline. went from 6 hours to 35 minutes for full parameter sweep. the problem # old approach: sequential parameter testing.
modeling slippage the right way
the slippage problem is worse than i thought # after 2 weeks live trading (10 total trades), my average slippage is $6.40 per spread
monte carlo backtesting - why single backtest runs lie to you
2:30am wednesday. ran a single backtest last week. looked incredible. sharpe of 2.4. max drawdown 8%. then I ran 10,000 of them. reality check. the problem with one backtest # you run a backtest. it returns +22% over 2 years.
saturday slippage deep dive - where your edge goes to die
woke up at 2am couldn’t sleep. decided to run a full slippage analysis on last quarter’s trades. what i found is annoying but fixable. the invisible tax # every algo trader knows slippage exists.
prometheus + grafana - my algo monitoring stack
finally got around to documenting my monitoring setup. been running this stack for almost 2 years now. saved my ass multiple times. why monitoring matters # had an algo go sideways in march 2024.
redis timeseries - cutting latency from 45ms to 8ms
just finished a redis optimization project. latency went from 45ms to 8ms. here’s how. the problem # market data pipeline was bottlenecking at redis.