Building a Simple Strategy Tester in Python

A minimal backtesting harness that teaches you where strategies actually break — and why most off-the-shelf frameworks hide the important parts.

Field note: Written in early 2020 while completing the Executive Programme in Algorithmic Trading. Published here as a record of that period.

Most backtesting frameworks do too much. They abstract away the parts you most need to understand: how positions are sized, how fills are simulated, how slippage compounds over time. When your strategy underperforms in live trading, you want to know exactly where the backtest was optimistic. If you didn't build the backtest, you can't answer that question.

This is a minimal Python tester — around 150 lines — that covers the essentials without hiding anything important.

The core loop

A backtester is a loop over price data that simulates decisions and tracks state. The state is simple: position (long, short, flat), entry price, and equity curve. The loop is simpler than most people expect:

import pandas as pd

def backtest(prices: pd.Series, signals: pd.Series, initial_capital=10_000):
    """
    prices:  closing prices indexed by date
    signals: +1 (long), -1 (short), 0 (flat) — aligned to prices
    """
    position = 0
    entry_price = 0.0
    capital = initial_capital
    equity = []

    for date, price in prices.items():
        signal = signals.get(date, 0)

        # Close existing position if signal flips or goes flat
        if position != 0 and signal != position:
            pnl = (price - entry_price) * position
            capital += pnl
            position = 0

        # Open new position
        if signal != 0 and position == 0:
            position = signal
            entry_price = price

        # Mark-to-market equity
        unrealised = (price - entry_price) * position if position else 0
        equity.append({"date": date, "equity": capital + unrealised})

    return pd.DataFrame(equity).set_index("date")

Metrics that matter

An equity curve is not a result. You need summary statistics that let you compare strategies on equal footing:

def metrics(equity: pd.Series, risk_free=0.0):
    returns = equity.pct_change().dropna()
    total_return = (equity.iloc[-1] / equity.iloc[0]) - 1
    cagr = (1 + total_return) ** (252 / len(returns)) - 1
    sharpe = (returns.mean() - risk_free/252) / returns.std() * (252 ** 0.5)
    roll_max = equity.cummax()
    drawdown = (equity - roll_max) / roll_max
    max_dd = drawdown.min()
    return {"total_return": total_return, "cagr": cagr,
            "sharpe": sharpe, "max_drawdown": max_dd}

Sharpe ratio alone is insufficient. A strategy with a Sharpe of 1.2 and a max drawdown of 40% is not the same as one with a Sharpe of 1.0 and a max drawdown of 8%. Know what you're optimising for before you run the first backtest.

Where simple testers lie

This tester assumes zero transaction costs, perfect fills at closing prices, no slippage, and infinite liquidity. Every one of those assumptions flatters the strategy. Add realistic costs before you trust any result: even a 0.1% round-trip cost applied to a strategy that trades daily compounds to a significant drag over a year. If the strategy's edge disappears under realistic costs, the edge was the assumption, not the strategy.