Quant Trading in Python — Part 2 of 6

Backtesting Engine — Test Before You Risk

Every profitable quant started with a backtest. We'll build a complete engine from scratch, implement two classic strategies, and learn the metrics that separate winners from losers.

Backtest Engine SMA Crossover RSI Reversion Sharpe & Drawdown
Quant Trading in Python2/6
What Is BacktestingCommon PitfallsBacktest EngineSMA CrossoverRSI ReversionWalk-Forward
What Is Backtesting

What Is Backtesting and Why It Matters

Backtesting is the process of testing a trading strategy on historical data to evaluate how it would have performed. It's the scientific method applied to trading: form a hypothesis (your strategy), test it against evidence (historical data), measure the results (metrics), and iterate.

Without backtesting, you're gambling. With backtesting, you're running a statistical experiment. The difference is the difference between a casino patron and the house.

The Backtest Loop

Every backtest follows the same loop: 1) Load historical data. 2) For each bar (day/hour/minute), compute your signal. 3) Based on the signal, decide to buy, sell, or hold. 4) Track positions, PnL, and portfolio value. 5) Compute performance metrics. Simple in concept, tricky in execution — the devil is in the details.

Key Performance Metrics

MetricFormulaGoodGreatWhy It Matters
Total Return(Final / Initial) - 1> 0%> SPYRaw performance
Sharpe Ratio(R - Rf) / Std(R) * sqrt(252)> 0.5> 1.5Risk-adjusted return
Max DrawdownMax peak-to-trough decline< 20%< 10%Worst-case scenario
Win RateWinning trades / Total trades> 40%> 55%Consistency
Profit FactorGross profit / Gross loss> 1.2> 2.0Edge magnitude
Calmar RatioAnnual return / Max drawdown> 0.5> 2.0Return per unit risk
Sortino Ratio(R - Rf) / Downside Std> 1.0> 2.0Penalizes downside only
Profit Factor
PF = Sum(Winning Trades) / |Sum(Losing Trades)| — if PF < 1.0, your strategy loses money
Common Pitfalls

The 5 Deadly Sins of Backtesting

Most backtest results are meaningless because of these common errors. Learn them now and you'll save months of wasted effort.

1. Lookahead Bias

Using future information to make past decisions. Example: computing a 20-day moving average that includes today's close to make today's trading decision. The fix: at each bar, you can only use data available up to that point. Our engine enforces this by iterating chronologically.

2. Survivorship Bias

Testing only on stocks that exist today. If you backtest a universe of S&P 500 stocks from 2010, you're only looking at companies that survived — not the ones that went bankrupt or were delisted. For learning, this is acceptable. For production, you need a survivorship-free dataset (Sharadar, Quandl, Norgate).

3. Ignoring Transaction Costs

A strategy that trades 50 times per day with 0.1% cost per trade = 10% daily drag. We model costs as: cost = commission (10bps) + slippage (5bps) = 15bps per trade. A strategy must earn more than its costs to be viable.

4. Overfitting

Optimizing parameters until the backtest looks perfect on historical data, then watching it fail live. If your strategy has 20 parameters optimized on 5 years of data, it's memorizing the past, not learning patterns. Rule of thumb: fewer than 5 free parameters, validated out-of-sample.

5. Data Snooping

Testing hundreds of variations on the same dataset until one "works." If you test 100 strategies at 95% confidence, 5 will appear significant by chance. The fix: define your strategy BEFORE looking at the data, and always validate on unseen data (walk-forward testing).

What about using Zipline or Backtrader?
These are excellent libraries for production backtesting. But building your own engine first teaches you what's happening under the hood — you'll understand lookback bias, vectorized vs event-driven approaches, and why certain results seem too good to be true. After this part, you'll know enough to use any library intelligently. We'll keep our engine for the rest of this series because it's simpler to modify and debug.
Building the Backtest Engine

The Backtest Engine (~200 Lines)

Our engine is vectorized (operates on entire arrays, not row-by-row loops) for speed, but processes signals chronologically to prevent lookahead bias. It handles transaction costs, tracks positions, and computes all standard metrics.

backtests/engine.py — Core Engine
import numpy as np
import pandas as pd
from dataclasses import dataclass
from typing import Optional, Dict

