Skip to content

Backtesting¤

Minitrade uses Backtesting.py as the core library for backtesting and adds the capability to implement multi-asset strategies.

Strategy Basics¤

A strategy is defined by subclassing Strategy and implementing the init() and next() methods.

from minitrade.backtest import Strategy

class MyStrategy(Strategy):

    def init(self):
        pass

    def next(self):
        pass

The init() method is called once at the beginning of the backtest to initialize the strategy. The next() method is called for each bar in the data to generate trading signals.

Historical Data¤

The historical data is accessed through the self.data object. It's an instance of _Data, which is a wrapper around a DataFrame with a 2-level column index, where the first level is the ticker and the second level is the OHLCV data. The raw DataFrame can be accessed with the .df accessor.

For instance,

# self.data
<Data i=4 (2018-01-08) 
    ('AAPL', 'Open')=41.38, 
    ('AAPL', 'High')=41.68, 
    ('AAPL', 'Low')=41.28, 
    ('AAPL', 'Close')=41.38, 
    ('AAPL', 'Volume')=82271200.0>

# self.data.df
            AAPL                                
            Open   High    Low  Close     Volume
Date
2018-01-02  40.39  40.90  40.18  40.89  102223600
2018-01-03  40.95  41.43  40.82  40.88  118071600
2018-01-04  40.95  41.18  40.85  41.07   89738400
2018-01-05  41.17  41.63  41.08  41.54   94640000
2018-01-08  41.38  41.68  41.28  41.38   82271200

Indicators¤

Indicators can be created using the I() method. The method takes a DataFrame or Series and an optional name argument. The indicator is calculated once in the init() method and can be accessed in the next() method.

class MyStrategy(Strategy):

    def init(self):
        self.sma = self.I(self.data.Close.df.rolling(10).mean(), name='SMA')

    def next(self):
        if self.data.Close[-1] > self.sma[-1]:
            print('Buy signal')
        else:
            print('Sell signal')

Orders¤

The buy() and sell() methods are used to place orders. The position() method returns the current position, which can be used to close the position using the close() method.

class MyStrategy(Strategy):

    def init(self):
        self.sma = self.I(self.data.Close.df.rolling(10).mean(), name='SMA')

    def next(self):
        if self.data.Close[-1] > self.sma[-1]:
            if self.position().size == 0:
                self.buy()
        else:
            if self.position().size > 0:
                self.position().close()

Single Asset Strategy¤

For single asset strategies, those written for Backtesting.py can be easily adapted to work with Minitrade. The following example from Backtesting.py illustrates what changes are necessary:

from minitrade.backtest import Strategy                                 #1
from minitrade.backtest.core.lib import crossover                       #1
from minitrade.backtest.core.test import SMA                            #1

class SmaCross(Strategy):
    fast = 10
    slow = 20

    def init(self):
        price = self.data.Close.df                                      #2
        self.ma1 = self.I(SMA, price, self.fast, overlay=True)          #3
        self.ma2 = self.I(SMA, price, self.slow, overlay=True)          #3

    def next(self):
        if crossover(self.ma1, self.ma2):
            self.position().close()                                     #4
            self.buy()
        elif crossover(self.ma2, self.ma1):
            self.position().close()                                     #4
            self.sell()


bt = Backtest(GOOG, SmaCross, commission=.001)
stats = bt.run()
bt.plot()
  1. Change to import from minitrade modules. Generally backtesting becomes minitrade.backtest.core. Note that importing from minitrade.backtest.core.test is discouraged. SMA can be easily achieved using self.data.Close.df.rolling(n).mean() as shown in earlier example.
  2. To access the historical data as a Pandas Series or DataFrame, use self.data.Close.df instead of self.data.Close.
  3. Minitrade doesn't try to guess where to plot the indicators. So if you want to overlay the indicators on the main chart, set overlay=True explicitly.
  4. Strategy.position is no longer a property but a function. Any occurrence of self.position should be changed to self.position().

The plot generated by the above code will look like this:

