Trading & Python β€” 18 min read

How to Build a Backtesting Engine in Python: The Complete Guide

Production-ready Python code for data layers, strategy engines, execution simulators with slippage, performance metrics, and walk-forward analysis.

πŸ“… May 8, 2026 πŸ“Š Trading 🐍 Python ⏱ 18 min

Why Backtesting Is the Difference Between Winners and Losers

Here's a number that should terrify you: 95% of retail algorithmic traders lose money. Not because their strategies are bad β€” but because they never properly tested them before risking real capital.

Backtesting is the process of simulating a trading strategy against historical data to see how it would have performed. It's the single most important step between "I have an idea" and "I'm putting money on the line."

At ZOO, we've built backtesting systems for trading platforms that process millions of data points. In this guide, we're giving you the exact architecture β€” with production-ready Python code you can run today.


The 3 Types of Backtesting

1. Vectorized Backtesting (Fast, Simplest)

Best for: Quick strategy prototyping, simple indicators.

import pandas as pd
import numpy as np

def vectorized_sma_backtest(df, fast=10, slow=50):
    """Simple Moving Average Crossover β€” vectorized."""
    df = df.copy()
    df['fast_ma'] = df['close'].rolling(fast).mean()
    df['slow_ma'] = df['close'].rolling(slow).mean()
    
    df['signal'] = 0
    df.loc[df['fast_ma'] > df['slow_ma'], 'signal'] = 1
    df.loc[df['fast_ma'] < df['slow_ma'], 'signal'] = -1
    
    df['market_return'] = df['close'].pct_change()
    df['strategy_return'] = df['signal'].shift(1) * df['market_return']
    
    df['cumulative_market'] = (1 + df['market_return']).cumprod()
    df['cumulative_strategy'] = (1 + df['strategy_return']).cumprod()
    
    return df

Pros: Runs in milliseconds on years of data. Cons: Assumes instant execution at close prices, no slippage, no transaction costs.

2. Event-Driven Backtesting (Realistic)

Best for: Production-grade backtesting, complex order types, realistic fills. This is what we use at ZOO. Every bar triggers an event. The strategy reacts. The execution engine simulates fills.

3. Walk-Forward Analysis (Robust)

Best for: Validating that your strategy isn't just overfitted to history. Split data into chunks. Train on chunk 1, test on chunk 2. Roll forward. Repeat.


Architecture Overview

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ BACKTESTING ENGINE β”‚ β”‚ β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ Data │──▢│ Strategy │──▢│ Execution β”‚ β”‚ β”‚ β”‚ Layer β”‚ β”‚ Engine β”‚ β”‚ Simulator β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ β”‚ Portfolio│◀──│ Risk β”‚β—€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ Manager β”‚ β”‚ Manager β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β”‚ β”‚ β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ Metrics β”‚ β”‚ β”‚ β”‚ Engine β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Each component is independent. Swap data sources, change strategies, adjust risk rules β€” without touching the rest.


Building the Data Layer

The data layer fetches, cleans, and serves price data. It should handle multiple timeframes and instruments.

from dataclasses import dataclass, field
from typing import List, Optional
from datetime import datetime
import pandas as pd
import numpy as np

@dataclass
class OHLCV:
    timestamp: datetime
    open: float
    high: float
    low: float
    close: float
    volume: float

@dataclass
class DataFeed:
    symbol: str
    timeframe: str
    data: pd.DataFrame = field(default_factory=pd.DataFrame)
    
    @classmethod
    def from_dataframe(cls, df, symbol, timeframe):
        df = df.copy()
        df = cls._validate_and_clean(df)
        return cls(symbol=symbol, timeframe=timeframe, data=df)
    
    @staticmethod
    def _validate_and_clean(df):
        required = ['timestamp','open','high','low','close','volume']
        for col in required:
            if col not in df.columns:
                raise ValueError(f"Missing: {col}")
        df = df.drop_duplicates(subset='timestamp')
        df = df.set_index('timestamp')
        df = df.resample('1min').ffill(limit=3)
        df = df.reset_index()
        df = df[df['volume'] > 0]
        df = df[df['high'] >= df['low']]
        return df.reset_index(drop=True)
    
    def __iter__(self):
        for idx, row in self.data.iterrows():
            yield OHLCV(
                timestamp=row['timestamp'], open=row['open'],
                high=row['high'], low=row['low'],
                close=row['close'], volume=row['volume']
            )

The Strategy Engine

A strategy is a function that receives market data and emits signals. Keep it pure β€” no side effects.

from enum import Enum
from dataclasses import dataclass

class SignalType(Enum):
    BUY = "buy"
    SELL = "sell"
    CLOSE = "close"
    NONE = "none"

@dataclass
class Signal:
    type: SignalType
    price: float
    stop_loss: Optional[float] = None
    take_profit: Optional[float] = None
    size: float = 1.0
    reason: str = ""