@dataclass
class BacktestResult:
    """Container for backtest results."""
    equity_curve: pd.Series
    returns: pd.Series
    positions: pd.Series
    trades: pd.DataFrame
    metrics: Dict[str, float]

class BacktestEngine:
    """
    Simple vectorized backtesting engine.

    Usage:
        engine = BacktestEngine(commission=0.001, slippage=0.0005)
        result = engine.run(data, signals)
    """

    def __init__(self,
                 initial_capital: float = 100_000,
                 commission: float = 0.001,
                 slippage: float = 0.0005,
                 risk_free_rate: float = 0.05):
        self.initial_capital = initial_capital
        self.commission = commission
        self.slippage = slippage
        self.risk_free_rate = risk_free_rate

    def run(self, prices: pd.Series,
             signals: pd.Series) -> BacktestResult:
        """
        Run backtest on a signal series.

        Args:
            prices:  pd.Series of close prices (DatetimeIndex)
            signals: pd.Series of positions:
                     +1 = long, -1 = short, 0 = flat

        Returns:
            BacktestResult with equity curve, trades, metrics
        """
        # Align signals with prices
        signals = signals.reindex(prices.index).ffill().fillna(0)

        # Detect trades (signal changes)
        trades = signals.diff().fillna(0)
        trade_mask = trades != 0

        # Transaction costs: apply on trade days
        costs = np.abs(trades) * (self.commission + self.slippage)

        # Daily returns = position * price return - costs
        price_returns = prices.pct_change().fillna(0)
        strategy_returns = signals.shift(1) * price_returns - costs
        strategy_returns = strategy_returns.fillna(0)

        # Equity curve
        equity = self.initial_capital * (1 + strategy_returns).cumprod()

        # Build trade log
        trade_log = self._build_trade_log(
            prices, signals, trade_mask
        )

        # Compute metrics
        metrics = self._compute_metrics(
            strategy_returns, equity, trade_log
        )

        return BacktestResult(
            equity_curve=equity,
            returns=strategy_returns,
            positions=signals,
            trades=trade_log,
            metrics=metrics
        )

    def _compute_metrics(self, returns, equity, trades):
        """Compute all performance metrics."""
        daily_rf = self.risk_free_rate / 252
        excess = returns - daily_rf

        # Core metrics
        total_return = equity.iloc[-1] / self.initial_capital - 1
        ann_return = (1 + total_return) ** (252 / len(returns)) - 1
        ann_vol = returns.std() * np.sqrt(252)
        sharpe = excess.mean() / returns.std() * np.sqrt(252) \
                 if returns.std() > 0 else 0

        # Drawdown
        peak = equity.cummax()
        drawdown = (equity - peak) / peak
        max_dd = drawdown.min()

        # Downside deviation (for Sortino)
        neg_returns = returns[returns < 0]
        downside_std = neg_returns.std() * np.sqrt(252) \
                       if len(neg_returns) > 0 else 0.001
        sortino = (ann_return - self.risk_free_rate) / downside_std

        # Trade stats
        if len(trades) > 0:
            wins = trades[trades['pnl'] > 0]
            losses = trades[trades['pnl'] <= 0]
            win_rate = len(wins) / len(trades)
            avg_win = wins['pnl'].mean() if len(wins) > 0 else 0
            avg_loss = losses['pnl'].mean() if len(losses) > 0 else 0
            profit_factor = abs(wins['pnl'].sum() / losses['pnl'].sum()) \
                            if losses['pnl'].sum() != 0 else 999
        else:
            win_rate = avg_win = avg_loss = 0
            profit_factor = 0

        return {
            'total_return': total_return,
            'ann_return': ann_return,
            'ann_volatility': ann_vol,
            'sharpe_ratio': sharpe,
            'sortino_ratio': sortino,
            'max_drawdown': max_dd,
            'calmar_ratio': ann_return / abs(max_dd) if max_dd != 0 else 0,
            'win_rate': win_rate,
            'profit_factor': profit_factor,
            'num_trades': len(trades),
            'avg_win': avg_win,
            'avg_loss': avg_loss,
        }

    def _build_trade_log(self, prices, signals, trade_mask):
        """Build a log of all trades with entry/exit/PnL."""
        trade_dates = prices.index[trade_mask]
        trades_list = []
        entry_price = None
        entry_date = None
        direction = 0

        for date in trade_dates:
            sig = signals[date]
            px = prices[date]

            if direction == 0 and sig != 0:
                # Open new position
                entry_price = px
                entry_date = date
                direction = sig
            elif direction != 0 and sig != direction:
                # Close position
                pnl_pct = direction * (px / entry_price - 1)
                trades_list.append({
                    'entry_date': entry_date,
                    'exit_date': date,
                    'direction': 'long' if direction > 0 else 'short',
                    'entry_price': entry_price,
                    'exit_price': px,
                    'pnl': pnl_pct,
                    'holding_days': (date - entry_date).days
                })
                # If reversing, open new position
                if sig != 0:
                    entry_price = px
                    entry_date = date
                    direction = sig
                else:
                    direction = 0
                    entry_price = None

        return pd.DataFrame(trades_list)
