Logo image
  • Home
  • Get Started
  • Tutorials
  • Documentation
  • About
v0.1.1
  • GitHub
  • Home
  • Data Pipelines
  • How To Backtest

How to backtest¶

This tutorial will show you an overview of how to use the framework to do backtest. It assumes that you are already familiar with the data module. The primary module that will be used in this tutorial is the engine module.

The normal structure of a backtest project¶

Usually, a backtest project should have these sections:

  • The data pipeline
  • The strategy definition
  • The backtest configuration
  • The analysis of the results

Each section could be in the same file for a small project, in different files for a bigger project and maybe also in different directories for even larger projects.

In the get_started section of the official documentation, there is a an example. We will walk through the example to see where each parts are found.

from backtest import Strategy, Backtest
from backtest.indicators import IndicatorSet, TA
from backtest.data import FetchCharts, ToTSData, Cache, PadNan
import backtest.engine.functional as F
from datetime import datetime

# --------------------------------------------------------------------------------------------------------------
# Section A
class MyStrategy(Strategy):
    def run(self, data, timestep):
        for ticker in data.main.tickers:
            chart = data.main[ticker].chart
            if len(chart) > 2 and F.crossover(chart["MACD"], chart["MACD_SIGNAL"]) and chart["MACD"].iloc[-1] < 0:
                if ticker not in self.broker.portfolio.long:
                    self.broker.buy_long(ticker, 500)
            if ticker in self.broker.portfolio.long and F.crossunder(chart["MACD"], chart["MACD_SIGNAL"]):
                self.broker.sell_long(ticker, 500)
# End of section A
# --------------------------------------------------------------------------------------------------------------


# --------------------------------------------------------------------------------------------------------------
# Section B
TICKERS = ["META", "AMZN", "AAPL", "NVDA", "GOOGL", "MSFT", "TSLA"]
data_pipeline = FetchCharts(TICKERS, auto_adjust=False) | PadNan() | ToTSData() | Cache()
# End of section B
# --------------------------------------------------------------------------------------------------------------

# --------------------------------------------------------------------------------------------------------------
# Section C
bt = Backtest(data_pipeline.get(datetime(2010, 1, 1), datetime(2020, 1, 1)),
              strategy=MyStrategy(),
              indicators=IndicatorSet(TA.MACD()))
results = bt.run()
# End of section C
# --------------------------------------------------------------------------------------------------------------

# --------------------------------------------------------------------------------------------------------------
# Section D
print(results)
# End of section D
# --------------------------------------------------------------------------------------------------------------

In the example the section:

  • A) Defines the strategy
  • B) Defines the data pipeline
  • C) Configures and runs the backtest
  • D) Analyse the results

As you can see, all of these sections are fairly simple in that example, but can become extensivly complex if needed. The syntax is really flexible.

Strategy¶

In this section, you will see how to create a strategy that fits your need. You will see how to interact with the bank account, the broker and the portfolio.

1. Derive the class¶

The first step is to derive a class from the Strategy class. The only required method to implement is the run method. This is where the logic of the strategy is implemented. It can be simple if-else clauses, it can be some decision trees or complex machine learning algorithms. You can also override the __init__ method to add some dynamic attributes to your class. You can add any method you want and attributes you want with two exceptions: init and __call__. You should not override those methods. Notice that it is written init not __init__.

When a strategy is release using the serve module its state needs to be saved to disk. To do so, there is a default save and load method implemented that store the state as a pickle file. If you want to store the state in another format, you can override those methods.

2. Data Interaction¶

The run method is called at every step and passes the current data to the strategy via the data parameter. This parameter contains a RecordsBucket object. This object contains few attributes defined below. This object contains all the records(charts) of all assets across all time resolutions (if multiple were used). To access a given time resolution, you can use the [] operator with the desired timedelta object or its index in the available_time_res attribute. This will return a Records object. The main attribute is an alias to access the Records object associated with the main time resolution.

The Records is sort of a set of individual Record for a single time resolution. Each record contains the price chart of the asset and all the added features, but also some other information such as the rate of dividends (quarterly, annually, etc). The data is kind of structured as a tree:

