How to Backtest a Trading Strategy in Python (Step-by-Step)

To backtest a trading strategy in Python is to build a faithful simulator of your own decision process under the information constraints you actually faced in real time. Most public tutorials get the arithmetic right and the epistemics wrong: they produce an equity curve that is an artifact of lookahead, optimistic fills, and an unstated multiple-testing search. This guide builds a small vectorized engine in pandas/numpy that you fully control, then spends most of its words on the parts that decide whether the number is real — fill timing, cost modeling, and statistical validation.

The simulation contract

A backtest is a function from a decision rule and a data set to a return series. The only invariant that matters is the information set: the position held over bar t -> t+1 must be a function of data observable strictly before t. Every classic backtesting failure is a violation of that contract somewhere — in the signal, the features, the fills, or the survivorship of the universe.

Write the contract down explicitly, because it dictates the data layout:

  • Decide on close of bar t using bars <= t.
  • Execute on bar t+1 (open, VWAP, or next close), not bar t.
  • Accrue PnL on the realized price path from fill to fill.
  • Charge costs at the moment of execution, sized to the traded quantity.

Step 1: Load and sanitize price data

Free and even paid data is dirty. Sanitize before you trust a single number.

import numpy as np
import pandas as pd

df = pd.read_csv("SPY.csv", parse_dates=["Date"], index_col="Date").sort_index()

# Hard validation, not optional
assert df.index.is_monotonic_increasing
assert not df.index.has_duplicates
df = df[~df.index.duplicated(keep="last")]

px = df["AdjClose"].astype(float)        # always adjusted for splits/dividends
assert (px > 0).all(), "non-positive prices present"

# Log returns: additive across time, symmetric, the right unit for vol math
ret = np.log(px).diff()
# Flag suspicious bars (|move| > 8 robust sigmas) for manual review
sigma = ret.rolling(63).std()
suspect = ret.abs() > 8 * sigma
print("suspect bars:", int(suspect.sum()))

Two non-negotiables: use adjusted close so corporate actions don't appear as returns, and work in log returns for the statistical machinery (they sum across time, which makes annualization and volatility scaling exact rather than approximate). Convert back to simple returns only when compounding an equity curve.

Step 2: Generate a defensible signal

Use a signal with an economic prior, not one reverse-engineered from the chart. Indicators like RSI, MACD, or Bollinger bands are nonlinear transforms of the same price series and are weak, heavily data-mined features — if you use them, treat them as candidates to be falsified, not as edges. A cleaner starting point is time-series momentum: the well-documented tendency of an asset's own trailing return to predict its next return. Crucially, the signal must be standardized and lagged.

def ts_momentum_signal(px: pd.Series, lookback=126, vol_window=63):
    """Volatility-scaled trailing return, lagged one bar to respect causality."""
    r = np.log(px).diff()
    raw = np.log(px).diff(lookback)                 # trailing log return
    vol = r.rolling(vol_window).std() * np.sqrt(252)
    z = raw / (vol * np.sqrt(lookback / 252))       # risk-normalized signal
    pos = np.sign(z).clip(-1, 1)
    return pos.shift(1).fillna(0.0)                 # <-- the causality lag

The .shift(1) encodes that the signal computed on the close of t cannot be acted on until t+1. It is the single most common bug in amateur backtests and the most consequential: omitting it leaks one bar of future information and turns noise into a flawless equity curve. Volatility scaling is not decoration — it makes the position size risk-aware and is the basis of volatility targeting.

Step 3: Model execution and costs honestly

The naive position * return formula assumes you transact frictionlessly at the close you used to decide. Replace it with explicit next-bar execution and a cost model that scales with turnover.

def backtest(px: pd.Series, target_pos: pd.Series,
             spread_bps=2.0, commission_bps=0.5, impact_coef=0.1,
             adv=None, notional=1.0):
    """
    Vectorized backtest with next-bar execution and turnover-based costs.
    target_pos is already lagged (decision uses past data only).
    """
    ret = px.pct_change().fillna(0.0)

    # Execute the target reached on the *next* bar's return
    pos = target_pos.reindex(ret.index).fillna(0.0)
    turnover = pos.diff().abs().fillna(pos.abs())   # fraction of book traded

    # Linear costs: half-spread + commission, both per unit traded
    linear_bps = (0.5 * spread_bps + commission_bps) / 1e4
    linear_cost = turnover * linear_bps

    # Square-root market impact (Almgren-style), optional if ADV provided
    if adv is not None:
        participation = (turnover * notional) / adv
        impact = impact_coef * np.sqrt(participation.clip(lower=0))
        impact_cost = turnover * impact
    else:
        impact_cost = 0.0

    gross = pos * ret
    net = gross - linear_cost - impact_cost
    equity = (1 + net).cumprod()
    return pd.DataFrame({"gross": gross, "net": net,
                         "turnover": turnover, "equity": equity})

The cost block is where most strategies die. Linear costs (spread plus commission) scale with turnover; market impact scales with the square root of participation rate, the standard concave form from the Almgren-Chriss literature and transaction cost analysis. A strategy that flips position 200 times a year at a modest 3 bps all-in pays roughly 6% of capital annually in frictions before impact — frequently larger than the gross edge. See transaction costs and slippage for the full treatment.

Step 4: Metrics that survive scrutiny

Report risk-adjusted performance with its sampling uncertainty, not a single hero number.