plot of single-asset strategy

Beyond this simple example, there are other caveats you should be aware of. Check out compatibility for more details. Also some original utility functions and strategy classes only make sense for single asset strategy. Don't use those in multi-asset strategies.

Multi-Asset Strategy¤

Minitrade extends Backtesting.py to support backtesting of multi-asset strategies.

Data Input¤

A multi-asset strategy requires a DataFrame with a 2-level column index as its data input. For instance, suppose you have a strategy aiming to invest in AAPL and GOOG as a portfolio. In this case, the data input to Backtest() should follow this format:

# bt = Backtest(data, AaplGoogStrategy)
# print(data)

          AAPL                              GOOG 
          Open  High  Low   Close Volume    Open  High  Low   Close Volume
Date          
2018-01-02 40.39 40.90 40.18 40.89 102223600 52.42 53.35 52.26 53.25 24752000
2018-01-03 40.95 41.43 40.82 40.88 118071600 53.22 54.31 53.16 54.12 28604000
2018-01-04 40.95 41.18 40.85 41.07 89738400 54.40 54.68 54.20 54.32 20092000
2018-01-05 41.17 41.63 41.08 41.54 94640000 54.70 55.21 54.60 55.11 25582000
2018-01-08 41.38 41.68 41.28 41.38 82271200 55.11 55.56 55.08 55.35 20952000

As mentioned earlier, the historical data is accessed through the self.data object.

# When called from within Strategy.init()

# self.data
<Data i=4 (2018-01-08) ('AAPL', 'Open')=41.38, ('AAPL', 'High')=41.68, ('AAPL', 'Low')=41.28, ('AAPL', 'Close')=41.38, ('AAPL', 'Volume')=82271200.0, ('GOOG', 'Open')=55.11, ('GOOG', 'High')=55.56, ('GOOG', 'Low')=55.08, ('GOOG', 'Close')=55.35, ('GOOG', 'Volume')=20952000.0>

# self.data.df
          AAPL                              GOOG 
          Open  High  Low   Close Volume    Open  High  Low   Close Volume
Date          
2018-01-02 40.39 40.90 40.18 40.89 102223600 52.42 53.35 52.26 53.25 24752000
2018-01-03 40.95 41.43 40.82 40.88 118071600 53.22 54.31 53.16 54.12 28604000
2018-01-04 40.95 41.18 40.85 41.07 89738400 54.40 54.68 54.20 54.32 20092000
2018-01-05 41.17 41.63 41.08 41.54 94640000 54.70 55.21 54.60 55.11 25582000
2018-01-08 41.38 41.68 41.28 41.38 82271200 55.11 55.56 55.08 55.35 20952000

Pandas TA Integration¤

For streamlined indicator calculation, Minitrade has built-in integration with pandas_ta. Accessing pandas_ta is made easy through the .ta property of any DataFrame. Refer to the documentation for usage instructions. Moreover, .ta has been augmented to fully support 2-level DataFrames.

For example,

# print(self.data.df.ta.sma(3))

                 AAPL       GOOG
Date                                            
2018-01-02         NaN        NaN
2018-01-03         NaN        NaN
2018-01-04   40.946616  53.898000
2018-01-05   41.163408  54.518500
2018-01-08   41.331144  54.926167

As a shortcut, self.data.ta.sma(3) works the same on self.data.

Indicators¤

self.I() accepts both DataFrame/Series and functions as arguments to define an indicator. When a DataFrame/Series is provided, it's assumed to have the same index as self.data. For instance,

self.sma = self.I(self.data.df.ta.sma(3), name='SMA_3')

Within Strategy.next(), indicators are returned as type _Array, essentially numpy.ndarray, similar to Backtesting.py. The .df accessor returns either a DataFrame or Series of the corresponding value. It's the caller's responsibility to know which exact type should be returned. The .s accessor is also available but serves only as syntactic sugar to return a Series. If the actual data is a DataFrame, .s throws a ValueError.

Weight Allocation¤

