← Back to Home
How to Properly Bakctest Trading Strategies with Backtrader in Python

How to Properly Bakctest Trading Strategies with Backtrader in Python

Backtesting is more than simply checking if a trading strategy works on historical data — it’s about rigorously stress-testing your approach across various market conditions. In this article, we delve into a robust methodology for running multiple randomized backtests over random assets and time periods. This enables you to derive the statistical distribution of both market and strategy returns, revealing insights into the true performance and risk of your trading ideas.

Why Randomized Backtesting?

Traditional backtests often run on a specific asset or a fixed timeframe. However, markets are dynamic, and a strategy that works on one asset or during one market cycle might not translate to another. Randomized backtesting addresses this by:

The Methodology

Our approach uses Python’s Backtrader library for strategy testing, along with yfinance for historical data acquisition. We add a dash of randomness to both asset selection and window period, then run each simulation in a loop. The aggregated results of multiple backtests provide the density distribution of returns, which is then visualized with overlapping histograms and mean markers.

Code Walkthrough

Below is a comprehensive code example that demonstrates this process. The code:

import backtrader as bt  
import yfinance as yf  
import random  
from datetime import datetime, timedelta  
import warnings  
warnings.filterwarnings(action='ignore')  
from tqdm import tqdm  
import sys  
import os   
import contextlib  

 
# Context manager for suppressing verbose outputs from yfinance  
@contextlib.contextmanager  
def suppress_stdout_stderr():  
    with open(os.devnull, 'w') as fnull:  
        old_stdout = sys.stdout  
        old_stderr = sys.stderr  
        try:  
            sys.stdout = fnull  
            sys.stderr = fnull  
            yield  
        finally:  
            sys.stdout = old_stdout  
            sys.stderr = old_stderr  
  
# EMA Crossover Strategy for comparison (optional)  
class EMA_Crossover_Strategy(bt.Strategy):  
    params = (  
        ('short_window', 7),  
        ('long_window', 30),  
    )  
    def __init__(self):  
        self.data_close = self.datas[0].close  
        self.emas = bt.indicators.ExponentialMovingAverage(self.data_close, period=self.params.short_window)  
        self.emal = bt.indicators.ExponentialMovingAverage(self.data_close, period=self.params.long_window)  
        self.order = None  
    def next(self):  
        if not self.position:  
            cash = self.broker.get_cash()  
            asset_price = self.data_close[0]  
            position_size = cash / asset_price * 0.99  
            if self.emas[0] > self.emal[0]:  
                self.buy(size=position_size)  
        else:  
            if self.emas[0] < self.emal[0]:  
                self.close()  
    def notify_order(self, order):  
        if order.status in [order.Submitted, order.Accepted]:  
            return  
        self.order = None  
# RSI-based Mean Reversion Strategy: buy when oversold, sell when overbought.  
class RSI_Strategy(bt.Strategy):  
    params = (  
        ('rsi_period', 14),  
        ('overbought', 70),  
        ('oversold', 30),  
    )  
    def __init__(self):  
        self.data_close = self.datas[0].close  
        self.rsi = bt.indicators.RelativeStrengthIndex(self.data_close, period=self.params.rsi_period)  
        self.order = None  
    def next(self):  
        if not self.position:  
            if self.rsi[0] < self.params.oversold:  
                cash = self.broker.get_cash()  
                asset_price = self.data_close[0]  
                position_size = cash / asset_price * 0.99  
                self.buy(size=position_size)  
        else:  
            if self.rsi[0] > self.params.overbought:  
                self.close()  
    def notify_order(self, order):  
        if order.status in [order.Submitted, order.Accepted]:  
            return  
        self.order = None  
    