RecordsBucket
├ Records
┊ ┕ Record
┊ ┊  ┕ pd.DataFrame (The chart)

3. Interactions with the Account¶

It can be useful to know how much available cash remains in the account to calculate how much money to invest in a trade. To do so, you can interact with the account that is referenced as the account attribute of the Strategy. It is directly connected to the backtest engine. It is strongly recommonded to not modify the state of the account for example depositing money or adding collateral, even though nothing blocks you from modifying doing because it could cause bugs and give wrong backtest results. What you can do with the account is access the available_cash property, the collateral property or the transactions property, but make sure you do not modify it.

4. Interactions with the portfolio¶

It can be useful to know what positions are currently open to perform actions. To do so, yoi can access the porfolio attribute of the Strategy. Again, you must not modify its state. You can access the long or the short attributes to access the open positions. Again, make sure to not modify anything.

5. Interaction with the broker¶

Your strategy will interact with the broker to open/close positions. You can use these methods:

  • buy_long: to open a long position
  • sell_long: to close a long position
  • sell_short: to open a short position
  • buy_short: to close a short position.

You must not change the state of the broker other than from these methods, again to avoid bugs and false results. If you want to know what are the pending transactions, you can access the the pending_orders property of the broker.

Example¶

In the following example, we will implement a Strategy that buys a security when the daily 7 day simple moving average start increasing and the candle is over the weekly 50 days simple moving average. It sells if the 7 day moving average start decreasing. It will invest one third of the available cash in any trade.

Features used in the example:

  • Data interactions with multiple time resolutions
  • Interaction with the account to know how much cash is available
  • Interaction with the portfolio to know if a given position is open
  • Interaction with the broker to trade.
class MyStrategy(Strategy):
    def run(self, data, timestep):
        for ticker in data.main.tickers:
            main_chart = data.main[ticker].chart
            w_chart = data[timedelta(weeks=1)][ticker].chart
            if len(w_chart) > 2 and (w_chart[["Open", "Close"]].iloc[-1] > w_chart['SMA(50)'].iloc[-1]).all():
                if F.ascending(main_chart["SMA(14)"]) and ticker not in self.broker.portfolio.long:
                    price = main_chart["Close"].iloc[-1].item()
                    amount = (self.account.available_cash / 3) // price   # Invest always 1 third of the available cash
                    if amount > 0:
                        self.broker.buy_long(ticker, amount)
            if ticker in self.broker.portfolio.long and F.descending(main_chart["SMA(14)"]):
                n_stocks = self.portfolio.long[ticker].amount
                self.broker.sell_long(ticker, n_stocks)

Backtest¶

In this section, you will learn how to configure a backtest object to fit your specific needs. We will use the above strategy and test it on a 10y backtest from 2010 to 2020.

There are multiple parameters that can be set to customize the way the backtest will work. For example, we can Add technical indicators (or more generally time-series generated from the data) - indicators parameter - that are computed on the fly on the window shown to the strategy. Calculating the indicators that way takes more time (more compute intensive), but the results should be closer to reality.

Another parameter that can be set is the TimeResExtender. This is an object that will extend the time resolution to other. For example, if the only time resolution fetch by the data pipeline is 1h, we could setup a TimeResExtender object to make new available time resolution such as daily or weekly. These will be available like any other time resolution fetch from a data pipeline from the RecordsBucket object.

We can also tune the window size: the number of days in the past the strategy can see.

Also, you can change the initial cash by providing a number to the initial_cash parameter.

from backtest import Backtest
from backtest.indicators import IndicatorSet, TA
from backtest.data import FetchCharts, ToTSData, Cache, PadNan
from backtest.engine import BasicExtender, TimePeriod
from datetime import datetime
# The magnificent 7 tickers
TICKERS = ["META", "AMZN", "AAPL", "NVDA", "GOOGL", "MSFT", "TSLA"]
data_pipeline = FetchCharts(TICKERS, auto_adjust=False) | PadNan() | ToTSData() | Cache()
bt = Backtest(data_pipeline.get(datetime(2010, 1, 1), datetime(2020, 1, 1)),
              window=250,
              initial_cash=10_000,
              strategy=MyStrategy(),
              indicators=IndicatorSet(TA.SMA(period=14), TA.SMA(period=50)),
              time_res_extender=BasicExtender(TimePeriod.ONE_WEEK))
