TradingTeaching Press · Dubai, UAE Trading is high-risk. Check local laws, your residency rules and your finances. Risk warning
🤖 Trading Automation

Building a Python Backtesting Framework from Scratch

Build your own backtesting framework in Python to test and validate trading strategies before going live.

7 min read TradingTeaching Team

Backtesting is critical for validating strategies before risking real money. This guide shows you how to build a flexible backtesting framework in Python.

Why Build Your Own?

Advantages

  • Complete control over logic
  • Understand every calculation
  • Customize for your needs
  • No library limitations

When to Use Libraries Instead

  • Complex portfolio management
  • Speed is critical
  • Need built-in indicators

Framework Architecture

Core Components

BacktestFramework/
│
├── data/
│   └── data_loader.py      # Load price data
│
├── strategy/
│   ├── base_strategy.py    # Base class
│   └── ma_crossover.py     # Example strategy
│
├── engine/
│   ├── backtest_engine.py  # Main engine
│   └── portfolio.py        # Track positions
│
├── analysis/
│   └── metrics.py          # Performance metrics
│
└── main.py                 # Entry point

Data Loader

# data/data_loader.py
import pandas as pd
import yfinance as yf

class DataLoader:
    def __init__(self):
        self.data = None

    def load_from_yahoo(self, symbol, start, end):
        """Load data from Yahoo Finance"""
        self.data = yf.download(symbol, start=start, end=end)
        self.data = self.data.rename(columns={
            'Open': 'open',
            'High': 'high',
            'Low': 'low',
            'Close': 'close',
            'Volume': 'volume'
        })
        return self.data

    def load_from_csv(self, filepath):
        """Load data from CSV file"""
        self.data = pd.read_csv(filepath, parse_dates=['date'])
        self.data.set_index('date', inplace=True)
        return self.data

    def prepare_data(self):
        """Clean and prepare data"""
        if self.data is None:
            raise ValueError("No data loaded")

        # Remove NaN
        self.data.dropna(inplace=True)

        # Calculate returns
        self.data['returns'] = self.data['close'].pct_change()

        return self.data

Base Strategy Class

# strategy/base_strategy.py
from abc import ABC, abstractmethod

class BaseStrategy(ABC):
    def __init__(self, name="BaseStrategy"):
        self.name = name
        self.parameters = {}

    @abstractmethod
    def generate_signals(self, data):
        """
        Generate trading signals

        Returns DataFrame with 'signal' column:
        1 = Buy, -1 = Sell, 0 = Hold
        """
        pass

    def set_parameters(self, **kwargs):
        """Set strategy parameters"""
        self.parameters.update(kwargs)

    def get_parameters(self):
        """Get current parameters"""
        return self.parameters

Example Strategy: MA Crossover

# strategy/ma_crossover.py
import pandas as pd
from .base_strategy import BaseStrategy

class MACrossoverStrategy(BaseStrategy):
    def __init__(self, fast_period=20, slow_period=50):
        super().__init__(name="MA_Crossover")
        self.parameters = {
            'fast_period': fast_period,
            'slow_period': slow_period
        }

    def generate_signals(self, data):
        """Generate signals based on MA crossover"""
        df = data.copy()

        fast = self.parameters['fast_period']
        slow = self.parameters['slow_period']

        # Calculate moving averages
        df['fast_ma'] = df['close'].rolling(fast).mean()
        df['slow_ma'] = df['close'].rolling(slow).mean()

        # Generate signals
        df['signal'] = 0

        # Buy when fast crosses above slow
        df.loc[df['fast_ma'] > df['slow_ma'], 'signal'] = 1

        # Sell when fast crosses below slow
        df.loc[df['fast_ma'] < df['slow_ma'], 'signal'] = -1

        # Only keep crossover points (signal changes)
        df['position'] = df['signal'].diff()

        return df

Portfolio Tracker

`python # engine/portfolio.py import pandas as pd

class Portfolio: def __init__(self, initial_capital=10000, commission=0.001): self.initial_capital = initial_capital self.capital = initial_capital self.commission = commission self.positions = {} self.trades = [] self.equity_curve = []

def open_position(self, symbol, direction, price, size, date): """Open a new position""" cost = price * size commission = cost * self.commission

if direction == 'long': self.capital -= (cost + commission) else: self.capital += (cost - commission)

self.positions[symbol] = { 'direction': direction, 'entry_price': price, 'size': size, 'entry_date': date }

self.trades.append({ 'symbol': symbol, 'type': 'open', 'direction': direction, 'price': price, 'size': size, 'date': date, 'commission': commission })

def close_position(self, symbol, price, date): """Close existing position""" if symbol not in self.positions: return 0

pos = self.positions[symbol] size = pos['size'] cost = price * size commission = cost * self.commission

if pos['direction'] == 'long': pnl = (price - pos['entry_price']) * size - commission self.capital += (cost - commission) else: pnl = (pos['entry_price'] - price) * size - commission self.capital -= (cost + commission)

self.trades.append({ 'symbol': symbol, 'type': 'close', 'direction': pos['direction'], 'entry_price': pos['entry_price'], 'exit_price': price, 'size': size, 'date': date, 'pnl': pnl, 'commission': commission })

del self.positions[symbol] return pnl

def get_position_value(self, symbol, current_price): """Get current value of position""" if symbol not in self.positions: return 0

pos = self.positions[symbol] if pos['direction'] == 'long': return pos['size'] * current_price else: return pos['size'] (2 pos['entry_price'] - current_price)

def get_total_equity(self, current_prices): """Get total portfolio equity""" equity = self.capital

for symbol, pos in self.positions.items(): if symbol in current_prices: equity += self.get_position_value(symbol, current_prices[symbol])

return equity

def record_equity(self, date, equity): """Record equity for curve""" self.equity_curve.append({ 'date': date, 'equity': equity })

def get_equity_df(self): """Get equity curve as DataFrame""" return pd.DataFrame(self.equity_curve)

def get_trades_df(self): """Get trades as DataFrame""" return pd.DataFrame(self.trades) `