if __name__ == '__main__':  
      
    market_returns = []     # List to store market returns for each test  
    strategy_returns = []   # List to store strategy returns for each test  
      
    cryptos = ['BTC-USD', 'ETH-USD', 'XRP-USD', 'LTC-USD', 'BCH-USD']  
    overall_start = datetime.strptime("2020-01-01", "%Y-%m-%d")  
    overall_end   = datetime.strptime("2025-12-31", "%Y-%m-%d")  
      
    iterations = 100  # Number of backtest runs  
    with tqdm(total=iterations) as pbar:  
        i = 0  
        while i < iterations:  
            try:  
                random_crypto = random.choice(cryptos)  
                two_months_days = 60  # approx. 60 days for a 2-month window  
                max_start_date = overall_end - timedelta(days=two_months_days)  
                random_start = overall_start + (max_start_date - overall_start) * random.random()  
                random_end = random_start + timedelta(days=two_months_days)  
                start_str = random_start.strftime("%Y-%m-%d")  
                end_str = random_end.strftime("%Y-%m-%d")  
                  
                with suppress_stdout_stderr():  
                    data = yf.download(random_crypto, start=start_str, end=end_str, progress=False)  
              
                if hasattr(data.columns, 'droplevel'):  
                    data.columns = data.columns.droplevel(1).str.lower()  
                else:  
                    data.columns = data.columns.str.lower()  
                  
                if data.empty:  
                    i += 1  
                    pbar.update(1)  
                    continue  
                  
                cerebro = bt.Cerebro()  
                data_feed = bt.feeds.PandasData(dataname=data)  
                cerebro.adddata(data_feed)  
                  
                # Uncomment one of the strategies to test:  
                cerebro.addstrategy(RSI_Strategy)  
                # cerebro.addstrategy(EMA_Crossover_Strategy)  
                  
                cerebro.broker.setcash(100.)  
                cerebro.broker.setcommission(commission=0.001)  
                cerebro.run()  
              
                market_return = 100 * (data.close[-1] / data.close[0] - 1)  
                strategy_return = cerebro.broker.getvalue() - 100  
                  
                market_returns.append(market_return)  
                strategy_returns.append(strategy_return)  
                  
                i += 1  
                pbar.update(1)  
            except Exception as e:  
                i += 1  
                pbar.update(1)  
                continue  
    # Visualize the distribution of returns using density histograms.  
    import matplotlib.pyplot as plt  
    import numpy as np  
    plt.figure(figsize=(10, 6))  
    bins = 25  # Number of histogram bins  
    plt.hist(market_returns, bins=bins, density=True, alpha=0.5,   
             label='Market Returns', color='blue', edgecolor='black')  
    plt.hist(strategy_returns, bins=bins, density=True, alpha=0.5,   
             label='Strategy Returns', color='green', edgecolor='black')  
    mean_market = np.mean(market_returns)  
    mean_strategy = np.mean(strategy_returns)  
    plt.axvline(mean_market, color='blue', linestyle='dashed', linewidth=2,   
                label=f'Market Mean: {mean_market:.2f}')  
    plt.axvline(mean_strategy, color='green', linestyle='dashed', linewidth=2,   
                label=f'Strategy Mean: {mean_strategy:.2f}')  
    plt.title("Statistical Distribution of Market & Strategy Returns")  
    plt.xlabel("Return Value")  
    plt.ylabel("Density")  
    plt.legend()  
    plt.show()

As you can see in the results, mean returns and also the distribution of returns show no obvious advantage over market returns for the simple classic strategies we tested for demonstration of the idea. You can test your strategies like this and analyze the results to see if they prove to be statistically significant returns over the market.

Key Takeaways

  1. Randomized Data Selections:
    Varying both the asset choice and the timeframe for each backtest run helps simulate different market conditions and ensures your strategy’s performance isn’t just a product of a favorable dataset.
  2. Multiple Iterations & Aggregation:
    Running many iterations and aggregating results gives you a statistical distribution of returns. This reveals not only average performance but also the variance and potential risk.
  3. Visual Analysis:
    Overlapping density histograms — with indicators for mean returns — allow for a clear comparison between raw market performance and your strategy’s performance. This visual approach aids in decision-making and risk assessment.
  4. Practical Setup with Backtrader:
    Integrating tools like tqdm for progress tracking and custom context managers to suppress noisy output creates a clean, automated testing environment.

Conclusion

By embracing the randomness inherent in markets — randomized asset selection and time windows — you can perform a more comprehensive and robust evaluation of your strategy. This method gives you statistical insight into performance variability, paving the way for more confident decision-making and risk management. With Backtrader and the above framework, you’re well-equipped to “crack the code” on how your strategy might perform across a myriad of market conditions.