σμimplied vol · KKmax(S−K,0)udβρᵢⱼfat tailsPnL · drawdownyield · TdSₜ = μSₜ dt + σSₜ dWₜC = S·N(d₁) − K·e⁻ʳᵀ·N(d₂)σₚ² = wᵀΣwd₁ = [ln(S/K) + (r + σ²⁄2)T] ⁄ σ√T∂V/∂t + ½σ²S²·∂²V/∂S² + rS·∂V/∂S − rV = 0Sharpe = (Rₚ − R_f) ⁄ σₚVaR₉₉ = μ − 2.33σρ = Cov(X,Y) ⁄ σₓσᵧE[Rₚ] = Σ wᵢ·E[Rᵢ]φ(x) = e^(−x²/2) ⁄ √2πE[Rᵢ] = R_f + βᵢ(E[Rₘ] − R_f)dXₜ = θ(μ − Xₜ)dt + σ dWₜσ²ₜ = ω + α·ε²ₜ₋₁ + β·σ²ₜ₋₁C − P = S − K·e⁻ʳᵀΔ = ∂V/∂S Γ = ∂²V/∂S²p = (e^(rΔt) − d) ⁄ (u − d)rₜ = ln(Pₜ ⁄ Pₜ₋₁)E[Xₜ₊₁ | ℱₜ] = Xₜf* = (bp − q) ⁄ bz = (x − μ) ⁄ σP(N=k) = λᵏe^(−λ) ⁄ k!Θ = −∂V/∂Tν = S·φ(d₁)·√Tmin wᵀΣw s.t. wᵀμ = μ*∫Σ∂∞λ
σμimplied vol · KKmax(S−K,0)udβρᵢⱼfat tailsPnL · drawdownyield · TdSₜ = μSₜ dt + σSₜ dWₜC = S·N(d₁) − K·e⁻ʳᵀ·N(d₂)σₚ² = wᵀΣwd₁ = [ln(S/K) + (r + σ²⁄2)T] ⁄ σ√T∂V/∂t + ½σ²S²·∂²V/∂S² + rS·∂V/∂S − rV = 0Sharpe = (Rₚ − R_f) ⁄ σₚVaR₉₉ = μ − 2.33σρ = Cov(X,Y) ⁄ σₓσᵧE[Rₚ] = Σ wᵢ·E[Rᵢ]φ(x) = e^(−x²/2) ⁄ √2πE[Rᵢ] = R_f + βᵢ(E[Rₘ] − R_f)dXₜ = θ(μ − Xₜ)dt + σ dWₜσ²ₜ = ω + α·ε²ₜ₋₁ + β·σ²ₜ₋₁C − P = S − K·e⁻ʳᵀΔ = ∂V/∂S Γ = ∂²V/∂S²p = (e^(rΔt) − d) ⁄ (u − d)rₜ = ln(Pₜ ⁄ Pₜ₋₁)E[Xₜ₊₁ | ℱₜ] = Xₜf* = (bp − q) ⁄ bz = (x − μ) ⁄ σP(N=k) = λᵏe^(−λ) ⁄ k!Θ = −∂V/∂Tν = S·φ(d₁)·√Tmin wᵀΣw s.t. wᵀμ = μ*∫Σ∂∞λ
uantCraft AcademySkills
Ctrl K
AO
+1.2k Learners
LoginJoin ↗
Skills
AO
+1.2k Learners
Login
  • English
  • Français
  • Español
  • العربية
  • Português
  • Deutsch
  • 日本語
  • 中文
Python skill›Module 6 · Backtesting & trading costs›Topic 6.5·~45 minAdvanced

Slippage

Skill positionModule 6 of 11 · Topic 5 of 6
6.5 Slippage
—Lab score— / 100Total XP—Streak—

The price you do not get

A backtest trades at the price in its data; a real order is filled somewhere else. Slippage is that difference: you buy a little higher than the price you saw, and sell a little lower. It has two sources. The spread: a market order buys at the ask and sells at the bid, so you pay half the spread each way relative to the mid price. And market impact: a large order uses up the best quotes and pushes the price against itself.

def fill_price(mid, side, slippage_bps):
    if side == "buy":
        return mid * (1 + slippage_bps / 10000)
    if side == "sell":
        return mid * (1 - slippage_bps / 10000)
    raise ValueError("side must be 'buy' or 'sell'")