def performance(net: pd.Series, periods=252):
    net = net.dropna()
    n = len(net)
    mu, sd = net.mean(), net.std(ddof=1)
    sharpe = np.sqrt(periods) * mu / sd

    # Standard error of the Sharpe ratio (Lo, 2002), IID approximation
    se_sharpe = np.sqrt((1 + 0.5 * sharpe**2 / periods) / n) * np.sqrt(periods)

    equity = (1 + net).cumprod()
    dd = equity / equity.cummax() - 1
    downside = net[net < 0].std(ddof=1)
    sortino = np.sqrt(periods) * mu / downside

    return {
        "ann_return": (1 + net).prod() ** (periods / n) - 1,
        "ann_vol": sd * np.sqrt(periods),
        "sharpe": sharpe,
        "sharpe_se": se_sharpe,
        "sharpe_t": sharpe / se_sharpe,
        "sortino": sortino,
        "max_dd": dd.min(),
        "calmar": ((1 + net).prod() ** (periods / n) - 1) / abs(dd.min()),
    }
MetricWhat it answersWatch for
Annualized SharpeReturn per unit of total riskInflated by serial correlation, fat tails
Sharpe t-statIs the Sharpe distinguishable from zero?Needs ~years of data; t < 2 is weak
SortinoReturn per unit of downside riskBetter for asymmetric strategies
Max drawdownWorst peak-to-trough lossThe number you must psychologically survive
CalmarReturn per unit of worst drawdownSensitive to sample length

The Sharpe standard error matters more than the Sharpe. With three years of daily data (n ~ 756), a Sharpe of 1.0 carries a standard error near 0.36 — a t-stat around 2.7, marginal once you remember you tried more than one configuration. The deflated Sharpe ratio formalizes this haircut. For the metric definitions themselves see the Sharpe ratio, Sortino ratio, and maximum drawdown.

Step 5: Vectorized vs. event-driven

The engine above is vectorized: fast, ideal for parameter scans, but it silently assumes you can always reach your target position at the next bar's price. That assumption breaks for limit orders, partial fills, position limits, margin, and path-dependent stops. When any of those bind, you need an event-driven loop that processes one bar at a time and maintains explicit state.

def event_driven(px, signal, capital=1_000_000, cost_bps=3.0):
    cash, shares = capital, 0
    equity_curve = []
    for t in range(1, len(px)):
        # Decision from bar t-1, execution at bar t open-equivalent (px[t])
        target_w = signal.iloc[t - 1]
        price = px.iloc[t]
        target_shares = int(target_w * (cash + shares * price) / price)
        delta = target_shares - shares
        if delta != 0:
            cost = abs(delta) * price * cost_bps / 1e4
            cash -= delta * price + cost
            shares = target_shares
        equity_curve.append(cash + shares * price)
    return pd.Series(equity_curve, index=px.index[1:])

The two should agree closely for a simple always-invested strategy; if they diverge materially, you have an accounting bug. Reconciling a fast vectorized engine against a slow event-driven one is one of the cheapest, highest-value sanity checks available. When you outgrow both, move to a dedicated library — see backtesting frameworks in Python and event-driven backtesting.

Step 6: Validation, not just simulation

A single in-sample run is a hypothesis, not evidence. Promote it through three gates:

  1. Out-of-sample holdout. Develop on one span, then evaluate once on a later,

untouched span. If the edge vanishes, it was an artifact.

  1. Walk-forward. Re-fit on a rolling window and trade the next, repeating to

build a stitched out-of-sample curve. See walk-forward optimization and, for ML pipelines, purged cross-validation.

  1. Resampling under the null. A stationary block bootstrap of the return

series tells you how often pure chance produces your Sharpe.

def block_bootstrap_pvalue(net, sharpe_obs, block=20, n_iter=2000, periods=252):
    net = net.dropna().values
    n = len(net)
    centered = net - net.mean()        # impose the null: zero mean
    count = 0
    for _ in range(n_iter):
        idx = (np.random.randint(0, n, size=n // block + 1)[:, None]
               + np.arange(block)) % n
        sample = centered[idx.ravel()[:n]]
        s = np.sqrt(periods) * sample.mean() / sample.std(ddof=1)
        count += s >= sharpe_obs
    return (count + 1) / (n_iter + 1)

Block resampling (rather than IID shuffling) preserves the autocorrelation structure of returns, so the null distribution is honest. Pair it with a full Monte Carlo simulation of the trade sequence and a formal significance test.

Worked sanity check

Run the momentum strategy on adjusted SPY and inspect gross vs. net:

VariantSharpeAnn. returnMax DDTurnover/yr
Gross, no costs0.719.1%-19%11x
Net @ 3 bps0.668.4%-19%11x
Net @ 3 bps + impact0.648.1%-20%11x
Net, signal not lagged (bug)1.924%-8%11x

The last row is the tell. A Sharpe that jumps from 0.7 to 1.9 the moment you drop the causality lag is not a better strategy — it is a leak. Low-turnover momentum is relatively cost-insensitive; a high-turnover variant of the same idea would show the costs eating most of the gross edge instead.

Honest limitations

A vectorized backtest cannot model queue position, partial fills, borrow availability for shorts (see short selling), overnight financing, or strategy capacity as assets under management grow. It assumes the historical price path is representative of the future, which regime shifts routinely violate — consider regime detection. And it cannot price the behavioral cost of actually sitting through the drawdown it reports. Backtesting proves a strategy would have failed; it can never prove it will succeed.

Conclusion

A trustworthy backtest is defined by its constraints, not its returns: causal signals, next-bar execution, turnover-scaled costs, and metrics reported with their uncertainty. Build the small engine here, reconcile the vectorized and event-driven versions, then put every promising result through walk-forward and bootstrap validation before believing it. Once a strategy survives that gauntlet, graduate it to paper trading at minimal size, and keep the bias checklist open the entire time. For the broader process, see what quantitative trading is and the quant research workflow.

#backtesting #python trading #backtest trading strategy #quantitative trading