Author:

UPDATE: YOU CAN NOW READ PART 2 OF THIS SERIES HERE

Building a backtesting framework can be a great learning experience for traders and investors who want to deepen their understanding of how financial markets work and how trading strategies are developed and tested.

Having said that, it is not an easy feat: many assumptions have to be made, and many considerations have to be taken into account. If not properly designed, the result will be unusable and full of errors and quirks.

I’ve found a few articles attempting to show how to create backtests, but they all cover the basics and not the inner workings of a framework itself. Thus, I wanted to have a go and give my take on how to implement a backtesting engine, hoping to contribute to the community with a piece of content that is currently not being addressed.

If you just want to know my favorite backtesting frameworks available in Python, refer to this article I wrote. Also, if you’re on the fence about whether or not to create your backtesting engine, here’s my take on that question (spoiler alert: I recommend building it!).

Creating a Backtesting Framework in Python

Basic Layout of the Backtesting Framework

Any serious attempt at creating a backtesting framework will require an Object Oriented approach. If you’ve never worked with classes before, do not worry. It is not only pretty straightforward, but I’ll also explain the code in great detail whenever necessary.

The structure of the framework could vary depending on personal preferences. In this case, we will create the following classes:

  • Engine: it is the main class that will be used to run our backtest.
  • Strategy: this base class will serve as the building block for implementing the logic of our trading strategies.
  • Order: When buying or selling, we first create an order object. If the order is filled, we create a trade object.
  • Trade: The corresponding trade object will be created whenever an order is filled.

You might wonder why we are creating both Order and Trade classes. The reason is simple: it is standard practice for backtesting engines to assume that an order is created on close and filled on the next open. Doing so is good practice and will avoid look-ahead bias!

Here’s the boilerplate code:

class Engine():
    """The engine is the main object that will be used to run our backtest.
    """
    def __init__(self):
        pass
    
class Strategy():
    """This base class will handle the execution logic of our trading strategies
    """
    def __init__(self):
        pass
    
class Trade():
    """Trade objects are created when an order is filled.
    """
    def __init__(self):
        pass

class Order():
    """When buying or selling, we first create an order object. If the order is filled, we create a trade object.
    """
    def __init__(self):
        pass

Implementing the Engine Class

Saying that our code is currently useless is an understatement, so let’s fix that! I’ll start by  expanding the Engine class by implementing the following features:

  • Add OHLC data to it
  • Append a strategy to run
  • Write a draft of the run() method, which is in charge of executing the backtest.

We will use the pandas and tqdm libraries, so make sure to install them. If you’ve never used the latter, it allows us to show a progress bar during iterations. It is not strictly required in our scenario, but it is definitely fancy!

import pandas as pd
from tqdm import tqdm

class Engine():
    def __init__(self, initial_cash=100_000):
        self.strategy = None
        self.cash = initial_cash
        self.data = None
        self.current_idx = None
        
    def add_data(self, data:pd.DataFrame):
        # Add OHLC data to the engine
        self.data = data
        
    def add_strategy(self, strategy):
        # Add a strategy to the engine
        self.strategy = strategy
    
    def run(self):
        # We need to preprocess a few things before running the backtest
        self.strategy.data = self.data
        
        for idx in tqdm(self.data.index):
            self.current_idx = idx
            self.strategy.current_idx = self.current_idx
            # fill orders from previus period
            self._fill_orders()
            
            # Run the strategy on the current bar
            self.strategy.on_bar()
            print(idx)
            
    def _fill_orders(self):
        # Fill orders from the previous period
        pass

Although incomplete, we now have something we can execute, so let’s go ahead and do that!

import yfinance as yf

data = yf.Ticker('AAPL').history(start='2020-01-01', end='2022-12-31', interval='1d')
e = Engine()
e.add_data(data)
e.add_strategy(Strategy())
e.run()

Since we did not implement the on_bar() method in the Strategy class yet, you should comment out that line to avoid an error.

We are using two years of daily OHLCV data fetched from yahoo finance for testing. We are adding said data to the engine in addition to a strategy without any logic. The result looks as follows:

Implementing the Strategy Class

Our current bottleneck is now in the Strategy class, so let us go ahead and switch to that for a while. I’ll introduce the methods for buying and selling. For simplicity, I’ll only introduce market orders.

class Strategy():
    def __init__(self):
        self.current_idx = None
        self.data = None
        self.orders = []
        self.trades = []
    
    def buy(self,ticker,size=1):
        self.orders.append(
            Order(
                ticker = ticker,
                side = 'buy',
                size = size,
                idx = self.current_idx
            ))

    def sell(self,ticker,size=1):
        self.orders.append(
            Order(
                ticker = ticker,
                side = 'sell',
                size = -size,
                idx = self.current_idx
            ))
        
    @property
    def position_size(self):
        return sum([t.size for t in self.trades])
        
    def on_bar(self):
        """This method will be overriden by our strategies.
        """
        pass

