Build a Trading Bot with Python— 2. Backtesting Feature
Because it’s more important than live trading
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.56778873867Getting 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 yfinanceWhere 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():
passOK, 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 resultsA 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, abstractmethodclass 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 symbolNow, 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.56778873867So 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:
- 👏 Clap for the story and follow the author 👉
- 📰 View more content in the InsiderFinance Wire
- 📚 Take our FREE Masterclass
- 📈 Discover Powerful Trading Tools




