SlippageBacktestingMarket MicrostructureExecution

Modeling Slippage and Commissions in Algorithmic Backtests

Published on September 12, 2026 · 9 min read
Modeling Slippage and Commissions in Algorithmic Backtests

Every backtest that assumes a fill at the exact signal price is quietly lying to you. This single simplification — zero slippage, zero spread, zero market impact — is one of the most common and most dangerous unrealistic assumptions in retail-grade backtesting. It is also one of the easiest to fix, provided the researcher understands the mechanics of transaction costs well enough to model them instead of ignoring them.

This article walks through the components of realistic execution cost — spread, market impact, and commissions — and provides a working Python implementation that converts a gross backtest into a net-of-cost one.

Why the "perfect fill" assumption is dangerous

A naive backtest engine typically evaluates a signal at the close of bar t and assumes the trade executes at that exact price. In live trading, several things stand between the decision and the fill: the bid-ask spread, the latency between signal generation and order routing, and the market impact of the order itself pushing the price away from the trader. None of these frictions exist in a backtest unless the researcher explicitly models them.

The result is systematic overstatement of performance. A strategy that appears to generate a modest positive edge on a spreadsheet can be a breakeven or losing strategy once realistic frictions are applied. This is not a minor calibration detail — it is frequently the difference between a strategy that looks publishable and one that is not viable at all. Marcos López de Prado's Advances in Financial Machine Learning devotes considerable attention to this exact failure mode, arguing that market microstructure effects must be built into the research pipeline from the start, not bolted on as an afterthought once a strategy "works."

It is worth distinguishing this problem from the closely related issue of backtest overfitting discussed via the deflated Sharpe ratio framework of Bailey and López de Prado — see our related article on the data-snooping ratio and the Sharpe ratio for that angle. Overfitting inflates performance through excessive parameter search; unmodeled transaction costs inflate performance through an entirely separate, purely mechanical channel. A strategy can be free of overfitting and still be worthless once realistic costs are applied.

Real-time order book and price screens where slippage occurs

Photo: Rômulo Queiroz (Pexels)

Order size, ADV, and non-linear market impact

The bid-ask spread is a baseline, roughly fixed transaction cost: it is paid on every round-trip trade regardless of size, up to the point where the order exceeds the quoted depth. Market impact is a separate and more insidious cost that scales with the size of the order relative to the liquidity available in the name being traded.

The standard proxy for available liquidity is Average Daily Volume (ADV). An order that represents 0.1% of ADV moves the price negligibly. An order that represents 15% of ADV can move the price substantially, both during execution (temporary impact) and, in some models, persistently afterward (permanent impact).

Critically, this relationship is non-linear. A large body of market-impact literature — most prominently the framework introduced by Almgren and Chriss for optimal execution — models impact as scaling with the square root of the participation rate rather than linearly with it. In plain terms:

impact_cost ≈ impact_coefficient × sqrt(order_size / ADV)

Doubling an order's size relative to ADV does not double its impact cost; it multiplies it by roughly 1.41. This square-root relationship means that impact costs grow slowly for small orders but accelerate sharply as an order approaches a meaningful fraction of daily volume. A backtest that assumes constant per-share cost regardless of size will underestimate the cost of scaling a strategy up in size — a failure mode that shows up specifically when researchers try to project backtested returns onto larger capital bases than were originally tested.

It is not necessary to implement the full Almgren-Chriss optimal-execution trajectory to get value from this insight. Even a simplified square-root impact term, calibrated to a reasonable coefficient, captures the qualitative behavior that matters most: costs that scale non-linearly with size relative to liquidity.

Traffic congestion: a large order in a thin market pays for the room it takes

Photo: Pixabay (Pexels)

Estimated market impact by order size

0 bps25 bps50 bps75 bps100 bps0.5%1%2%5%10%15%20%Order size as % of average daily volume
Square-root impact model with 2% daily volatility: cost ≈ σ·√(Q/ADV). Doubling the order does not double the cost, but it never stops rising.