A key addition to support multi-asset strategy is a Strategy.alloc attribute, which, combined with Strategy.rebalance(), allows specifying how portfolio value should be allocated among the different assets.

Strategy.alloc is an instance of Allocation. The Allocation class manages the allocation of values among different assets in a portfolio. It provides methods for creating and managing asset buckets, assigning weights to assets, and merging the weights into the parent allocation object, which is then used to rebalance the portfolio.

Example¤

from minitrade.backtest import Strategy

class MyStrategy(Strategy):

    lookback = 10

    def init(self):
        self.roc = self.I(self.data.ta.roc(self.lookback), name='ROC')     #1

    def next(self):
        self.alloc.assume_zero()                                #2
        roc = self.roc.df.iloc[-1]                              #3
        (self.alloc.bucket['equity']                            #4
            .append(roc.sort_values(ascending=False), roc > 0)  #5
            .trim(3)                                            #6
            .weight_explicitly(1/3)                             #7
            .apply())                                           #8
        self.rebalance(cash_reserve=0.01)                       #9

The above illustrates the general workflow of defining a multi-asset strategy:

  1. Calculate the indicator. It uses .ta accessor to calculate the rate of change of the close prices over a 10-day period, wraps the result in I() to create an indicator named ROC, and assigns it to self.roc.
  2. self.alloc.assume_zero() resets the weight allocation to zero at the beginning of each next() call.
  3. Get the latest ROC value from the self.roc indicator and assign it to roc.
  4. Create a new bucket named equity in the allocation object.
  5. Add stocks with positive ROC, ranking in descending order, to the equity bucket.
  6. Trim the equity bucket to contain a maximum of top 3 stocks.
  7. Allocate 1/3 of the portfolio value to each stock in the equity bucket.
  8. Apply the weight allocation to the portfolio.
  9. Rebalance the portfolio based on the current allocation.

Data Source¤

Minitrade provides built-in data sources to fetch historical quotes from Yahoo Finance and others. The data source can be instantiated by name:

from minitrade.datasource import QuoteSource

yahoo = QuoteSource.get_source('Yahoo')
data = yahoo.daily_bar('AAPL', start='2018-01-01', end='2019-01-01')

The daily_bar() method returns a DataFrame with the following format:

            AAPL                                
            Open   High    Low  Close     Volume
Date
2018-01-02  40.28  40.79  40.07  40.78  102223600
2018-01-03  40.84  41.32  40.71  40.77  118071600
2018-01-04  40.84  41.06  40.73  40.96   89738400
2018-01-05  41.06  41.51  40.96  41.43   94640000
2018-01-08  41.27  41.57  41.17  41.27   82271200
...                          ...    ...    ...    ...        ...

The data source can also fetch historical quotes for multiple stocks at once.

data = yahoo.daily_bar(['AAPL', 'GOOG'], start='2018-01-01', end='2019-01-01')

It returns a DataFrame with a 2-level column index as required for multi-asset strategies:

          AAPL                              GOOG 
          Open  High  Low   Close Volume    Open  High  Low   Close Volume
Date
2018-01-02 40.39 40.90 40.18 40.89 102223600 52.42 53.35 52.26 53.25 24752000
2018-01-03 40.95 41.43 40.82 40.88 118071600 53.22 54.31 53.16 54.12 28604000
2018-01-04 40.95 41.18 40.85 41.07 89738400 54.40 54.68 54.20 54.32 20092000
2018-01-05 41.17 41.63 41.08 41.54 94640000 54.70 55.21 54.60 55.11 25582000
2018-01-08 41.38 41.68 41.28 41.38 82271200 55.11 55.56 55.08 55.35 20952000
...                          ...    ...    ...    ...        ...

Revisit self.data¤

Much of writing a strategy involves manipulating the historical data. self.data is a key object that provides access to the historical data. It's an instance of _Data, which is a wrapper around the DataFrame that supports revealing data progressively to prevent look-ahead bias.

Multiple Assets¤

