avatarEsteban Thilliez

Summary

The provided content outlines a comprehensive guide on building a trading bot with Python, emphasizing the importance of backtesting to validate and optimize trading strategies.

Abstract

The article is the second installment in a series dedicated to constructing a trading bot using Python. It assumes prior knowledge of Backtrader, a Python framework for backtesting and trading. The author provides a GitHub repository with code for the bot, which includes a backtesting feature that allows traders to test strategies against historical data and optimize parameters for profitability. The guide covers setting up the project with necessary packages like Backtrader, Pandas, and Yfinance, and explains how to implement a backtesting method within the bot's class structure. It also details the creation of data sources using an abstract class and a concrete implementation using Yfinance for fetching historical trading data. The article concludes with a demonstration of running the bot with a sample strategy and parameters, and teases future stories that will delve into complex strategies and live trading capabilities.

Opinions

  • The author believes that backtesting is crucial for evaluating trading strategies before live trading.
  • Flexibility in the trading bot's design is highlighted as important, with the use of abstract classes for data sources allowing for easy integration of different APIs.
  • The author suggests that by following the provided instructions and code, users can create a robust trading bot tailored to their specific needs.
  • The article implies that Python is a versatile language for trading applications, particularly because of its extensive libraries and frameworks.
  • The author encourages user engagement by inviting readers to follow the series, subscribe via email, and support the author's work on Medium.

Build a Trading Bot with Python— 2. Backtesting Feature

Because it’s more important than live trading

Photo by Isaac Smith on Unsplash

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:

There’s a GitHub repo I’ve made for this series. If you want to use it to follow the code, you can find it here: Trading Bot Series.

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.

What’s next?

In the next story, we will try to build complex strategies, and later, we will see how to implement a live trading feature to make our bot run automatically and follows the rules defined in our strategies.

To find the other stories of this series and more about mixing trading and Python, check this: Improve your Trading with Python

To explore more of my Python stories, click here!

If you liked the story, don’t forget to clap and maybe follow me if you want to explore more of my content :)

You can also subscribe to me via email to be notified every time I publish a new story, just click here!

If you’re not subscribed to Medium yet and wish to support me or get access to all my stories, you can use my link:

A Message from InsiderFinance

Thanks for being a part of our community! Before you go:

Trading
Python
Finance
Programming
Algorithmic Trading
Recommended from ReadMedium