Backtest an automated trading strategy in Python

What if you could determine in less than a second whether a trading strategy is successful? That would be great, wouldn't it?

Imagine having a tool that can evaluate the past performance of a strategy, providing valuable insights without risking a single penny of your capital. Today, we dive into the captivating world of backtesting. We'll explore how, with just a few lines of Python code, you can test the viability of your trading strategy, assess its strengths and weaknesses, and be better prepared to confidently enter the volatile trading arena. Ready to begin? Let's dive in!

The Trading Strategy to Backtest

Today, we'll be backtesting one of the most well-known trading strategies: the moving average crossover.

A moving average simply represents the average price over the last n periods.

This strategy involves buying when a short-term moving average (like 50 periods) crosses above a long-term moving average (such as 200 periods). Conversely, a sell signal is triggered when the short-term moving average crosses below the long-term moving average.

pff9_1.png

It's a time-tested method for identifying potential entry and exit points based on historical market trends. With the power of Python and the pandas library, in just a few lines of code, we'll determine if this strategy has been profitable in the past, offering valuable insights for our future investments.

Backtesting a Trading Strategy in Python

Loading Historical Data with Pandas

To simulate our trading strategy, we'll need historical price data for the asset we want to backtest. This is a fundamental element, as these valuable data allow us to analyze and evaluate our strategy's performance under real market conditions. We provide the hourly OHLCV (Open, High, Low, Close, Volume) data for Bitcoin in a CSV file available at this link: https://github.com/RobotTraders/Python_For_Finance/blob/main/BTC-USDT.csv. This file contains crucial information, such as the opening, highest, lowest, and closing prices, and the volume for each hour over the last few years.

After importing the Pandas library with import pandas as pd, we'll start by loading this file into a Pandas DataFrame using pd.read_csv().