Suppose the following data is used to backtest a multi-asset strategy.

          AAPL                              GOOG 
          Open  High  Low   Close Volume    Open  High  Low   Close Volume
Date
2018-01-02 40.39 40.90 40.18 40.89 102223600 52.42 53.35 52.26 53.25 24752000
2018-01-03 40.95 41.43 40.82 40.88 118071600 53.22 54.31 53.16 54.12 28604000
2018-01-04 40.95 41.18 40.85 41.07 89738400 54.40 54.68 54.20 54.32 20092000
2018-01-05 41.17 41.63 41.08 41.54 94640000 54.70 55.21 54.60 55.11 25582000
2018-01-08 41.38 41.68 41.28 41.38 82271200 55.11 55.56 55.08 55.35 20952000

Let's see how self.data can be used within the strategy:

Strategy.init()¤

In the init() method, the full length of historical data is available. The following illustrates how to slice the data in different, sometimes equivalent, ways:

# self.data
<Data i=4 (2018-01-08) ('AAPL', 'Open')=41.38, ('AAPL', 'High')=41.68, ('AAPL', 'Low')=41.28, ('AAPL', 'Close')=41.38, ('AAPL', 'Volume')=82271200.0, ('GOOG', 'Open')=55.11, ('GOOG', 'High')=55.56, ('GOOG', 'Low')=55.08, ('GOOG', 'Close')=55.35, ('GOOG', 'Volume')=20952000.0>

# self.data.df
          AAPL                              GOOG 
          Open  High  Low   Close Volume    Open  High  Low   Close Volume
Date          
2018-01-02 40.39 40.90 40.18 40.89 102223600 52.42 53.35 52.26 53.25 24752000
2018-01-03 40.95 41.43 40.82 40.88 118071600 53.22 54.31 53.16 54.12 28604000
2018-01-04 40.95 41.18 40.85 41.07 89738400 54.40 54.68 54.20 54.32 20092000
2018-01-05 41.17 41.63 41.08 41.54 94640000 54.70 55.21 54.60 55.11 25582000
2018-01-08 41.38 41.68 41.28 41.38 82271200 55.11 55.56 55.08 55.35 20952000

# self.data.Close
[[40.89 53.25]
 [40.88 54.12]
 [41.07 54.32]
 [41.54 55.11]
 [41.38 55.35]]

# self.data.Close.df
            AAPL   GOOG
Date                                     
2018-01-02  40.89  53.25
2018-01-03  40.88  54.12
2018-01-04  41.07  54.32
2018-01-05  41.54  55.11
2018-01-08  41.38  55.35

# self.data.df.xs("Close", axis=1, level=1)
            AAPL   GOOG
Date                                     
2018-01-02  40.89  53.25
2018-01-03  40.88  54.12
2018-01-04  41.07  54.32
2018-01-05  41.54  55.11
2018-01-08  41.38  55.35

# self.data.Close[-1]
[41.38 55.35]

# self.data.Close.df.iloc[-1]
AAPL    41.38
GOOG    55.35
Name: 2018-01-08, dtype: float64

# self.data["AAPL"]
[[4.039000e+01 4.090000e+01 4.018000e+01 4.089000e+01 1.022236e+08]
 [4.095000e+01 4.143000e+01 4.082000e+01 4.088000e+01 1.180716e+08]
 [4.095000e+01 4.118000e+01 4.085000e+01 4.107000e+01 8.973840e+07]
 [4.117000e+01 4.163000e+01 4.108000e+01 4.154000e+01 9.464000e+07]
 [4.138000e+01 4.168000e+01 4.128000e+01 4.138000e+01 8.227120e+07]]

# self.data["AAPL"].df
            Open   High    Low  Close     Volume
Date                                                              
2018-01-02  40.39  40.90  40.18  40.89  102223600
2018-01-03  40.95  41.43  40.82  40.88  118071600
2018-01-04  40.95  41.18  40.85  41.07   89738400
2018-01-05  41.17  41.63  41.08  41.54   94640000
2018-01-08  41.38  41.68  41.28  41.38   82271200

