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.