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!).
Table of Contents
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
Error processing API data.
8 Responses
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!
Hi.
Can you elaborate where and what you would change?
Sure! I moved the idx update line to the very top of the loop. I updated the code in the post.
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!
Fantastic post! This was a really great introduction for me to get started on some quant projects. I just wanted to confirm if a part of the code is correct.
In your fill orders functions, you wrote:
elif order.side == ‘sell’ and self.strategy.position_size >= order.size:
If I am not wrong, the second part would always be true because you made the sell size negative (size = -size). You should change the order.size part to negative to counteract this.
You’re right, it should be the absolute value of the order size! Thanks for letting me know!