Backtest Engine

# engine/backtest_engine.py
import pandas as pd
from .portfolio import Portfolio

class BacktestEngine:
    def __init__(self, data, strategy, initial_capital=10000,
                 commission=0.001, position_size=0.1):
        self.data = data
        self.strategy = strategy
        self.initial_capital = initial_capital
        self.commission = commission
        self.position_size = position_size
        self.portfolio = None
        self.results = None

    def run(self):
        """Run the backtest"""
        # Generate signals
        signals = self.strategy.generate_signals(self.data)

        # Initialize portfolio
        self.portfolio = Portfolio(self.initial_capital, self.commission)

        symbol = "SYMBOL"
        in_position = False
        current_direction = None

        # Iterate through each bar
        for i in range(len(signals)):
            date = signals.index[i]
            row = signals.iloc[i]
            price = row['close']
            signal = row['signal']

            # Calculate position size in units
            equity = self.portfolio.capital
            if in_position:
                equity = self.portfolio.get_total_equity({symbol: price})

            size = (equity * self.position_size) / price

            # Check for signal changes
            if signal == 1 and not in_position:
                # Buy signal
                self.portfolio.open_position(symbol, 'long', price, size, date)
                in_position = True
                current_direction = 'long'

            elif signal == -1 and in_position and current_direction == 'long':
                # Close long, open short
                self.portfolio.close_position(symbol, price, date)
                self.portfolio.open_position(symbol, 'short', price, size, date)
                current_direction = 'short'

            elif signal == 1 and in_position and current_direction == 'short':
                # Close short, open long
                self.portfolio.close_position(symbol, price, date)
                self.portfolio.open_position(symbol, 'long', price, size, date)
                current_direction = 'long'

            elif signal == -1 and not in_position:
                # Short signal
                self.portfolio.open_position(symbol, 'short', price, size, date)
                in_position = True
                current_direction = 'short'

            # Record equity
            equity = self.portfolio.get_total_equity({symbol: price})
            self.portfolio.record_equity(date, equity)

        # Close any remaining position
        if in_position:
            final_price = signals['close'].iloc[-1]
            final_date = signals.index[-1]
            self.portfolio.close_position(symbol, final_price, final_date)

        self.results = {
            'equity_curve': self.portfolio.get_equity_df(),
            'trades': self.portfolio.get_trades_df(),
            'final_capital': self.portfolio.capital
        }

        return self.results

    def get_results(self):
        """Get backtest results"""
        return self.results

Performance Metrics

# analysis/metrics.py
import pandas as pd
import numpy as np

class PerformanceMetrics:
    def __init__(self, equity_curve, trades, initial_capital):
        self.equity = equity_curve
        self.trades = trades
        self.initial_capital = initial_capital

    def calculate_all(self):
        """Calculate all metrics"""
        return {
            'Total Return': self.total_return(),
            'Annual Return': self.annual_return(),
            'Sharpe Ratio': self.sharpe_ratio(),
            'Max Drawdown': self.max_drawdown(),
            'Win Rate': self.win_rate(),
            'Profit Factor': self.profit_factor(),
            'Total Trades': self.total_trades(),
            'Avg Trade': self.average_trade()
        }

    def total_return(self):
        """Calculate total return"""
        final = self.equity['equity'].iloc[-1]
        return (final - self.initial_capital) / self.initial_capital

    def annual_return(self):
        """Calculate annualized return"""
        total = self.total_return()
        days = len(self.equity)
        years = days / 252
        if years == 0:
            return 0
        return (1 + total) ** (1 / years) - 1

    def sharpe_ratio(self, risk_free=0):
        """Calculate Sharpe ratio"""
        returns = self.equity['equity'].pct_change().dropna()
        if len(returns) == 0 or returns.std() == 0:
            return 0

        excess_returns = returns.mean() - risk_free / 252
        return (excess_returns / returns.std()) * np.sqrt(252)

    def max_drawdown(self):
        """Calculate maximum drawdown"""
        equity = self.equity['equity']
        peak = equity.expanding(min_periods=1).max()
        drawdown = (equity - peak) / peak
        return drawdown.min()

    def win_rate(self):
        """Calculate win rate"""
        closed = self.trades[self.trades['type'] == 'close']
        if len(closed) == 0:
            return 0

        winners = len(closed[closed['pnl'] > 0])
        return winners / len(closed)

    def profit_factor(self):
        """Calculate profit factor"""
        closed = self.trades[self.trades['type'] == 'close']
        if len(closed) == 0:
            return 0

        gross_profit = closed[closed['pnl'] > 0]['pnl'].sum()
        gross_loss = abs(closed[closed['pnl'] < 0]['pnl'].sum())

        if gross_loss == 0:
            return float('inf') if gross_profit > 0 else 0

        return gross_profit / gross_loss

    def total_trades(self):
        """Count total trades"""
        return len(self.trades[self.trades['type'] == 'close'])

    def average_trade(self):
        """Calculate average trade P&L"""
        closed = self.trades[self.trades['type'] == 'close']
        if len(closed) == 0:
            return 0
        return closed['pnl'].mean()