Slippage is asymmetric, not just present

A frequently overlooked detail is that slippage is directional and always works against the trader. A buy order that consumes liquidity on the offer side of the book tends to walk the price up as it executes; a sell order that consumes liquidity on the bid side tends to walk the price down. In both cases, the realized execution price is worse than the price observed at the moment the decision was made — never better, on average.

This matters for round-trip cost accounting. A long entry pays impact cost pushing the price up on the buy and, at exit, pays impact cost pushing the price down on the sell. The two legs do not offset; they compound. A backtest that only charges a single "slippage estimate" per trade rather than separately modeling entry and exit impact will typically understate total round-trip cost.

Commissions compound with slippage at high turnover

Commission structures are simpler to model than market impact — typically a fixed cost per share, per trade, or a percentage of notional — but their effect compounds sharply with trading frequency. Consider a strategy with a genuine gross edge of 5 basis points per trade. At low turnover (say, ten round trips per year), realistic transaction costs of 3-4 basis points per round trip barely dent the annual return. At high turnover (say, ten round trips per day), the same 3-4 basis points per round trip consumes an enormous multiple of the theoretical edge over the course of a year, because the cost is paid on every single trade while the edge is a per-trade average that assumes independence.

Numerically: a strategy trading 2,000 round trips per year with a 5 bps gross edge per trade has a theoretical gross annual return contribution of roughly 1,000 bps, or 10%, before compounding effects. If realistic frictions (spread + impact + commission) average 6 bps per round trip — a perfectly plausible number for a liquid but not mega-cap name — the net edge per trade becomes negative, and the strategy loses money at scale despite a "positive" gross backtest. This is precisely the trap that high-frequency and high-turnover strategies fall into when researchers do not model costs realistically from the outset. TrueVerdikt's own statistical backtest-audit tool, available at /outils, specifically flags this kind of turnover-versus-cost mismatch alongside overfitting and sample-size diagnostics, since it is one of the most common reasons a backtest fails to survive contact with live execution.

Coins piling up: commissions and slippage compound with every trade

Photo: Steve A Johnson (Pexels)

A cost-adjusted backtest wrapper in Python

The function below takes a series of gross, signal-based returns along with cost assumptions and produces a net-of-cost equity curve. It applies spread cost and commission per trade, and a square-root market-impact cost scaled by the ratio of assumed order size to ADV.

import numpy as np
import pandas as pd


def apply_transaction_costs(
    gross_returns: pd.Series,
    trade_flags: pd.Series,
    spread_bps: float = 5.0,
    impact_coefficient_bps: float = 10.0,
    order_size_to_adv: float = 0.02,
    commission_per_trade_bps: float = 1.0,
) -> pd.DataFrame:
    """
    Convert a gross signal-return series into a net-of-cost equity curve.

    Parameters
    ----------
    gross_returns : pd.Series
        Period-over-period strategy returns assuming perfect fills (no cost).
    trade_flags : pd.Series
        Boolean series, same index as gross_returns, True on periods where
        a trade (entry or exit) actually occurs.
    spread_bps : float
        Half-spread cost paid per trade, in basis points of notional.
    impact_coefficient_bps : float
        Calibration constant for the square-root market-impact model.
    order_size_to_adv : float
        Assumed order size as a fraction of Average Daily Volume (ADV).
        Held constant here for simplicity; in practice this varies per trade.
    commission_per_trade_bps : float
        Fixed commission cost per trade, in basis points of notional.

    Returns
    -------
    pd.DataFrame with columns:
        gross_return, cost_bps, net_return, gross_equity, net_equity
    """
    if not gross_returns.index.equals(trade_flags.index):
        raise ValueError("gross_returns and trade_flags must share the same index")

    # Square-root market impact model (Almgren-Chriss-style reference framework):
    # impact scales with sqrt(participation rate), not linearly with size.
    impact_bps = impact_coefficient_bps * np.sqrt(order_size_to_adv)

    # Total one-way cost per executed trade, in basis points.
    per_trade_cost_bps = spread_bps + impact_bps + commission_per_trade_bps

    # Convert basis points to a decimal return drag, applied only on trade periods.
    cost_drag = np.where(trade_flags.values, per_trade_cost_bps / 10_000.0, 0.0)

    net_returns = gross_returns.values - cost_drag

    result = pd.DataFrame(
        {
            "gross_return": gross_returns.values,
            "cost_bps": np.where(trade_flags.values, per_trade_cost_bps, 0.0),
            "net_return": net_returns,
        },
        index=gross_returns.index,
    )

    result["gross_equity"] = (1.0 + result["gross_return"]).cumprod()
    result["net_equity"] = (1.0 + result["net_return"]).cumprod()

    return result