Strategy 1: SMA Crossover

SMA Crossover (20/50)

The simplest trend-following strategy: go long when the fast moving average (20-day) crosses above the slow (50-day), go flat when it crosses below. It captures trends, but suffers in sideways markets.

strategies/sma_crossover.py
import pandas as pd
import numpy as np

def sma_crossover_signals(prices: pd.Series,
                           fast: int = 20,
                           slow: int = 50) -> pd.Series:
    """
    SMA Crossover Strategy.

    Buy when SMA_fast > SMA_slow.
    Sell when SMA_fast < SMA_slow.

    Returns: pd.Series of signals (+1 long, 0 flat)
    """
    sma_fast = prices.rolling(fast).mean()
    sma_slow = prices.rolling(slow).mean()

    # Signal: +1 when fast > slow, 0 otherwise
    signals = pd.Series(
        np.where(sma_fast > sma_slow, 1, 0),
        index=prices.index
    )

    # No signal until we have enough data
    signals.iloc[:slow] = 0

    return signals

# === Run the Backtest ===
import yfinance as yf
from backtests.engine import BacktestEngine

# Load data
aapl = yf.download("AAPL", start="2021-01-01", end="2026-01-01", auto_adjust=True)
prices = aapl["Close"]

# Generate signals
signals = sma_crossover_signals(prices, fast=20, slow=50)

# Run backtest
engine = BacktestEngine(commission=0.001, slippage=0.0005)
result = engine.run(prices, signals)

# Print metrics
for k, v in result.metrics.items():
    print(f"{k:<20} {v:.4f}")

Why shift(1)?

In the engine, we use signals.shift(1) to apply today's signal to tomorrow's return. This is critical: when SMA crosses at today's close, you can only execute the trade at tomorrow's open. Without this shift, you'd have lookahead bias — using today's close to trade at today's close, which is impossible in reality.

Strategy 2: RSI Mean Reversion

RSI Mean Reversion

The opposite philosophy from SMA: buy when the market is oversold (RSI < 30), sell when overbought (RSI > 70). Works well in range-bound markets, fails in strong trends.

strategies/rsi_reversion.py
import pandas as pd
import numpy as np

def compute_rsi(prices: pd.Series, period: int = 14) -> pd.Series:
    """Compute Relative Strength Index."""
    delta = prices.diff()
    gain = delta.where(delta > 0, 0).rolling(period).mean()
    loss = (-delta.where(delta < 0, 0)).rolling(period).mean()
    rs = gain / loss
    return 100 - (100 / (1 + rs))

def rsi_reversion_signals(prices: pd.Series,
                           period: int = 14,
                           oversold: int = 30,
                           overbought: int = 70) -> pd.Series:
    """
    RSI Mean Reversion Strategy.

    Buy when RSI crosses below oversold level.
    Sell when RSI crosses above overbought level.
    """
    rsi = compute_rsi(prices, period)

    # State machine: track position
    signals = pd.Series(0, index=prices.index)
    position = 0

    for i in range(1, len(rsi)):
        if pd.isna(rsi.iloc[i]):
            continue

        if position == 0 and rsi.iloc[i] < oversold:
            position = 1  # Enter long
        elif position == 1 and rsi.iloc[i] > overbought:
            position = 0  # Exit

        signals.iloc[i] = position

    return signals

# === Run the Backtest ===
rsi_signals = rsi_reversion_signals(prices, period=14, oversold=30, overbought=70)
rsi_result = engine.run(prices, rsi_signals)