# self.data.df["AAPL"]
            Open   High    Low  Close     Volume
Date                                                              
2018-01-02  40.39  40.90  40.18  40.89  102223600
2018-01-03  40.95  41.43  40.82  40.88  118071600
2018-01-04  40.95  41.18  40.85  41.07   89738400
2018-01-05  41.17  41.63  41.08  41.54   94640000
2018-01-08  41.38  41.68  41.28  41.38   82271200

# self.data["AAPL", "Close"]
[40.89 40.88 41.07 41.54 41.38]

# self.data["AAPL", "Close"].df
Date
2018-01-02    40.89
2018-01-03    40.88
2018-01-04    41.07
2018-01-05    41.54
2018-01-08    41.38
Name: (AAPL, Close), dtype: float64

# self.data.df[("AAPL","Close")]
Date
2018-01-02    40.89
2018-01-03    40.88
2018-01-04    41.07
2018-01-05    41.54
2018-01-08    41.38
Name: (AAPL, Close), dtype: float64

# self.data["AAPL", "Close"][-1]
41.38

# self.data["AAPL", "Close"].df[-1]
41.38

# self.data.df[("AAPL","Close")][-1]
41.38

Since Strategy.init() is only called once, performance is generally not an issue. It's recommended to use .df property that return DataFrame to simplify further processing.

Here are some other notable properties of self.data:

# self.data.tickers
['AAPL', 'GOOG']

# self.data.index
DatetimeIndex(['2018-01-02', '2018-01-03',
               '2018-01-04', '2018-01-05',
               '2018-01-08'],
              dtype='datetime64[ns]', name='Date', freq=None)

Strategy.next()¤

In Strategy.next(), both self.data and registered indicators are revealed progressively to prevent look-ahead bias.

Since Strategy.next() is called for every bar in a backtest and optimizing a strategy may take many backtest runs, data indexing performance can be a concern in Strategy.next(). Therefore, indicators are returned as Numpy array by default, for example:

# self.sma at time step 4
[[        nan         nan]
 [        nan         nan]
 [40.94666667 53.89666667]
 [41.16333333 54.51666667]]

To access the DataFrame version of indicators, use .df property:

# self.sma.df at time step 4
                                AAPL       GOOG
Date                                             
2018-01-02        NaN        NaN
2018-01-03        NaN        NaN
2018-01-04  40.946667  53.896667
2018-01-05  41.163333  54.516667

len(self.data) returns the current time step, and self.data.now returns the current simulation time. They can be used to take actions at fixed time interval or on specific days.

# rebalance every 10 days
if len(self.data) % 10 == 0:
    self.rebalance()

# rebalance every Friday
if self.data.now.weekday() == 4:
    self.rebalance()

# rebalance on the first trading day of each month
if self.data.index[-1].month != self.data.index[-2].month:
    self.rebalance()

Single Asset¤

Suppose the following data is used to backtest a single asset strategy.

          AAPL                              
          Open  High  Low   Close Volume
Date
2018-01-02 40.39 40.90 40.18 40.89 102223600
2018-01-03 40.95 41.43 40.82 40.88 118071600
2018-01-04 40.95 41.18 40.85 41.07 89738400
2018-01-05 41.17 41.63 41.08 41.54 94640000
2018-01-08 41.38 41.68 41.28 41.38 82271200

Since there is only one asset, specifying the asset name is not necessary. self.data can be accessed as follows:

# self.data.the_ticker
AAPL

# self.data
<Data i=4 (2018-01-08) ('AAPL', 'Open')=41.38, ('AAPL', 'High')=41.68, ('AAPL', 'Low')=41.28, ('AAPL', 'Close')=41.38, ('AAPL', 'Volume')=82271200.0>

# self.data.df
            Open   High    Low  Close     Volume
Date                                                              
2018-01-02  40.39  40.90  40.18  40.89  102223600
2018-01-03  40.95  41.43  40.82  40.88  118071600
2018-01-04  40.95  41.18  40.85  41.07   89738400
2018-01-05  41.17  41.63  41.08  41.54   94640000
2018-01-08  41.38  41.68  41.28  41.38   82271200