def summarize_cost_impact(result: pd.DataFrame, periods_per_year: int = 252) -> dict:
    """Compute annualized gross vs. net return and the drag attributable to costs."""
    n_periods = len(result)
    gross_total = result["gross_equity"].iloc[-1] - 1.0
    net_total = result["net_equity"].iloc[-1] - 1.0

    years = n_periods / periods_per_year
    gross_annualized = (1.0 + gross_total) ** (1.0 / years) - 1.0
    net_annualized = (1.0 + net_total) ** (1.0 / years) - 1.0

    return {
        "gross_annualized_return": gross_annualized,
        "net_annualized_return": net_annualized,
        "annualized_cost_drag": gross_annualized - net_annualized,
        "total_trades_cost_bps": result["cost_bps"].sum(),
    }


if __name__ == "__main__":
    # Illustrative example: a high-turnover strategy with a small theoretical edge.
    rng = np.random.default_rng(42)
    n = 504  # roughly two years of daily bars

    # Simulate a strategy that trades every day (high turnover) with a
    # small positive average edge and realistic daily noise.
    daily_gross_returns = pd.Series(
        rng.normal(loc=0.0005, scale=0.01, size=n),
        index=pd.date_range("2024-01-01", periods=n, freq="B"),
    )
    trades_every_day = pd.Series(True, index=daily_gross_returns.index)

    net_result = apply_transaction_costs(
        daily_gross_returns,
        trades_every_day,
        spread_bps=5.0,
        impact_coefficient_bps=10.0,
        order_size_to_adv=0.02,
        commission_per_trade_bps=1.0,
    )

    summary = summarize_cost_impact(net_result)
    print(f"Gross annualized return: {summary['gross_annualized_return']:.2%}")
    print(f"Net annualized return:   {summary['net_annualized_return']:.2%}")
    print(f"Annualized cost drag:    {summary['annualized_cost_drag']:.2%}")

Running this illustration typically shows a gross annualized return in the low double digits collapsing to a small or negative net return once realistic per-trade frictions are applied daily — precisely the dynamic described above. Adjusting order_size_to_adv upward (simulating a larger capital allocation trading the same signal) makes the impact term grow with the square root of that ratio, illustrating why strategies that look attractive at small size often degrade sharply when scaled.

Practical takeaways for researchers

Treat spread, market impact, and commissions as three separate cost components with distinct scaling behavior: spread is roughly constant per trade, impact scales non-linearly with order size relative to ADV, and commissions are typically fixed or linear in notional. Model all three, even approximately, before drawing conclusions from a backtest — especially for strategies with high turnover, where per-trade edges are thin relative to realistic frictions. For teams building out a research methodology from scratch, TrueTrueVerdikt's ebook on clean backtesting methodology walks through building this cost layer into a research pipeline end-to-end, alongside the broader risk-management practices that accompany it.

Disclaimer: This article is provided for educational and informational purposes only. It does not constitute financial, investment, or trading advice. Trading and investing involve substantial risk of loss. Past performance, backtested or live, is not indicative of future results. Always conduct your own research and consult a licensed financial advisor before making investment decisions.

From theory to practice

Validate a strategy before risking capital on it: data-snooping, overfitting, walk-forward analysis, survivorship bias and the Deflated Sharpe Ratio.

Analyse a backtest