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.
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.
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.
| Metric | Formula | Good | Great | Why It Matters |
|---|---|---|---|---|
| Total Return | (Final / Initial) - 1 | > 0% | > SPY | Raw performance |
| Sharpe Ratio | (R - Rf) / Std(R) * sqrt(252) | > 0.5 | > 1.5 | Risk-adjusted return |
| Max Drawdown | Max peak-to-trough decline | < 20% | < 10% | Worst-case scenario |
| Win Rate | Winning trades / Total trades | > 40% | > 55% | Consistency |
| Profit Factor | Gross profit / Gross loss | > 1.2 | > 2.0 | Edge magnitude |
| Calmar Ratio | Annual return / Max drawdown | > 0.5 | > 2.0 | Return per unit risk |
| Sortino Ratio | (R - Rf) / Downside Std | > 1.0 | > 2.0 | Penalizes downside only |
Most backtest results are meaningless because of these common errors. Learn them now and you'll save months of wasted effort.
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.
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).
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.
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.
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).
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.
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)
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.
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}")
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.
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.
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%}")
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.
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 (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.
# 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