print(round(fill_price(50.0, "buy", 4), 4))        # 50.02   pays more
print(round(fill_price(50.0, "sell", 4), 4))       # 49.98   receives less
# Crossing the spread: a market buy pays the ask, a market sell receives the bid
bid, ask = 49.98, 50.02
mid = (bid + ask) / 2
half_spread_bps = (ask - bid) / 2 / mid * 10000
print(mid, round(half_spread_bps, 2))                     # 50.0 4.0   each way, relative to the mid price
print(round((ask - mid) / mid * 10000, 2), round((mid - bid) / mid * 10000, 2))   # 4.0 4.0

Measuring it against a benchmark

To measure slippage you need a reference: the arrival price, the mid price when the order was decided. The shortfall is signed so that positive always means it cost you: for a buy, fill - arrival; for a sell, arrival - fill, in basis points of the arrival price. The same fill that is bad for a buyer is good for a seller, which is why the sign depends on the side. Large orders are filled in pieces, so the number to report is the volume-weighted average fill price.

import numpy as np

def slippage_bps(fill, arrival, side):
    if side == "buy":
        return (fill - arrival) / arrival * 10000
    if side == "sell":
        return (arrival - fill) / arrival * 10000
    raise ValueError("side must be 'buy' or 'sell'")

def average_fill(prices, quantities):
    prices, quantities = np.asarray(prices, dtype=float), np.asarray(quantities, dtype=float)
    return float(np.sum(prices * quantities) / np.sum(quantities))

# Buying 600 shares in three pieces; the price at arrival was 50.00
avg = average_fill([50.0, 50.1, 50.3], [100, 200, 300])
print(round(avg, 4))                                      # 50.1833
print(round(slippage_bps(avg, 50.0, "buy"), 2))           # 36.67 bps: it cost us
print(round(slippage_bps(avg, 50.0, "sell"), 2))          # -36.67: the same fills would have helped a seller

Impact grows with size

The spread is a fixed charge; impact depends on how much you trade. A well-tested rule of thumb is the square-root law: the expected impact as a fraction of the price is about k * volatility * sqrt(quantity / ADV), where ADV is the average daily volume and k a constant near 1. Quadrupling the order only doubles the impact, so splitting a big order into pieces over time helps, but not as much as you would hope.

import math

def sqrt_impact_bps(volatility, quantity, adv, k=1.0):
    return k * volatility * math.sqrt(quantity / adv) * 10000

# A stock with 2% daily volatility and 1,000,000 shares of average daily volume
for shares in (10_000, 50_000, 200_000, 800_000):
    print(shares, round(sqrt_impact_bps(0.02, shares, 1_000_000), 1), "bps")
# 10,000: 20.0, 50,000: 44.7, 200,000: 89.4, 800,000: 178.9
Add slippage to every backtestEven a crude model beats none: charge half the quoted spread on every trade and an impact term that grows with the order size, then see whether the strategy survives. If it only works with zero slippage, it does not work. The honest check is to compare the slippage you assumed with what live fills show, and to lower the assumption only when the data allows.
  • A buy fills above the mid price and a sell below: mid * (1 +/- bps / 10000); the spread alone costs half its width each way.
  • Measure against the arrival price, signed so positive means cost; partial fills are combined with a volume-weighted average.
  • Impact follows the square-root law k * volatility * sqrt(quantity / ADV): four times the size, twice the impact.

Next up: realistic order fills, simulating how orders really get filled against an order book and bar data.

Lab · what's graded

  • Price the fill with fill_price25 pts
  • Measure the shortfall with slippage_bps25 pts
  • Blend partial fills with average_fill25 pts
  • Forecast the impact with sqrt_impact_bps25 pts
Explorer · lab-91-1
▾ lab-91-1
…
bash · in-browser
learner@quantcraft:~/lab-91-1$
Python not loaded yetPythonLn 1, Col 1Spaces: 4UTF-8

Reset this topic?

Everything in lab-91-1 goes back to the starter files and the terminal is cleared. Your current solution will be lost — this can't be undone.

Will be reset
  • main.py → starter code
  • Terminal history and test output
  • Unsubmitted changes
Will be kept
  • Best lab score
  • Your XP and streak
  • Lecture progress and notes

Log in to run your code.

Every lecture is free to read. Create an account or log in to run code, run tests and submit this lab.

  • In-browser IDE with a bash terminal — nothing to install
  • Instant grading, XP and your place on the skill
Create an accountLog in
OR
Continue with GitHub