class Strategy:
    def __init__(self, params=None):
        self.params = params or {}
    
    def on_bar(self, bar, history) -> Signal:
        raise NotImplementedError

class MeanReversionStrategy(Strategy):
    """Bollinger Band mean reversion."""
    def __init__(self, period=20, num_std=2.0):
        super().__init__()
        self.period = period
        self.num_std = num_std
    
    def on_bar(self, bar, history):
        if len(history) < self.period:
            return Signal(type=SignalType.NONE, price=bar.close)
        
        closes = [h.close for h in history[-self.period:]]
        sma = np.mean(closes)
        std = np.std(closes)
        lower = sma - self.num_std * std
        
        if bar.close <= lower:
            return Signal(
                type=SignalType.BUY, price=bar.close,
                stop_loss=bar.close - 2 * std,
                take_profit=sma, size=0.02,
                reason=f"Price below lower BB"
            )
        if bar.close >= sma:
            return Signal(type=SignalType.SELL, price=bar.close)
        
        return Signal(type=SignalType.NONE, price=bar.close)

The Execution Simulator

This is where most amateur backtests fail. Realistic execution matters.

@dataclass
class Fill:
    timestamp: datetime
    side: str
    price: float
    quantity: float
    commission: float
    slippage: float

class ExecutionSimulator:
    def __init__(self, commission_rate=0.001, 
                 slippage_model='volatility', slippage_value=0.5):
        self.commission_rate = commission_rate
        self.slippage_model = slippage_model
        self.slippage_value = slippage_value
        self.fills = []
        self.current_position = None
    
    def calculate_slippage(self, bar, side):
        if self.slippage_model == 'volatility':
            bar_range = (bar.high - bar.low) / bar.close
            return bar.close * bar_range * self.slippage_value
        return 0.0
    
    def execute_signal(self, signal, bar):
        if signal.type == SignalType.NONE:
            return None
        slippage = self.calculate_slippage(bar, signal.type.value)
        fill_price = bar.close + slippage if signal.type == SignalType.BUY else bar.close - slippage
        commission = fill_price * signal.size * self.commission_rate
        
        fill = Fill(
            timestamp=bar.timestamp, side=signal.type.value,
            price=fill_price, quantity=signal.size,
            commission=commission, slippage=slippage
        )
        self.fills.append(fill)
        return fill
    
    def check_stops(self, bar):
        """Check if SL/TP was hit during this bar."""
        if self.current_position is None:
            return None
        pos = self.current_position
        if pos.side == 'long':
            if pos.stop_loss and bar.low <= pos.stop_loss:
                # Execute stop loss fill
                self.current_position = None
                return True
            if pos.take_profit and bar.high >= pos.take_profit:
                self.current_position = None
                return True
        return None
Key insight: In backtesting, you CAN always buy at the close price. In reality, you can't. Slippage kills more strategies than bad signals.

Performance Metrics That Actually Matter

Most people only look at total return. That's dangerously incomplete.

@dataclass
class BacktestMetrics:
    total_return: float
    annualized_return: float
    sharpe_ratio: float
    sortino_ratio: float
    max_drawdown: float
    max_drawdown_duration: int
    total_trades: int
    win_rate: float
    avg_win: float
    avg_loss: float
    profit_factor: float
    total_commission: float
    total_slippage: float
    cost_drag: float

class MetricsCalculator:
    @staticmethod
    def calculate(equity_curve, fills, bars_count, risk_free_rate=0.05):
        equity = np.array(equity_curve)
        returns = np.diff(equity) / equity[:-1]
        returns = returns[returns != 0]
        
        total_return = (equity[-1] / equity[0]) - 1
        n_years = bars_count / 252
        annualized = (1 + total_return) ** (1 / max(n_years, 0.01)) - 1
        
        excess = returns - (risk_free_rate / 252)
        sharpe = np.mean(excess) / (np.std(returns) + 1e-10) * np.sqrt(252)
        
        downside = returns[returns < 0]
        downside_std = np.std(downside) if len(downside) > 0 else 1e-10
        sortino = np.mean(excess) / downside_std * np.sqrt(252)
        
        peak = np.maximum.accumulate(equity)
        drawdown = (equity - peak) / peak
        max_dd = np.min(drawdown)
        
        # Trade analysis
        trades = []
        for i in range(0, len(fills)-1, 2):
            pnl = (fills[i+1].price - fills[i].price) * fills[i].quantity
            pnl -= fills[i].commission + fills[i+1].commission
            trades.append(pnl)
        
        wins = [t for t in trades if t > 0]
        losses = [t for t in trades if t <= 0]
        
        return BacktestMetrics(
            total_return=total_return,
            annualized_return=annualized,
            sharpe_ratio=sharpe, sortino_ratio=sortino,
            max_drawdown=max_dd, max_drawdown_duration=0,
            total_trades=len(trades),
            win_rate=len(wins)/max(len(trades),1),
            avg_win=np.mean(wins) if wins else 0,
            avg_loss=np.mean(losses) if losses else 0,
            profit_factor=abs(sum(wins)/sum(losses)) if losses and sum(losses)!=0 else float('inf'),
            total_commission=sum(f.commission for f in fills),
            total_slippage=sum(f.slippage*f.quantity for f in fills),
            cost_drag=(sum(f.commission for f in fills))/equity[0]
        )