df = pd.read_csv('BTC-USDT.csv'

Next, we'll convert the date column to datetime for easier handling of dates and times. The dates are currently in Unix format (the number of milliseconds since 1970).

df["date"] = pd.to_datetime(df["date"], unit="ms")
df = df.set_index(df["date"])
del df["date"]

With these lines, we've converted the 'date' column into a datetime object and then set this column as our DataFrame's index. We also removed the original 'date' column to keep our DataFrame tidy.

Calculating Our Technical Indicators

To execute our strategy, we need to calculate our technical indicators, which in this case are the short-term and long-term moving averages.

One moving average will be over 50 periods, and the other over 200 periods. We'll then use them to generate buy and sell signals.

Une moyenne mobile sera sur 50 périodes et l'autre sur 200 périodes. Nous allons par la suite les utiliser pour générer des signaux d'achat et de vente.

df['ma50'] = df['close'].rolling(50).mean()
df['ma200'] = df['close'].rolling(200).mean()

Here, we've used the rolling() method to create our moving averages. The 50-period moving average will react more quickly to price changes than the 200-period average.

Generating Our Buy and Sell Signals

Now that we have our indicators, we'll create columns for buy and sell signals based on the relationship between these two moving averages.

df['buy_signal'] = False
df['sell_signal'] = False
df.loc[(df['ma50'] > df['ma200']), 'buy_signal'] = True
df.loc[(df['ma50'] < df['ma200']), 'sell_signal'] = True

We initialized our signal columns with False values. Then, we used the loc[] method to fill these columns with True values when the conditions for buying or selling are met.

When the 50-period moving average is above the 200-period moving average, we have a buy signal; conversely, we have a sell signal.

Preparing the Bitcoin Backtest

We're now ready to execute the backtest. We'll initialize our balance and go through the DataFrame to execute buy and sell orders based on our signals.

Let's start by initializing three variables:

balance = 1000
position = None
asset = "BTC"
  • The balance variable represents the amount in dollars of our initial portfolio (here $1000).
  • The position variable represents the current “position”; if we make a purchase, we open the position, and when we sell, we close the position. We start without a position, so the variable is initialized to None.
  • The asset variable will simply be used for display purposes.

Executing the Backtest in Python

We've now reached a crucial step in our journey: executing the backtest using a loop that goes through our historical data and applies our trading strategy. Hang on, as we break down and dissect each line to help you grasp the subtlety and power of this code.

for index, row in df.iterrows():

We begin a loop that will go through our DataFrame, line by line, each line representing an hourly period in our historical Bitcoin data.

Each row contains the trading data for a specific hour, and index is the temporal index of that line.

    if position is None and row['buy_signal']:

We enter a condition where we check two things. First, if we don’t have an open position (i.e., if we haven't bought Bitcoin) and second, if the buy signal is active for the current period.

        open_price = row['close']
        open_usd_size = balance
        position = {
            'open_price': open_price,
            'usd_size': open_usd_size,
        }

If that's the case, we open a position. We note the opening price, which is the closing price of the current period. We invest our entire available balance and record these details in the position variable via a dictionary.

        print(f"{index} - Buy for {open_usd_size}$ of {asset} at {open_price}$")

Finally, we print a summary of the purchase, including the date and time, the size of the position, and the purchase price.

    elif position and row['sell_signal']:

Next, we check if we have an open position and if the sell signal is active.

        close_price = row['close']
        trade_result = (close_price - position['open_price']) / position['open_price']
        balance = balance + trade_result * position['usd_size']
        position = None

If the condition is true, we retrieve the closing price for the period in question. Then, we calculate the result of the trade by subtracting the opening price of our position from the closing price, divided by the opening price to get the return percentage. Our balance is then updated by adding the gain or loss from the trade. Finally, we close the position by resetting it to None.

print(f"{index} - Sell for {balance}$ of {asset} at {close_price}$")

For each sale, we print a summary of the trade, including the date and time, the new balance, and the price at which we sold the asset.

Once this code is executed, we can run our backtest and finally discover the results. Thanks to the print() statements in the code, we display each purchase and sale to explore the execution of the strategy.

resultat-strategie-84611.png

The Results of the Trading Strategy

Our backtest is now complete, and we can add a print() statement to display our final portfolio:

print(f"Final balance: {balance}$")

Final balance: 7847.2548834433455$

If we had applied this moving average crossover trading strategy from the end of 2017 to early July 2023, our portfolio would have soared to an impressive $ 7847. Yes, you read that correctly. We would have made a profit of $ 6847! That's a +684% gain!

However, before getting too excited, it's essential to temper these results. We did not include transaction fees in our simulation. As you know, in the real world, each buy and sell transaction comes with fees that can significantly impact returns.

Moreover, it's vital to put these gains into perspective with Bitcoin's overall performance during this period. A strategy is all the more impressive if it outperforms the underlying asset, offering an attractive risk-adjusted return.

Nonetheless, it's clear that this strategy would have been profitable in the past. However, and this is a warning worth repeating: the past doesn't predict the future. Markets evolve, and a strategy that was winning yesterday may not be tomorrow.

In the next episode, we'll dive deeper into analyzing the results of this strategy. We'll look at key metrics and techniques to objectively evaluate performance and discuss potential adjustments to improve profitability and reduce risk. Stay tuned, the best is yet to come!

Structuring Our Backtest with a Class and Functions

In our quest to make our code cleaner, more efficient, and reusable, the introduction of classes and functions becomes an essential step. Consider, for example, the BacktestCrossMA class above. This structure not only makes our code more readable but also organizes it modularly, making it easier to maintain and reuse.

class BacktestCrossMA:
    def __init__(self) -> None:
        self.df = pd.DataFrame()

    def load_data(self, path):
        self.df = pd.read_csv(path)
        self.df["date"] = pd.to_datetime(self.df["date"], unit="ms")
        self.df = self.df.set_index(self.df["date"])
        del self.df["date"]

    def populate_indicators(self):
        self.df['ma50'] = self.df['close'].rolling(50).mean()
        self.df['ma200'] = self.df['close'].rolling(200).mean()

    def populate_signals(self):
        self.df['buy_signal'] = False
        self.df['sell_signal'] = False
        self.df.loc[(self.df['ma50'] > self.df['ma200']), 'buy_signal'] = True
        self.df.loc[(self.df['ma50'] < self.df['ma200']), 'sell_signal'] = True

    def run_backtest(self):
        balance = 1000
        position = None
        asset = "BTC"

        for index, row in self.df.iterrows():

            if position is None and row['buy_signal']:
                open_price = row['close']
                position = {
                    'open_price': open_price,
                    'usd_size': balance,
                }
                print(f"{index} - Buy for {balance}$ of {asset} at {open_price}$")

            elif position and row['sell_signal']:
                close_price = row['close']
                trade_result = (close_price - position['open_price']) / position['open_price']
                balance = balance + trade_result * position['usd_size']
                position = None
                print(f"{index} - Sell for {balance}$ of {asset} at {close_price}$")
                
            
        
        print(f"Final balance: {balance}$")

bt = BacktestCrossMA()
bt.load_data('BTC-USDT.csv')
bt.populate_indicators()
bt.populate_signals()
bt.run_backtest()

The BacktestCrossMA class encapsulates all the steps of our backtesting, from data retrieval to backtest execution. Each method of the class is dedicated to a specific stage of the process. The load_data() method loads the data, populate_indicators() calculates the moving averages, and populate_signals() determines the buy and sell signals. Finally, run_backtest() executes the backtest based on these signals.

By encapsulating these steps in a class, we can easily reuse and adapt our code for other assets or strategies, making our work not only more efficient but also more organized and manageable. This demonstrates the beauty and efficacy of object-oriented programming, a valuable skill for any developer working in quantitative finance.

Practical Application

Having explored the moving average crossover strategy, it's time to put our knowledge to the test with a new approach. We propose a practical exercise where you backtest a trading strategy based on Bollinger Bands, another popular technical analysis tool among traders.

To implement this strategy, we buy when the closing price is above the upper Bollinger Band and sell when the price falls back below the middle band. If you're unfamiliar with Bollinger Bands, I encourage you to learn more about them here https://www.investopedia.com/terms/b/bollingerbands.asp.

To start, you'll need to become familiar with calculating and interpreting Bollinger Bands. By combining theory with practice, we have the opportunity to test and refine a new strategy, thereby expanding our trading arsenal and honing our Python programming skills for finance.

Useful Library for Technical Indicator Calculation

If you're interested in exploring other strategies based on different indicators, check out the Python TA library https://technical-analysis-library-in-python.readthedocs.io/en/latest/, which includes numerous technical indicators. Here's the documentation.

Once installed with pip install ta and imported into your code with import ta, you can, for example, calculate the RSI, another well-known technical trading indicator.

df["RSI"] = ta.momentum.RSIIndicator(close=df["close"], window=14).rsi()

A correction example can be found here: https://github.com/RobotTraders/Python_For_Finance/blob/main/exercise_correction_chapter_9.ipynb. Good luck, and see you in the next and last chapter where we'll discover how to best analyze the results of these backtests.