img

Build a Trading Bot with Python— 2. Backtesting Feature

img
valuezone 26 September 2022

Build a Trading Bot with Python— 2. Backtesting Feature

This is the second story of the “Build a Trading Bot” series. You need to know Backtrader to understand this story. If you don’t know what Backtrader is or if you want to find the other stories of this series, you should check the following story:

What we will get

At the end of this story, we will have a flexible trading bot with a backtesting feature.

Backtesting means trying a strategy on past data to see if it is profitable. While backtesting, you can also optimize your strategy to find the best parameters.

Here is what you will get at the end of this story:

Input:

results = bot.backtest(strategy, some_parameters)
for result in results:
print(f"Net profit: {profit}")

Output:

Net profit: 14075.772535935259
Net profit: 9407.347764489063
Net profit: 27047.968861593035
Net profit: 23669.66175297717
Net profit: 15670.56778873867

Getting started

As I said in the previous story, we’ll build a trading bot in Python. The first thing to do is to set up your project and your environment.

You will need Backtrader, Pandas, and Yfinance for now.

pip install backtrader2
pip install pandas
pip install yfinance

Where to start?

Let’s begin with creating a new class for our bot. We will also implement the main method we will need:

class TradingBot:    def backtest():
pass

OK, now, how to backtest? If you’ve followed the Backtrader series I made, you know how we can do this.

We need first to declare a Cerebro, then to add data, to add our strategy, our sizer, our analyzers, etc…

It gives us an idea about the parameters we need to pass to backtest :

def backtest(self, strategy, backtest_parameters, data_source, sizer=bt.sizers.FixedSize, strategy_parameters=None,
sizer_parameters=None, analyzers=None):

Some parameters may be confusing:

  • backtest_parameters: start date for the backtest, end date, initial cash, symbol, etc…
  • data_source: the class we will use to extract our backtest data (Open High Low Close DataFrame)
  • sizer: an object used to deal with our position size.

Now, we have everything we need to implement our backtest method:

def backtest(self, strategy, backtest_parameters, data_source, sizer=bt.sizers.FixedSize, strategy_parameters=None,
sizer_parameters=None, analyzers=None):
cerebro = bt.Cerebro()

data = data_source.get_data(backtest_parameters)
datafeed = bt.feeds.PandasData(dataname=data)
cerebro.adddata(datafeed)

initial_cash = backtest_parameters.get('initial_cash', 10000)
commission = backtest_parameters.get('commission', 0.001)
slippage = backtest_parameters.get('slippage', 0.001)

cerebro.broker.setcash(initial_cash)
cerebro.broker.setcommission(commission=commission)
cerebro.broker.set_slippage_perc(slippage)

cerebro.adddata(datafeed)

if not strategy_parameters:
strategy_parameters = {}
cerebro.optstrategy(strategy, **strategy_parameters)

if not sizer_parameters:
sizer_parameters = {}
cerebro.addsizer(sizer, **sizer_parameters)

if analyzers:
for analyzer in analyzers:
cerebro.addanalyzer(analyzer)

results = cerebro.run(maxcpus=1)
return results

A few words about this line:

datafeed = bt.feeds.PandasData(dataname=data)

Here, we simply create a DataFeed with our data (an OHLCV DataFrame).

Data sources

Currently, we can’t run our bot because we don’t have any data sources. To make the bot flexible, we’ll make an interface and create subclasses of this interface.

In the interface, we will have one public method: get_data . This method should return an OHLCV DataFrame.

We will use this method to wrap another method: _get_data . This method is private and abstract. We need to override it to define the behavior of a concrete data source.

In addition, we will use other methods to check if the parameters correspond to the concrete data source. For example, if our data source can work only with datetimes and you give it a string for the date, it won’t work. So our _get_start_date method will convert the string into datetime before passing it to _get_data .


from abc import ABC, abstractmethod
class DataSource(ABC):

def get_data(self, backtest_parameters):
start_date = backtest_parameters.get('start_date', dt.datetime(2019, 1, 1))
end_date = backtest_parameters.get('end_date', dt.datetime(2020, 1, 1))
timeframe = backtest_parameters.get('timeframe', Timeframes.d1)
symbol = backtest_parameters.get('symbol', 'BTC-USD')

print(f'Getting data for {symbol} from {start_date} to {end_date} with {timeframe.name} timeframe with {self.__class__.__name__} data source')
return self._get_data(self._get_start_date(start_date), self._get_end_date(end_date), self._get_timeframe(timeframe), self._get_symbol(symbol))