Walk-Forward Analysis: Avoiding Overfitting

This is the most important section of this guide. Overfitting is why 95% of strategies fail in live trading.

class WalkForwardAnalysis:
    """
    Split data into in-sample (training) and out-of-sample (testing).
    Optimize on in-sample, validate on out-of-sample.
    Roll forward and repeat.
    """
    def __init__(self, strategy_class, param_grid, n_splits=5):
        self.strategy_class = strategy_class
        self.param_grid = param_grid
        self.n_splits = n_splits
    
    def run(self, data, engine):
        n = len(data)
        fold_size = n // self.n_splits
        results = []
        
        for i in range(self.n_splits):
            test_start = i * fold_size
            test_end = min((i+1)*fold_size, n)
            train_end = test_start
            train_start = max(0, int(train_end - fold_size*0.7/0.3))
            
            if train_start >= train_end:
                continue
            
            train = data.iloc[train_start:train_end]
            test = data.iloc[test_start:test_end]
            
            best_params = self._optimize_params(train, engine)
            strategy = self.strategy_class(**best_params)
            metrics = engine.run(test, strategy)
            
            results.append({'fold': i, 'metrics': metrics})
            print(f"Fold {i}: Return={metrics.total_return:.2%}, "
                  f"Sharpe={metrics.sharpe_ratio:.2f}")
        
        avg_return = np.mean([r['metrics'].total_return for r in results])
        avg_sharpe = np.mean([r['metrics'].sharpe_ratio for r in results])
        print(f"Avg OOS Return: {avg_return:.2%}")
        print(f"Avg OOS Sharpe: {avg_sharpe:.2f}")
        return results

Complete Working Example

import pandas as pd
import numpy as np
from datetime import datetime, timedelta

def generate_sample_data(n_bars=5000, start_price=100.0, volatility=0.02):
    np.random.seed(42)
    dates = [datetime(2024,1,1) + timedelta(minutes=i) for i in range(n_bars)]
    prices = [start_price]
    for i in range(1, n_bars):
        drift = 0.0001
        shock = np.random.normal(0, volatility)
        mr = -0.001 * (prices[-1] - start_price) / start_price
        prices.append(max(prices[-1] * (1 + drift + shock + mr), 0.01))
    
    data = []
    for date, close in zip(dates, prices):
        intra = close * volatility * 0.5
        high = close + abs(np.random.normal(0, intra))
        low = close - abs(np.random.normal(0, intra))
        data.append({
            'timestamp': date, 'open': close,
            'high': high, 'low': low,
            'close': close, 'volume': np.random.lognormal(10, 1)
        })
    return pd.DataFrame(data)

if __name__ == "__main__":
    data = generate_sample_data(5000)
    feed = DataFeed.from_dataframe(data, "SYNTH", "1min")
    strategy = MeanReversionStrategy(period=20, num_std=2.0)
    execution = ExecutionSimulator(commission_rate=0.001, slippage_model='volatility')
    
    engine = BacktestEngine(feed, strategy, execution, initial_capital=10000)
    metrics = engine.run()
    print(metrics)

Next Steps: From Backtest to Live Trading

A profitable backtest is just the beginning. Here's the path to production:

Backtest β†’ Paper Trade β†’ Small Live β†’ Scale β†’ Monitor
  Weeks      2-4 weeks    1 month    Gradual   Ongoing
⚠️ Common Pitfall: Overfitting

Your strategy works perfectly on historical data and fails live. Solution: Walk-forward analysis, fewer parameters, simpler strategies.

⚠️ Common Pitfall: Look-Ahead Bias

Your strategy accidentally uses future data. Solution: Never access bar[i+1] in your strategy logic.

⚠️ Common Pitfall: Ignoring Costs

Slippage and commissions can turn a profitable strategy into a losing one. Solution: Always model realistic costs (0.1%+ per round trip).

Want Us to Build Your Trading System?

At ZOO, we build algorithmic trading platforms, backtesting engines, and automated trading systems. Custom backtesting engines, strategy development, live trading infrastructure, AI-powered signal generation.

Our stack: Python, C++, Rust, Next.js, PostgreSQL, Redis, Docker, AWS/GCP


Get a Free Strategy Audit β†’

Send us your backtest results. We'll tell you in 48 hours if your strategy is ready for live trading.

This post is part of our Algorithmic Trading Series. Next week: "How to Connect Your Backtest to a Live Exchange API" β€” with real order execution code.

Last updated: May 8, 2026 | Trading involves risk. Past performance (including backtests) does not guarantee future results.