Implementing the Order and Trade Classes

Based on the buy and sell methods, we must expand our Order and Trade classes to include the parameters we need.

class Order():
    def __init__(self, ticker, size, side, idx):
        self.ticker = ticker
        self.side = side
        self.size = size
        self.type = 'market'
        self.idx = idx
        
class Trade():
    def __init__(self, ticker,side,size,price,type,idx):
        self.ticker = ticker
        self.side = side
        self.price = price
        self.size = size
        self.type = type
        self.idx = idx

Refining the Engine Class

We’ll now return to our Engine class and implement the _fill_orders() method.

Whenever there are orders to be filled, we will also check for the following conditions:

  • If we’re buying, our cash balance has to be large enough to cover the order.
  • If we are selling, we must have enough shares to cover the order.

In other words, our backtesting engine will be restricted to long-only strategies. I introduced this restriction to show how to incorporate such a feature, but it is by no means a requirement. If you want to test long-short strategies, you can safely comment out these lines of code. Even better, you could add an “allow_short_trading” boolean parameter to the engine class and let the end-user decide.

def _fill_orders(self):
    """this method fills buy and sell orders, creating new trade objects and adjusting the strategy's cash balance.
    Conditions for filling an order:
    - If we're buying, our cash balance has to be large enough to cover the order.
    - If we are selling, we have to have enough shares to cover the order.
    
    """
    for order in self.strategy.orders:
        can_fill = False
        if order.side == 'buy' and self.cash >= self.data.loc[self.current_idx]['Open'] * order.size:
                can_fill = True 
        elif order.side == 'sell' and self.strategy.position_size >= order.size:
                can_fill = True
        if can_fill:
            t = Trade(
                ticker = order.ticker,
                side = order.side,
                price= self.data.loc[self.current_idx]['Open'],
                size = order.size,
                type = order.type,
                idx = self.current_idx)

            self.strategy.trades.append(t)
            self.cash -= t.price * t.size
    self.strategy.orders = []

We can now go ahead and test the framework in its current state of development. I created a simple strategy that inherits from the base class and switches between buying and selling the asset. I’m testing the output with one month of daily data to make the output understandable.

class BuyAndSellSwitch(Strategy):
    def on_bar(self):
        if self.position_size == 0:
            self.buy('AAPL', 1)
            print(self.current_idx,"buy")
        else:
            self.sell('AAPL', 1)
            print(self.current_idx,"sell")

data = yf.Ticker('AAPL').history(start='2022-12-01', end='2022-12-31', interval='1d')
e = Engine()
e.add_data(data)
e.add_strategy(BuyAndSellSwitch())
e.run()            

We can check the list of trades that were executed:

e.strategy.trades

Everything seems to be working properly, but the list of trades does not allow us to check whether the trades are correct. We can solve this by adding the __repr__ dunder (“double-under“) method to the Trade class!

# Trade class string representation dunder method
def __repr__(self):
    return f'<Trade: {self.idx} {self.ticker} {self.size}@{self.price}>'

If we re-run the backtest and print the trades, we can now understand what happened!

Calculating the Total Return

This article is longer than I initially planned, and I think it’s best to round it off here. Having said that, a backtesting engine is completely useless unless it returns some performance metrics.

So, before wrapping up, let’s calculate the strategy’s total return!

def _get_stats(self):
    metrics = {}
    total_return =100 * ((self.data.loc[self.current_idx]['Close'] * self.strategy.position_size + self.cash) / self.initial_cash -1)
    metrics['total_return'] = total_return
    return metrics

The total return is none other than the available cash at the end of the backtest, plus the value of our current position divided by the initial cash. During the initialization of the Engine class, we should also store the initial cash as a variable to access it later.

This method should be added to the Engine class, and we need to invoke it at the end of the run() method.

Putting everything together

If you’ve followed along, you should now have a working backtesting engine. The complete code is as follows:

import pandas as pd
from tqdm import tqdm


class Order():
    def __init__(self, ticker, size, side, idx):
        self.ticker = ticker
        self.side = side
        self.size = size
        self.type = 'market'
        self.idx = idx
        
class Trade():
    def __init__(self, ticker,side,size,price,type,idx):
        self.ticker = ticker
        self.side = side
        self.price = price
        self.size = size
        self.type = type
        self.idx = idx
    def __repr__(self):
        return f'<Trade: {self.idx} {self.ticker} {self.size}@{self.price}>'

class Strategy():
    def __init__(self):
        self.current_idx = None
        self.data = None
        self.orders = []
        self.trades = []
    
    def buy(self,ticker,size=1):
        self.orders.append(
            Order(
                ticker = ticker,
                side = 'buy',
                size = size,
                idx = self.current_idx
            ))

    def sell(self,ticker,size=1):
        self.orders.append(
            Order(
                ticker = ticker,
                side = 'sell',
                size = -size,
                idx = self.current_idx
            ))
        
    @property
    def position_size(self):
        return sum([t.size for t in self.trades])
        
    def on_bar(self):
        """This method will be overriden by our strategies.
        """
        pass