Complete Example

# main.py
from data.data_loader import DataLoader
from strategy.ma_crossover import MACrossoverStrategy
from engine.backtest_engine import BacktestEngine
from analysis.metrics import PerformanceMetrics
import matplotlib.pyplot as plt

def main():
    # Load data
    loader = DataLoader()
    data = loader.load_from_yahoo("EURUSD=X", "2022-01-01", "2024-01-01")
    data = loader.prepare_data()

    # Create strategy
    strategy = MACrossoverStrategy(fast_period=20, slow_period=50)

    # Run backtest
    engine = BacktestEngine(
        data=data,
        strategy=strategy,
        initial_capital=10000,
        commission=0.0001,
        position_size=0.5
    )

    results = engine.run()

    # Calculate metrics
    metrics = PerformanceMetrics(
        results['equity_curve'],
        results['trades'],
        10000
    )

    performance = metrics.calculate_all()

    # Print results
    print("\n=== Backtest Results ===")
    print(f"Strategy: {strategy.name}")
    print(f"Parameters: {strategy.get_parameters()}")
    print("-" * 30)

    for metric, value in performance.items():
        if isinstance(value, float):
            print(f"{metric}: {value:.4f}")
        else:
            print(f"{metric}: {value}")

    # Plot equity curve
    plt.figure(figsize=(12, 6))
    plt.plot(results['equity_curve']['date'],
             results['equity_curve']['equity'])
    plt.title('Equity Curve')
    plt.xlabel('Date')
    plt.ylabel('Equity')
    plt.grid(True)
    plt.show()

if __name__ == "__main__":
    main()

Adding More Strategies

`python # strategy/rsi_strategy.py import pandas as pd from .base_strategy import BaseStrategy

class RSIStrategy(BaseStrategy): def __init__(self, period=14, oversold=30, overbought=70): super().__init__(name="RSI_MeanReversion") self.parameters = { 'period': period, 'oversold': oversold, 'overbought': overbought }

def calculate_rsi(self, prices, period): """Calculate RSI indicator""" delta = prices.diff() gain = (delta.where(delta > 0, 0)).rolling(window=period).mean() loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean()

rs = gain / loss rsi = 100 - (100 / (1 + rs)) return rsi

def generate_signals(self, data): """Generate signals based on RSI""" df = data.copy()

period = self.parameters['period'] oversold = self.parameters['oversold'] overbought = self.parameters['overbought']

df['rsi'] = self.calculate_rsi(df['close'], period)

df['signal'] = 0 df.loc[df['rsi'] < oversold, 'signal'] = 1 # Buy df.loc[df['rsi'] > overbought, 'signal'] = -1 # Sell

return df ``

Key Takeaways

  • Build modular components for flexibility
  • Use base classes for consistent strategy interface
  • Track all trades for detailed analysis
  • Calculate comprehensive performance metrics
  • Visualize equity curve for insights
  • Easy to add new strategies
  • Test thoroughly before live trading

What's Next?

Learn how to deploy Python trading bots to run 24/7 on cloud servers.


This article is for educational purposes only and does not constitute financial advice.

Risk warning. This is education only, not financial, investment, tax or legal advice, and not a recommendation to buy or sell anything. Trading forex, CFDs, stocks, commodities, futures, options and crypto is high-risk; with leverage you can lose more than you deposit, and most retail traders lose money. Before you trade or invest in any form, check the local laws where you are and the rules of your country of residence, and consider your own financial situation; if in doubt, ask a licensed adviser. Past and tested results, and all examples, are for illustration only and do not guarantee future results.

Get the strategy right before you automate it

An automated system is only as good as the rules behind it. The book series covers building and testing those rules before a line of code is written.

Explore the books