# self.data.Close
[40.89 40.88 41.07 41.54 41.38]

# self.data["Close"]
[40.89 40.88 41.07 41.54 41.38]

# self.data.Close.df
Date
2018-01-02    40.89
2018-01-03    40.88
2018-01-04    41.07
2018-01-05    41.54
2018-01-08    41.38
Name: AAPL, dtype: float64

# self.data["Close"].df
Date
2018-01-02    40.89
2018-01-03    40.88
2018-01-04    41.07
2018-01-05    41.54
2018-01-08    41.38
Name: AAPL, dtype: float64

# self.data.df["Close"]
Date
2018-01-02    40.89
2018-01-03    40.88
2018-01-04    41.07
2018-01-05    41.54
2018-01-08    41.38
Name: Close, dtype: float64

# self.data.Close[-1]
41.38

# self.data.Close.df.iloc[-1]
41.38

# self.data["Close"][-1]
41.38

# self.data["Close"].df[-1]
41.38

# self.data.df["Close"][-1]
41.38

Backtest¤

Once the data and strategy are defined, the backtest can be run as follows:

from minitrade.backtest import Backtest

bt = Backtest(data, MyStrategy)
result = bt.run()

The date range for backtesting is inferred from the data.

The run() method returns a pandas.Series with the backtest statistics:

# print(result)
Start                                                   2023-01-03 00:00:00
End                                                     2024-03-28 00:00:00
Duration                                                  450 days 00:00:00
Exposure Time [%]                                                 88.745981
Equity Final [$]                                                 11860.6813
Equity Peak [$]                                                11879.591159
Return [%]                                                        18.606813
Buy & Hold Return [%]                                             12.122283
Return (Ann.) [%]                                                 15.357352
Volatility (Ann.) [%]                                             15.370613
Sharpe Ratio                                                       0.999137
Sortino Ratio                                                      1.717817
Calmar Ratio                                                       0.945122
Max. Drawdown [%]                                                -16.249076
Avg. Drawdown [%]                                                 -3.229986
Max. Drawdown Duration                                    181 days 00:00:00
Avg. Drawdown Duration                                     35 days 00:00:00
# Trades                                                                111
Win Rate [%]                                                      64.864865
Best Trade [%]                                                    28.119439
Worst Trade [%]                                                   -6.810767
Avg. Trade [%]                                                     2.376234
Max. Trade Duration                                        66 days 00:00:00
Avg. Trade Duration                                        16 days 00:00:00
Profit Factor                                                       3.35965
Expectancy [%]                                                     2.577326
SQN                                                                1.246637
Kelly Criterion                                                    0.242071
_strategy                                                        MyStrategy
_equity_curve                               Equity          MMM         ...
_trades                        EntryBar  ExitBar Ticker  Size  EntryPric...
_orders                              Ticker  Side  Size
SignalTime      ...
_positions                {'MMM': 37, 'AXP': 17, 'AAPL': 0, 'BA': 0, 'CV...
_trade_start_bar                                                         10
dtype: object

The backtest result can be visually inspected using the plot() method:

bt.plot(plot_allocation=True)

plot of multi-asset strategy

Optimization¤

Minitrade provides a simple interface to optimize strategy parameters. The optimize() method takes a list of parameters to optimize, a constraint function, and a metric to maximize.

stats, heatmap = bt.optimize(
    lookback=range(10, 60, 10),
    constraint=None,
    maximize='Equity Final [$]',
    random_state=0,
    return_heatmap=True)

It returns the backtest statistics for the optimal parameter and the results for each parameter combination.

# print(heatmap)
lookback
10          11839.802638
20          10751.761075
30          11462.385435
40          10283.880003
50          10458.447683
Name: Equity Final [$], dtype: float64

Further Reading¤

The API Reference provides detailed information on the classes and methods available in Minitrade.