# Compare strategies
print("=== SMA Crossover ===")
print(f"Return: {result.metrics['total_return']:.2%}")
print(f"Sharpe: {result.metrics['sharpe_ratio']:.2f}")
print(f"Max DD: {result.metrics['max_drawdown']:.2%}")

print("\n=== RSI Reversion ===")
print(f"Return: {rsi_result.metrics['total_return']:.2%}")
print(f"Sharpe: {rsi_result.metrics['sharpe_ratio']:.2f}")
print(f"Max DD: {rsi_result.metrics['max_drawdown']:.2%}")
Walk-Forward Validation

Walk-Forward Validation

In-sample results are meaningless for predicting future performance. Walk-forward validation splits your data into training and testing windows, sliding forward through time. It's the gold standard for evaluating trading strategies.

backtests/walk_forward.py
def walk_forward_test(prices: pd.Series,
                       strategy_fn,
                       train_days: int = 504,   # 2 years
                       test_days: int = 63,     # 3 months
                       step_days: int = 63,     # 3 months
                       **strategy_params):
    """
    Walk-forward validation.

    Slides a [train_window | test_window] across the data.
    Strategy is 'trained' (parameters selected) on the train
    window, then tested on the unseen test window.
    """
    results = []
    engine = BacktestEngine()

    for start in range(0, len(prices) - train_days - test_days, step_days):
        # Split
        train = prices.iloc[start : start + train_days]
        test  = prices.iloc[start + train_days : start + train_days + test_days]

        # Generate signals on FULL window (train + test)
        # but only evaluate on test portion
        full = prices.iloc[start : start + train_days + test_days]
        signals = strategy_fn(full, **strategy_params)

        # Run backtest only on the test window
        test_signals = signals.loc[test.index]
        result = engine.run(test, test_signals)

        results.append({
            'train_start': train.index[0],
            'test_start': test.index[0],
            'test_end': test.index[-1],
            'sharpe': result.metrics['sharpe_ratio'],
            'return': result.metrics['total_return'],
            'max_dd': result.metrics['max_drawdown'],
            'num_trades': result.metrics['num_trades'],
        })

    return pd.DataFrame(results)

# === Usage ===
wf_results = walk_forward_test(
    prices, sma_crossover_signals,
    train_days=504, test_days=63,
    fast=20, slow=50
)

print(f"Walk-forward windows: {len(wf_results)}")
print(f"Avg OOS Sharpe:      {wf_results['sharpe'].mean():.2f}")
print(f"% Profitable:        {(wf_results['return'] > 0).mean():.0%}")
print(f"Worst OOS Return:    {wf_results['return'].min():.2%}")

In-Sample vs Out-of-Sample

In-sample (IS): the data you used to develop/tune your strategy. Results look great because the strategy was designed to fit this data. Out-of-sample (OOS): data the strategy has never seen. This is the only honest measure of performance. If your strategy has a Sharpe of 3.0 in-sample but 0.2 out-of-sample, it's overfit. A healthy ratio: OOS Sharpe should be at least 50% of IS Sharpe.

Transaction Cost Sensitivity

Python — Cost Impact Analysis
# How sensitive is the strategy to transaction costs?
cost_levels = [0, 0.0005, 0.001, 0.002, 0.005]

for cost in cost_levels:
    eng = BacktestEngine(commission=cost, slippage=cost/2)
    res = eng.run(prices, signals)
    print(f"Cost: {cost*100:.2f}%  |  "
          f"Return: {res.metrics['total_return']:>8.2%}  |  "
          f"Sharpe: {res.metrics['sharpe_ratio']:>6.2f}  |  "
          f"Trades: {res.metrics['num_trades']}")

# Typical output:
# Cost: 0.00%  |  Return:   42.31%  |  Sharpe:  0.85  |  Trades: 24
# Cost: 0.05%  |  Return:   39.87%  |  Sharpe:  0.79  |  Trades: 24
# Cost: 0.10%  |  Return:   37.43%  |  Sharpe:  0.73  |  Trades: 24
# Cost: 0.20%  |  Return:   32.55%  |  Sharpe:  0.60  |  Trades: 24
# Cost: 0.50%  |  Return:   17.91%  |  Sharpe:  0.28  |  Trades: 24

Part 2 Checklist

Next: Part 3 of 6
Factors & Signals — Finding Your Edge
Quant Trading in Python2/6