results = bt.run()
print(results)
print("Saving results...")
results.save("tmp_results.bcktst")
Backtesting...:   0%|                                                                                                                                                                     | 0/2265 [00:00<?, ?it/s]/Users/alavertu/PycharmProjects/backtestPro/venv/lib/python3.12/site-packages/backtest/engine/backtest.py:700: UnexpectedBehaviorRisk: This method is not guaranteed to work for your setup.  You should override it, or make sure it works for your setup if you have series that have a higher resolution than the main resolution.
  warnings.warn("This method is not guaranteed to work for your setup.  You should override it, or make sure it "
Backtesting...: 100%|██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 2265/2265 [05:34<00:00,  6.76it/s]
Backtest results for MyStrategy from 2010-12-30 00:00:00 to 2019-12-31 00:00:00:
	Duration:                    3288 days 00:00:00
	Exposure time [days]:        2999.0
	Initial cash [$]:            10000
	External cash variation [$]: 0.0
	Equity final [$]:            71075.4714374238
	Equity peak [$]:             72401.37303540048
	Returns [%]:                 610.7547143742379
	Index Returns [%]:           None
	Annual returns [%]:          24.322341586376936
	Sharp ratio:                 1.0719929733853757
	Sortino ratio:               2.2044702077404934
	Max drawdown [%]:            20.40702361196548
	Avg drawdown [%]:            13.041929797595417
	Calmar ratio:                None
	Num trades:                  1469
	Num exits:                   729
	Win rate [%]:                39.91769547325103
	Best trade [%]:              133.37151584574266
	Worst trade [%]:             -13.683227784615005
	Avg trade [%]:               1.1232226274537158
	Max trade duration [days]:   138.0
	Avg trade duration [days]:   15.762345679012347
	Min trade duration [days]:   0.0
	Profit factor:               1.5826997756958008
	SQN:                         3.2848366112654817

Saving results...

Now, let's say you want to add money into your account monthly, or weekly, you can with a CashController! CashController objects manage money flow in and out of the account outside of the trading mecanism. The object is called at every day, every week, every month and every year. This means that you can derive the base class and make your own CashController.

However, if you just want to add or remove a fix amount of cash weekly, for example, you can use the SimpleCashController class and just pass the amount you want to add or remove weekly.

from backtest.engine import SimpleCashController
controller = SimpleCashController(every_week=100)

If you want to design more complex mecanism, you can derive the class and override the corresponding method: every_day, every_week, every_month and/or every_year. From any of these methods, you can access the bank account, the broker and the strategy object from 'self'. This way, the CashController could add money conditionally to the available cash into the account.

from backtest.engine import CashControllerBase

class CustomCashController(CashControllerBase):
    def every_week(self, timestamp: datetime) -> Tuple[float, Optional[str]]:
        if self.account.available_cash < 1000:
            return 10_000, "Fund deposit"

To use it, pass the CashController object to the cash_controller parameter of the backtest object.

To ensure that results are reproductable, a lot of information (metadata) is saved with the results of the backtest. Those informations can be the system information, the strategy name and version ,the author of the code, etc. Most of the information is acquired automatically (need to be in a git repository), but can be overriden. For example, the name of the startegy is, by default, the name of the class. However, if for some reason, you want to use another name, you can pass a string to the strategy_name parameter of the Metadata object. You can also pass a lot of different informations to ensure that the results are reproductible. Another example could be to provide a description that gives a detailed explanation on how to run the backtest in the same conditions (which tickers, timeframe, etc). The metadata object is passed to the Backtest object during its initialization, and is used by the same object during the saving of the results.

from backtest.engine import Metadata
metadata = Metadata(
    strategy_name = "MyStrat",
    description = "# This is my description",
    version = '0.1.0',
    author = 'myself')

Author: Anthony Lavertu (2024)

Generated by pdoc 0.10.0.