@abstractmethod
def _get_data(self, start_date, end_date, timeframe, symbol) -> pd.DataFrame:
pass

def _get_start_date(self, start_date):
return start_date

def _get_end_date(self, end_date):
return end_date

def _get_timeframe(self, timeframe):
return timeframe

def _get_symbol(self, symbol):
return symbol

Now, we will implement a concrete data source. But before doing this, we’ll implement an Enum to define timeframes.

from enum import Enum
import backtrader as bt


class Timeframes(Enum):
m1 = (bt.TimeFrame.Minutes, 1)
m5 = (bt.TimeFrame.Minutes, 5)
m15 = (bt.TimeFrame.Minutes, 15)
m30 = (bt.TimeFrame.Minutes, 30)
h1 = (bt.TimeFrame.Minutes, 60)
h4 = (bt.TimeFrame.Minutes, 240)
d1 = (bt.TimeFrame.Days, 1)
w1 = (bt.TimeFrame.Weeks, 1)
mo1 = (bt.TimeFrame.Months, 1)

You can implement any timeframe you want, and implement them the way you want. I’ve chosen the Backtrader format, which is (timeframe, resolution) .

Now, let’s build a data source. If there’s an API you like, you can implement your own data source and do what you want for this part. Just be sure you return an OHLCV DataFrame (I should have added a test to check if the DataFrame is correct in get_data). For me, I will implement the Yfinance API as a data source:

import yfinance as yf
class Yfinance(DataSource):

First, I have to make my timeframes compatible with Yfinance. So, I will use the _get_timeframe method:

def _get_timeframe(self, timeframe):
try:
timeframe = timeframe.name[-1] + timeframe.name[:-1]
if timeframe not in ['1m', '5m', '15m', '30m', '60m', '90m', '1h', '1d', '5d', '1wk', '1mo', '3mo']:
raise ValueError
return timeframe
except ValueError:
raise ValueError(f'Yfinance does not support {timeframe} timeframe')

Then, I have to ensure my symbol is supported by Yfinance. For example, if I want to extract “BTC-USDT” data from Yfinance, it won’t work, so I have to catch the error.

def _get_symbol(self, symbol):
try:
ticker = yf.Ticker(symbol)
info = ticker.info
if not info.get('regularMarketPrice', None):
raise ValueError
return symbol
except ValueError as e:
raise ValueError(f'Yfinance does not support {symbol} symbol')

Now, I can override the _get_data method:

def _get_data(self, start_date, end_date, timeframe, symbol):
data = yf.download(symbol, start=start_date, end=end_date, interval=timeframe)
return yf.download(symbol, start_date, end_date, interval=timeframe)

I’m nearly sure there won’t be problems because I checked for potential errors through the other methods, and I’m sure my parameters are in a format supported by Yfinance.

Run run run!

Okay, so now I have my TradingBot class, and I have my Yfinance data source I can use to download backtesting data, what is missing?

Nothing, I can just put everything in a script and run it:

import backtrader as bt

from trading_bot import TradingBot
from timeframes import Timeframes
from data_sources.yfinance import Yfinance


bot = TradingBot()
data_source = Yfinance()

backtest_parameters = {
'start_date': '2010-01-01',
'end_date': '2022-01-01',
'timeframe': Timeframes.d1,
'symbol': 'AAPL',
'initial_cash': 10000,
'commission': 0.001,
'slippage': 0.001
}

strategy = bt.strategies.MA_CrossOver
strategy_parameters = {
'fast': range(10, 15),
}

sizer = bt.sizers.PercentSizer
sizer_parameters = {
'percents': 99
}

analyzers = [
bt.analyzers.TradeAnalyzer
]

results = bot.backtest(strategy, backtest_parameters, data_source, strategy_parameters=strategy_parameters, sizer=sizer,
sizer_parameters=sizer_parameters, analyzers=analyzers)
for result in results:
print(f"Net profit: {result[0].analyzers.tradeanalyzer.get_analysis()['pnl']['net']['total']}")

When I run this code, it gives me this:

Net profit: 14075.772535935259
Net profit: 9407.347764489063
Net profit: 27047.968861593035
Net profit: 23669.66175297717
Net profit: 15670.56778873867

So the backtesting feature is working!

Obviously, you can change the parameters I used to run the code and it will (hopefully) still work.