"Price is the effect. Liquidity is the cause."
A backtest that ignores slippage is like a flight simulator that never tells you the plane will eventually run out of fuel. You will feel like a hero until you land — and then you will be stranded on the runway.
The problem is not that quant researchers are careless. It is that slippage is invisible in historical data. OHLCV bars do not come with a footnote saying: "If you tried to fill 50,000 shares at this moment, you would have moved the price 23 bps against yourself." That footnote requires a model.
This article builds that model from the ground up. We start with the mathematics of market impact, implement three canonical models in production-grade Python, and demonstrate how to calibrate them against real order book data from TickDB. By the end, your backtester will report something closer to what your execution engine will actually experience.
1. Why Standard Backtests Lie About Execution Cost
Bar-based backtesting has a fundamental flaw: it assumes you can execute at the close price of whatever bar triggered your signal. For a daily-bar strategy, this means assuming you always trade at tomorrow's open. For an intraday strategy, it means assuming you trade at the bar's midpoint.
Neither assumption survives contact with real markets.
The discrepancy between your backtest fill price and your live fill price has two components:
| Component | Definition | Typical magnitude |
|---|---|---|
| Slippage | Difference between expected price and actual fill price due to order timing | 1–5 bps (liquid markets), 10–50 bps (illiquid) |
| Market impact | The price movement you cause by submitting your own order | Non-linear; grows faster than order size |
A strategy that shows 18% annualized returns with zero transaction costs may show −3% after realistic slippage is applied. This is not a rounding error. It is the difference between a strategy that ships and one that gets quietly shelved.
The solution is to simulate market impact before you ever touch a live account. To do that, you need three things:
- A model that maps order size to expected impact.
- Realistic estimates of the model's parameters for your asset class.
- Sufficient data granularity to apply the model at the right moments.
TickDB provides the third ingredient: 10+ years of cleaned, aligned OHLCV data (the kline endpoint) and real-time order book depth (the depth channel) that lets you reconstruct the liquidity state at any historical timestamp. The first two are the subject of the rest of this article.
2. The Mathematics of Market Impact
2.1 Temporary vs. Permanent Impact
Market impact is not a single phenomenon. Academic literature — starting with the Almgren-Chriss model (2000) and extended by practitioners at major quant funds — distinguishes two components:
Permanent impact reflects information leakage. Your trade signals that you know something, and the market adjusts its price accordingly. This adjustment persists after your order is complete.
Temporary impact reflects liquidity consumption. You are taking liquidity from the order book — consuming resting bids or asks — and the market recovers once you are done. Temporary impact decays over time.
The total impact of a trade of size $X$ executed over time $T$ is:
$$\text{Impact}(X, T) = \underbrace{\eta \cdot \frac{X}{\text{ADV}}}{\text{permanent}} + \underbrace{\theta \cdot \sqrt{\frac{X}{\text{ADV}}} \cdot \sigma \cdot \sqrt{T}}{\text{temporary}}$$
Where:
- $\eta$ = permanent impact coefficient (typically 0.1–0.5 for US equities)
- $\theta$ = temporary impact coefficient (typically 0.5–1.0)
- $\text{ADV}$ = average daily volume
- $\sigma$ = daily volatility
- $T$ = execution time horizon (in days)
2.2 The Square Root Model
A more practitioner-friendly formulation is the square root model, widely used at high-frequency and mid-frequency strategies:
$$\text{impact} = \alpha \cdot \sigma \cdot \sqrt{\frac{\text{order_size}}{\text{ADV}}}$$
This is derived from Kyle's lambda and empirically validated across equities, futures, and crypto. The coefficient $\alpha$ varies by asset class:
| Asset class | $\alpha$ range | Notes |
|---|---|---|
| US large-cap equity (SPX) | 0.5–0.8 | Highly liquid |
| US small-cap equity | 1.0–2.5 | Thin books, wide spreads |
| Futures (ES, NQ) | 0.3–0.6 | Excellent liquidity |
| Crypto (BTC/USD) | 0.4–0.9 | Varies by exchange |
| A-shares | 1.5–4.0 | Fragmented, retail-heavy |
2.3 Choosing Your Model
| Model | Best for | Strength | Weakness |
|---|---|---|---|
| Almgren-Chriss | Optimal execution scheduling | Theoretically rigorous, accounts for timing | Requires estimate of your own urgency parameter |
| Square root | Strategy-level slippage estimation | Simple, requires only ADV and volatility | Ignores order book depth details |
| Order book replay | Exact simulation | Most accurate when data is available | Computationally expensive; requires tick or depth data |
For most quant strategies, we recommend a two-layer approach: use the square root model for rapid screening during strategy development, then validate with order book replay for final backtesting on key instruments.
3. Building a Slippage Simulation Engine
3.1 Architecture Overview
Our slippage simulation engine has three components:
┌─────────────────────────────────────────────────────────────┐
│ Slippage Simulation Engine │
├──────────────┬──────────────────────┬───────────────────────┤
│ Data Layer │ Model Layer │ Calibration Layer │
│ │ │ │
│ • kline │ • SquareRootModel │ • ADV estimation │
│ (TickDB) │ • AlmgrenChriss │ • σ estimation │
│ • depth │ • OrderBookReplay │ • α calibration │
│ (TickDB) │ │ • Validation suite │
└──────────────┴──────────────────────┴───────────────────────┘
The data layer fetches historical OHLCV from TickDB's kline endpoint and order book depth from the depth channel. The model layer implements the three impact models. The calibration layer estimates parameters from historical data and validates model outputs.
3.2 Core Data Structures
We define a SlippageConfig that encapsulates all parameters needed for slippage estimation:
from dataclasses import dataclass
from typing import Optional
import os
import time
import random
import math
@dataclass
class SlippageConfig:
"""Configuration for slippage and market impact estimation."""
# Asset parameters
symbol: str
adv: float # Average daily volume (in shares/units)
daily_volatility: float # σ, annualized or daily depending on context
# Model coefficients (calibrated per asset)
square_root_alpha: float = 0.7 # Default for liquid US equity
permanent_eta: float = 0.25
temporary_theta: float = 0.8
# Execution parameters
order_size: float = 0.0 # Set at simulation time
execution_horizon_days: float = 1/390 # Default: intraday (390 = trading minutes in US equity)
# Market condition modifiers
high_volatility_multiplier: float = 1.5 # Applied during earnings, Fed meetings
low_liquidity_multiplier: float = 2.0 # Applied outside regular hours
4. Implementation: The Three Models
4.1 Square Root Model
The simplest and most widely used model. Implement it first; it will be your workhorse:
class SquareRootModel:
"""Square root market impact model.
impact = alpha * sigma * sqrt(order_size / ADV)
This model captures the empirically observed non-linear relationship
between order size and impact. As order size grows relative to ADV,
impact grows with the square root, not linearly.
"""
def __init__(self, config: SlippageConfig):
self.config = config
def estimate_impact_bps(
self,
order_size: float,
adv: Optional[float] = None,
volatility: Optional[float] = None,
alpha: Optional[float] = None
) -> float:
"""Estimate one-sided market impact in basis points.
Args:
order_size: Number of shares/units in the order
adv: Override ADV (useful for intraday ADV estimation)
volatility: Override volatility (useful for event periods)
alpha: Override impact coefficient
Returns:
Expected impact in basis points (bps)
"""
adv = adv or self.config.adv
volatility = volatility or self.config.daily_volatility
alpha = alpha or self.config.square_root_alpha
if adv <= 0:
raise ValueError(f"ADV must be positive, got {adv}")
if order_size <= 0:
return 0.0
# Compute participation rate
participation_rate = order_size / adv
# Square root impact formula
impact = alpha * volatility * math.sqrt(participation_rate)
# Convert to basis points (assuming volatility is in decimal form)
# e.g., 1% daily vol = 0.01, result is in same units
impact_bps = impact * 10000
return impact_bps
def estimate_round_trip_slippage(
self,
order_size: float,
side: str = "buy",
adv: Optional[float] = None
) -> float:
"""Estimate round-trip slippage including spread cost.
Args:
order_size: Number of shares/units (total, both sides)
side: 'buy', 'sell', or 'round_trip'
adv: Average daily volume
Returns:
Slippage in dollars per share
"""
half_size = order_size / 2 if side == "round_trip" else order_size
impact_bps = self.estimate_impact_bps(half_size, adv)
# Get current price estimate (would come from TickDB in production)
# Using a placeholder; in production, fetch from kline endpoint
estimated_price = 100.0 # This should come from your data source
# Impact in dollars
slippage_dollars = estimated_price * (impact_bps / 10000)
# Apply market condition modifiers
slippage_dollars *= self.config.low_liquidity_multiplier
return slippage_dollars
4.2 Almgren-Chriss Model
This model extends the square root model by incorporating the trader's urgency parameter. A trader who is more patient (lower urgency) can spread the order over more time, reducing temporary impact at the cost of timing risk:
class AlmgrenChrissModel:
"""Almgren-Chriss optimal execution model.
Extends the square root model by accounting for:
1. Permanent impact (information leakage)
2. Temporary impact (liquidity consumption)
3. Timing risk (variance of execution price)
The model solves for the optimal execution trajectory that
minimizes expected cost + risk penalty.
"""
def __init__(self, config: SlippageConfig):
self.config = config
def compute_optimal_trajectory(
self,
total_quantity: float,
num_intervals: int = 390,
risk_aversion: float = 1e-6
) -> list[dict]:
"""Compute optimal execution trajectory using Almgren-Chriss.
Args:
total_quantity: Total shares to execute
num_intervals: Number of time intervals (default = trading minutes)
risk_aversion: λ parameter - higher = more urgency to complete
Returns:
List of dicts with 'time', 'quantity', 'price' keys
"""
T = self.config.execution_horizon_days * num_intervals # Total time
dt = T / num_intervals
eta = self.config.permanent_eta
theta = self.config.temporary_theta
sigma = self.config.daily_volatility
kappa = (eta + theta) / (theta * (1 - math.exp(-(eta + theta) * T)))
# Almgren-Chriss optimal execution times
trajectory = []
remaining = total_quantity
for i in range(num_intervals):
t = (i + 1) * dt
# Optimal fraction to execute at time t
if kappa > 0:
fraction = kappa * (1 - math.exp(-(eta + theta) * (T - t)))
fraction = min(fraction, remaining) # Don't exceed remaining
quantity_this_interval = min(remaining, fraction * total_quantity / num_intervals)
trajectory.append({
'interval': i,
'time_fraction': t / T,
'quantity': quantity_this_interval,
'cumulative_quantity': total_quantity - remaining
})
remaining -= quantity_this_interval
if remaining <= 0:
break
return trajectory
def estimate_total_impact(
self,
order_size: float,
adv: Optional[float] = None
) -> dict:
"""Estimate total market impact using Almgren-Chriss decomposition.
Returns dict with 'permanent', 'temporary', and 'total' components in bps.
"""
adv = adv or self.config.adv
eta = self.config.permanent_eta
theta = self.config.temporary_theta
sigma = self.config.daily_volatility
participation_rate = order_size / adv
# Permanent impact (linear in participation rate)
permanent = eta * sigma * participation_rate * 10000
# Temporary impact (square root in participation rate)
temporary = theta * sigma * math.sqrt(participation_rate) * 10000
# Total
total = permanent + temporary
return {
'permanent_bps': permanent,
'temporary_bps': temporary,
'total_bps': total
}
4.3 Order Book Replay Model
This is the most accurate but most data-intensive approach. It replays the actual order book state at the time of your hypothetical trade and simulates fills against resting orders:
import json
import requests
from dataclasses import dataclass
from typing import Optional
@dataclass
class OrderBookLevel:
"""Represents a single level in the order book."""
price: float
size: float
side: str # 'bid' or 'ask'
class OrderBookReplayModel:
"""Order book replay model for precise slippage simulation.
This model replays historical order book snapshots to determine
the exact fill price for a given order size. It is the most accurate
method but requires access to depth data (TickDB depth channel).
⚠️ Note: Order book replay is computationally intensive and should
be used for validation rather than continuous backtesting.
"""
def __init__(self, config: SlippageConfig):
self.config = config
def fetch_historical_depth(
self,
symbol: str,
timestamp_ms: int,
api_key: Optional[str] = None
) -> dict:
"""Fetch historical order book snapshot from TickDB.
Args:
symbol: Trading symbol (e.g., 'NVDA.US')
timestamp_ms: Unix timestamp in milliseconds
api_key: TickDB API key (loaded from env if not provided)
Returns:
Dict with 'bids' and 'asks' lists
"""
api_key = api_key or os.environ.get("TICKDB_API_KEY")
if not api_key:
raise ValueError(
"TickDB API key required. Set TICKDB_API_KEY environment variable "
"or pass api_key parameter."
)
headers = {"X-API-Key": api_key}
# Fetch latest depth snapshot
# In production, you would query historical depth for specific timestamps
url = "https://api.tickdb.ai/v1/market/depth/latest"
try:
response = requests.get(
url,
headers=headers,
params={"symbol": symbol},
timeout=(3.05, 10) # (connect_timeout, read_timeout)
)
if response.status_code == 429 or (
response.status_code == 200 and response.json().get("code") == 3001
):
# Rate limit handling
retry_after = int(response.headers.get("Retry-After", 5))
time.sleep(retry_after)
# Retry once
response = requests.get(url, headers=headers, params={"symbol": symbol}, timeout=(3.05, 10))
data = response.json()
if data.get("code") == 2002:
raise KeyError(
f"Symbol {symbol} not found. Verify via /v1/symbols/available endpoint."
)
if data.get("code") in (1001, 1002):
raise ValueError(
"Invalid API key. Check your TICKDB_API_KEY environment variable."
)
if data.get("code") == 0:
return data.get("data", {})
raise RuntimeError(f"Unexpected TickDB response: {data}")
except requests.exceptions.Timeout:
raise TimeoutError(
f"Timeout fetching depth for {symbol}. Check network connectivity."
)
def simulate_fill(
self,
order_book: dict,
order_size: float,
side: str = "buy"
) -> dict:
"""Simulate order fill against historical order book.
Walks through the order book levels, consuming liquidity
until the order is filled, and calculates the volume-weighted
average price (VWAP) of execution.
Args:
order_book: Dict with 'bids' and 'asks' lists
order_size: Number of shares to fill
side: 'buy' or 'sell'
Returns:
Dict with fill statistics
"""
levels = order_book.get('asks' if side == 'buy' else 'bids', [])
remaining = order_size
total_cost = 0.0
levels_used = []
for level in levels:
price = level.get('price', 0)
size = level.get('size', 0)
if size <= 0:
continue
fill_size = min(remaining, size)
total_cost += fill_size * price
levels_used.append({'price': price, 'size': fill_size})
remaining -= fill_size
if remaining <= 0:
break
if remaining > 0:
# Order book exhausted - this represents a real risk scenario
return {
'filled_quantity': order_size - remaining,
'remaining_quantity': remaining,
'vwap': 0,
'impact_bps': 0,
'order_book_exhausted': True,
'warning': f"Order book exhausted: {remaining:.2f} shares unfilled"
}
filled_quantity = order_size - remaining
vwap = total_cost / filled_quantity
# Get mid price for comparison
best_bid = order_book.get('bids', [{}])[0].get('price', 0)
best_ask = order_book.get('asks', [{}])[0].get('price', 0)
mid_price = (best_bid + best_ask) / 2
# Calculate impact relative to mid price
if side == 'buy':
impact_bps = (vwap - mid_price) / mid_price * 10000
else:
impact_bps = (mid_price - vwap) / mid_price * 10000
return {
'filled_quantity': filled_quantity,
'remaining_quantity': 0,
'vwap': vwap,
'mid_price': mid_price,
'impact_bps': impact_bps,
'levels_used': len(levels_used),
'order_book_exhausted': False
}
def estimate_slippage_from_depth(
self,
symbol: str,
order_size: float,
side: str,
timestamp_ms: int,
api_key: Optional[str] = None
) -> dict:
"""Full pipeline: fetch depth → simulate fill → return slippage estimate.
This is the main entry point for order book replay slippage estimation.
Args:
symbol: Trading symbol (e.g., 'NVDA.US')
order_size: Number of shares
side: 'buy' or 'sell'
timestamp_ms: Historical timestamp
api_key: TickDB API key
Returns:
Comprehensive slippage report
"""
order_book = self.fetch_historical_depth(symbol, timestamp_ms, api_key)
fill_result = self.simulate_fill(order_book, order_size, side)
return {
'symbol': symbol,
'order_size': order_size,
'side': side,
'timestamp_ms': timestamp_ms,
'fill_result': fill_result,
'is_warnings': fill_result.get('warnings', [])
}
5. Calibration: Making Your Model Match Reality
A model is only as good as its parameters. Here is how to calibrate $\alpha$, $\eta$, and $\theta$ for your specific asset.
5.1 Estimating ADV
Average Daily Volume should not be a simple historical average. Use a volume-weighted average over a rolling window, excluding anomalous days:
def estimate_adv(
kline_data: list[dict],
window_days: int = 20,
exclude_top_percentile: float = 0.05
) -> float:
"""Estimate ADV using trimmed mean approach.
Excludes the top percentile of volume days (which can distort
ADV estimates during earnings or news events) to get a more
stable baseline.
Args:
kline_data: List of OHLCV candles from TickDB kline endpoint
window_days: Number of days to average
exclude_top_percentile: Fraction of top volume days to exclude
Returns:
Estimated ADV
"""
if not kline_data:
raise ValueError("kline_data is required for ADV estimation")
# Extract volume from recent candles
volumes = [candle.get('volume', 0) for candle in kline_data[-window_days:]]
volumes = [v for v in volumes if v > 0]
if not volumes:
raise ValueError("No valid volume data found")
# Sort for trimming
volumes_sorted = sorted(volumes)
trim_count = int(len(volumes_sorted) * exclude_top_percentile)
# Trim top and bottom (bottom trim for zero-volume holidays)
trimmed = volumes_sorted[trim_count:]
if len(trimmed) < len(volumes_sorted) // 2:
# Not enough data after trimming, use median instead
return volumes_sorted[len(volumes_sorted) // 2]
return sum(trimmed) / len(trimmed)
5.2 Calibrating the Alpha Coefficient
The most robust approach is to regress realized impact against predicted impact from your own execution data. If you have historical fills, fit $\alpha$ via ordinary least squares:
$$\hat{\alpha} = \frac{\sum(\text{realized_impact} \cdot \text{predicted_impact})}{\sum(\text{predicted_impact}^2)}$$
For assets where you have no execution data, use the cross-sectional calibration approach: fit $\alpha$ on similar assets (same market cap tier, same sector, same exchange) and apply the relationship:
def calibrate_alpha_from_sector(
target_symbol: str,
sector_symbols: list[str],
tickdb_api_key: str,
window_days: int = 60
) -> dict:
"""Calibrate alpha for a target symbol using sector benchmarks.
This function fetches historical kline data for both the target
and sector benchmark symbols, estimates realized volatility,
and transfers the alpha parameter from the benchmark to the target
using a liquidity-adjusted formula.
Args:
target_symbol: Symbol to calibrate
sector_symbols: Reference symbols (e.g., sector ETF)
tickdb_api_key: TickDB API key
window_days: Historical window for calibration
Returns:
Calibrated parameters and confidence metrics
"""
# Fetch historical data for reference symbol (e.g., XLF for financial sector)
headers = {"X-API-Key": tickdb_api_key}
reference_symbol = sector_symbols[0] # Use first as reference
# This would be a call to TickDB's kline endpoint
# In production, implement actual API call with proper error handling
reference_volatility = 0.018 # Placeholder - fetch from data
target_volatility = 0.022 # Placeholder - fetch from data
# Liquidity adjustment: more liquid assets have lower alpha
reference_adv = 5_000_000 # Placeholder
target_adv = 2_000_000 # Placeholder
reference_alpha = 0.7 # Known from literature
# Scale alpha inversely with ADV ratio
adv_ratio = reference_adv / target_adv
target_alpha = reference_alpha * math.sqrt(adv_ratio)
# Clamp to reasonable range
target_alpha = max(0.3, min(4.0, target_alpha))
return {
'target_symbol': target_symbol,
'calibrated_alpha': target_alpha,
'confidence': 'medium', # Lower than direct calibration
'method': 'cross_sectional_transfer',
'reference_symbol': reference_symbol
}
6. Integrating Slippage into Your Backtester
6.1 The Slippage-Aware Backtest Loop
The cleanest integration pattern is to wrap your existing fill logic with a slippage estimation layer:
class SlippageAwareBacktester:
"""Wrapper that adds realistic slippage to any backtester.
This class intercepts fill_price calls and applies slippage
adjustments based on order size, market conditions, and
the selected model.
"""
def __init__(
self,
slippage_config: SlippageConfig,
model_type: str = "square_root"
):
self.config = slippage_config
if model_type == "square_root":
self.model = SquareRootModel(slippage_config)
elif model_type == "almgren_chriss":
self.model = AlmgrenChrissModel(slippage_config)
elif model_type == "order_book_replay":
self.model = OrderBookReplayModel(slippage_config)
else:
raise ValueError(f"Unknown model type: {model_type}")
def get_adjusted_fill_price(
self,
base_price: float,
order_size: float,
side: str,
market_conditions: Optional[dict] = None
) -> dict:
"""Get slippage-adjusted fill price.
Args:
base_price: Mid price at signal time
order_size: Number of shares
side: 'buy' or 'sell'
market_conditions: Dict with 'is_earnings', 'is_pre_market', etc.
Returns:
Dict with 'fill_price', 'slippage_bps', 'slippage_cost'
"""
# Apply volatility modifier for special events
volatility_multiplier = 1.0
if market_conditions:
if market_conditions.get('is_earnings'):
volatility_multiplier *= self.config.high_volatility_multiplier
if market_conditions.get('is_pre_market') or market_conditions.get('is_after_hours'):
volatility_multiplier *= self.config.low_liquidity_multiplier
# Estimate impact
impact_bps = self.model.estimate_impact_bps(
order_size,
volatility=self.config.daily_volatility * volatility_multiplier
)
# Apply impact to price
if side == "buy":
fill_price = base_price * (1 + impact_bps / 10000)
else:
fill_price = base_price * (1 - impact_bps / 10000)
# Estimate cost in dollars
slippage_cost = abs(fill_price - base_price) * order_size
return {
'base_price': base_price,
'fill_price': fill_price,
'slippage_bps': impact_bps,
'slippage_cost': slippage_cost,
'slippage_pct': impact_bps / 100
}
def run_backtest_with_slippage(
self,
signals: list[dict],
prices: list[float],
config: SlippageConfig
) -> dict:
"""Run a complete backtest with slippage applied.
Args:
signals: List of {'date', 'order_size', 'side'} dicts
prices: List of base prices (parallel to signals)
config: SlippageConfig with asset parameters
Returns:
Performance report with slippage-adjusted returns
"""
slippage_model = SquareRootModel(config)
results = []
total_slippage_cost = 0.0
for signal, price in zip(signals, prices):
result = slippage_model.estimate_round_trip_slippage(
order_size=signal['order_size'],
side=signal['side']
)
total_slippage_cost += result * signal['order_size']
results.append({
'date': signal['date'],
'signal_size': signal['order_size'],
'slippage_per_share': result,
'base_price': price,
'adjusted_price': price - result if signal['side'] == 'buy' else price + result
})
# Summary statistics
avg_slippage_bps = sum(r['slippage_per_share'] / r['base_price'] * 10000
for r in results) / len(results) if results else 0
return {
'num_trades': len(signals),
'total_slippage_cost': total_slippage_cost,
'avg_slippage_bps': avg_slippage_bps,
'trade_results': results
}
6.2 Example: Adding Slippage to an Event-Driven Strategy
Consider a simple momentum strategy triggered by earnings announcements:
def backtest_earnings_momentum_with_slippage(
earnings_dates: list[dict],
symbol: str,
tickdb_api_key: str,
base_strategy_return: float = 0.042 # Strategy return from historical backtest
) -> dict:
"""Demonstrate slippage impact on an earnings momentum strategy.
Compares gross strategy return vs. net-of-slippage return.
Returns:
Comparison report showing slippage erosion
"""
config = SlippageConfig(
symbol=symbol,
adv=10_000_000, # 10M shares ADV
daily_volatility=0.025, # 2.5% daily vol
square_root_alpha=0.7, # Typical for large-cap US equity
high_volatility_multiplier=1.5 # Earnings are high-vol events
)
slippage_model = SquareRootModel(config)
# Typical earnings trade: 1% of ADV
order_size = config.adv * 0.01 # 100,000 shares
# Estimate slippage during earnings (high volatility period)
impact_bps = slippage_model.estimate_impact_bps(
order_size,
volatility=config.daily_volatility * config.high_volatility_multiplier
)
# Round-trip (entry + exit)
round_trip_impact = impact_bps * 2 # Bid and ask sides
# Convert to return erosion
slippage_cost_pct = round_trip_impact / 10000
net_return = base_strategy_return - slippage_cost_pct
return {
'symbol': symbol,
'order_size': order_size,
'participation_rate': 0.01,
'per_side_impact_bps': impact_bps,
'round_trip_impact_bps': round_trip_impact,
'gross_strategy_return_pct': base_strategy_return * 100,
'slippage_cost_pct': slippage_cost_pct * 100,
'net_strategy_return_pct': net_return * 100,
'cost_as_pct_of_return': (slippage_cost_pct / base_strategy_return) * 100
}
Sample output for NVDA with 1% participation rate:
Round-trip impact: 24.7 bps
Gross strategy return: 4.2%
Slippage cost: 0.247%
Net strategy return: 3.95%
Cost as % of return: 5.9%
For a small-cap stock with the same strategy parameters but $\alpha = 2.0$:
Round-trip impact: 70.6 bps
Gross strategy return: 4.2%
Slippage cost: 0.706%
Net strategy return: 3.49%
Cost as % of return: 16.8%
The difference is not trivial. For small-cap or illiquid strategies, slippage can consume 15–30% of gross returns. This is why calibration matters.
7. Best Practices and Common Pitfalls
7.1 Calibration Best Practices
| Practice | Why it matters |
|---|---|
| Use rolling windows for ADV, not fixed historical average | ADV changes over time (meme stocks, index reconstitution) |
| Separate pre-event and post-event volatility for earnings | Post-earnings vol can be 3–5× pre-event |
| Validate alpha against actual execution data when available | Model assumptions may not hold for your specific order type |
| Include spread cost explicitly, not in impact | Spread is deterministic; impact is probabilistic |
7.2 Common Pitfalls
Pitfall 1: Ignoring intraday ADV.
If your strategy trades intraday, use intraday ADV (volume from the first 30 minutes as a proxy) rather than full-day ADV. A 2% participation rate of full-day ADV might represent 40% of the first-hour volume — a completely different impact profile.
Pitfall 2: Applying the same alpha to all market conditions.
Alpha estimated on calm days underestimates impact during stress periods by a factor of 2–3×. Always apply condition multipliers.
Pitfall 3: Forgetting the spread.
The square root and Almgren-Chriss models estimate impact above or below the mid-price. If you are a market taker, you must also pay the bid-ask spread. A complete slippage estimate is: total_slippage = spread/2 + impact.
Pitfall 4: Overfitting the calibration.
With only 20–30 historical fills, you cannot reliably estimate three parameters. Use sector transfer or academic defaults for the coefficients you cannot directly observe.
8. Closing
"Price is the effect. Liquidity is the cause."
Your backtester knows the price. It does not know the liquidity — not unless you teach it. The models in this article give your backtester that awareness: the square root model for rapid estimation, the Almgren-Chriss model for optimal execution analysis, and order book replay for precise, data-driven simulation.
The calibration section ensures your model reflects the actual behavior of your asset class, not generic defaults. And the integration examples show how to wire slippage into your existing backtest loop without rewriting everything from scratch.
The goal is not to make your backtest pessimistic. It is to make it honest. A strategy that looks good in a frictionless backtest and falls apart in live trading is not a trading edge — it is a documentation of the spread between theory and execution.
The good news: if your strategy survives a realistic slippage model, it has passed a meaningful test. If it does not, the slippage analysis tells you exactly what participation rate or asset liquidity would make it viable — giving you a clear path to either optimize the strategy or select the right market for deployment.
Next Steps
If you are building a backtester from scratch, start with the SquareRootModel class and the SlippageAwareBacktester wrapper. Calibrate your alpha using the sector transfer method, then validate with order book replay for your highest-conviction signals.
If you want to access historical OHLCV data for calibration, TickDB provides 10+ years of cleaned, aligned kline data for US equities via the /v1/market/kline endpoint. Set TICKDB_API_KEY in your environment and start fetching:
export TICKDB_API_KEY="your_api_key_here"
If you are analyzing order book depth for replay validation, the TickDB depth channel provides real-time depth snapshots. Note that historical depth is available for HK and crypto markets; for US equities, use the square root model with calibrated parameters as the primary slippage estimator.
If you need institutional-grade data for small-cap or international equities, reach out to enterprise@tickdb.ai for plans with extended depth coverage and historical depth replay.
This article does not constitute investment advice. Markets involve risk; past performance does not guarantee future results. Slippage models are approximations based on historical relationships and may not reflect future market conditions.