class Engine():
    def __init__(self, initial_cash=100_000):
        self.strategy = None
        self.cash = initial_cash
        self.initial_cash = initial_cash
        self.data = None
        self.current_idx = None
        
    def add_data(self, data:pd.DataFrame):
        # Add OHLC data to the engine
        self.data = data
        
    def add_strategy(self, strategy):
        # Add a strategy to the engine
        self.strategy = strategy

    
    def run(self):
        # We need to preprocess a few things before running the backtest
        self.strategy.data = self.data
        
        for idx in tqdm(self.data.index):
            self.current_idx = idx
            self.strategy.current_idx = self.current_idx
            # fill orders from previus period
            self._fill_orders()
            
            # Run the strategy on the current bar
            self.strategy.on_bar()
        return self._get_stats()
                
    def _fill_orders(self):
        """this method fills buy and sell orders, creating new trade objects and adjusting the strategy's cash balance.
        Conditions for filling an order:
        - If we're buying, our cash balance has to be large enough to cover the order.
        - If we are selling, we have to have enough shares to cover the order.
        """
        for order in self.strategy.orders:
            can_fill = False
            if order.side == 'buy' and self.cash >= self.data.loc[self.current_idx]['Open'] * order.size:
                    can_fill = True 
            elif order.side == 'sell' and self.strategy.position_size >= order.size:
                    can_fill = True
            if can_fill:
                t = Trade(
                    ticker = order.ticker,
                    side = order.side,
                    price= self.data.loc[self.current_idx]['Open'],
                    size = order.size,
                    type = order.type,
                    idx = self.current_idx)

                self.strategy.trades.append(t)
                self.cash -= t.price * t.size
        self.strategy.orders = []
    
    def _get_stats(self):
        metrics = {}
        total_return = 100 *((self.data.loc[self.current_idx]['Close'] * self.strategy.position_size + self.cash) / self.initial_cash -1)
        metrics['total_return'] = total_return
        return metrics

Sanity check: comparing results with Backtesting.py

I promised to end this article multiple times before, but we should replicate this strategy on another backtesting framework as a sanity check! Let’s use the backtesting.py library, a very popular Python backtesting library.

Strategy in our custom framework:

import yfinance as yf
class BuyAndSellSwitch(Strategy):
    def on_bar(self):
        if self.position_size == 0:
            self.buy('AAPL', 1)
        else:
            self.sell('AAPL', 1)

data = yf.Ticker('AAPL').history(start='2022-12-01', end='2022-12-31', interval='1d')
e = Engine()
e.add_data(data)
e.add_strategy(BuyAndSellSwitch())
e.run()      

Strategy replicated in Backtesting.py

from backtesting import Backtest, Strategy

class BuyAndSellSwitch(Strategy):
    def init(self):
        pass
    def next(self):
        if self.position.size == 0:
            self.buy(size=1)
        else:
            self.sell(size=1)

bt = Backtest(data, BuyAndSellSwitch, cash=10000)
output = bt.run()
output

If you execute both backtests, you’ll notice a slight (very little but non-zero) difference in the total returns. This is not a bug in either framework but a difference in how we implemented our engine:

  • For some reason, Backtesting.py skips the first period, whereas our Engine does not. Thus, backtesting.py starts trading later.
  • Backtesting.py closes all remaining positions at the end of the period, whereas we do not do that.

Taking these differences into account, our results match!

Final remarks

As you can see, creating a backtesting framework can be a very rewarding and interesting project. As in any simulation, we could further refine its capabilities, but I’ll leave that for an upcoming article.

In the upcoming article, I’ll probably implement the following features:

  • Add commissions
  • Add output metrics
  • Add plotting features
  • Limit Orders

If you have any questions or would like for me to cover something else, don’t hesitate to leave a comment!

UPDATE: YOU CAN NOW READ PART 2 OF THIS SERIES HERE

Categories:

Tags:

[convertkit form=4793161]

4 Responses

  1. Please correct me if I am wrong. You need to update the current index before you fill orders, otherwise your order will be filled using the open price from yesterday (i.e., the same day where you have used the close price to generate the trading signal which is hindsight bias).

    • Hi Jason! Sorry for the late response! It looks like you’re right. I got lines mixed while copy pasting from vscode to WordPress! I really appreciate you finding the error, thank you very much!

  2. why do my returns get smaller with exactly the same strategy and settings when I increase the initial cash to 500k?

    • Adam, without looking at your code I cannot provide an accurate answer. Having said that, I think your buying a fixed number of shares. The higher your initial cash, the smaller the investment in stocks relative to the total. Hence, your returns (or losses) ares smaller when expressed as a percentage of total assets.

      Let me know if this solved your issue or if you need further assistance!

Leave a Reply

Your email address will not be published. Required fields are marked *