Backtesting a Strategy

We’re all set to test a basic trading strategy using PyBroker! To get started, we’ll import the necessary classes listed below:

[1]:
import pybroker
from pybroker import Strategy, StrategyConfig, YFinance

pybroker.enable_data_source_cache('my_strategy')
[1]:
<diskcache.core.Cache at 0x7fda987c2850>

For our backtest, we’ll be using Yahoo Finance as our DataSource. We’ll also be using data source caching to ensure that we only download the necessary data once when we run our backtests.

The next step is to set up a new instance of the Strategy class which will be used to perform a backtest on our trading strategy. Here’s how you can do it:

First, you can create a StrategyConfig object to configure the Strategy. In this case, we’re setting the initial cash to 500,000:

[2]:
config = StrategyConfig(initial_cash=500_000)

Then, you can create a new instance of the Strategy class by passing in the following arguments:

  • A data source: In this case, we’re using Yahoo Finance as the data source.

  • A start date: This is the starting date for the backtest.

  • An end date: This is the end date for the backtest.

  • The configuration object created earlier.

[3]:
strategy = Strategy(YFinance(), '3/1/2017', '3/1/2022', config)

The Strategy instance is now ready to download data from Yahoo Finance for the period between March 1, 2017, and March 1, 2022, before running the backtest using the specified configuration options. If you need to modify other configuration options, you can refer to the StrategyConfig reference documentation.

Defining Strategy Rules

In this section, you will learn how to implement a basic trading strategy in PyBroker with the following rules:

  1. Buy shares in a stock if the last close price is less than the low of the previous bar and there is no open long position in that stock.

  2. Set the limit price of the buy order to 0.01 less than the last close price.

  3. Hold the position for 3 days before liquidating it at market price.

  4. Trade the rules on AAPL and MSFT, allocating up to 25% of the portfolio to each.

To accomplish this, you will define a buy_low function that PyBroker will call separately for AAPL and MSFT on every bar of data. Each bar corresponds to a single day of data:

[4]:
def buy_low(ctx):
    # If shares were already purchased and are currently being held, then return.
    if ctx.long_pos():
        return
    # If the latest close price is less than the previous day's low price,
    # then place a buy order.
    if ctx.bars >= 2 and ctx.close[-1] < ctx.low[-2]:
        # Buy a number of shares that is equal to 25% the portfolio.
        ctx.buy_shares = ctx.calc_target_shares(0.25)
        # Set the limit price of the order.
        ctx.buy_limit_price = ctx.close[-1] - 0.01
        # Hold the position for 3 bars before liquidating (in this case, 3 days).
        ctx.hold_bars = 3

That is a lot to unpack! The buy_low function will receive an ExecContext (ctx) containing data for the current ticker symbol (AAPL or MSFT). The ExecContext will contain all of the close prices up until the most recent bar of the current ticker symbol. The latest close price is retrieved with ctx.close[-1].

The buy_low function will use the ExecContext to place a buy order. The number of shares to purchase is set using ctx.buy_shares, which is calculated with ctx.calc_target_shares. In this case, the number of shares to buy will be equal to 25% of the portfolio.

The limit price of the order is set with buy_limit_price. If the criteria are met, the buy order will be filled on the next bar. The time at which the order is filled can be configured with StrategyConfig.buy_delay, and its fill price can be set with ExecContext.buy_fill_price. By default, buy orders are filled on the next bar (buy_delay=1) and at a fill price equal to the midpoint between that bar’s low and high price.

Finally, ctx.hold_bars specifies how many bars to hold the position for before liquidating it. When liquidated, the shares are sold at market price equal to ExecContext.sell_fill_price, which is configurable and defaults to the midpoint between the bar’s low and high price.

To add the buy_low rules to the Strategy for AAPL and MSFT, you will use add_execution:

[5]:
strategy.add_execution(buy_low, ['AAPL', 'MSFT'])

Adding a Second Execution

You can use different sets of trading rules for different tickers within the same Strategy instance. In other words, you are not restricted to using only one set of trading rules for a single group of tickers.

To demonstrate this, a new set of rules for a short strategy is provided in a function called short_high, which is similar to the previous set of rules:

[6]:
def short_high(ctx):
    # If shares were already shorted then return.
    if ctx.short_pos():
        return
    # If the latest close price is more than the previous day's high price,
    # then place a sell order.
    if ctx.bars >= 2 and ctx.close[-1] > ctx.high[-2]:
        # Short 100 shares.
        ctx.sell_shares = 100
        # Cover the shares after 2 bars (in this case, 2 days).
        ctx.hold_bars = 2

The rules in short_high will be traded on TSLA:

[7]:
strategy.add_execution(short_high, ['TSLA'])

(Note, you can also retrieve bar data for another symbol by calling ExecContext#foreign)

Running a Backtest

To run a backtest, call the backtest method on the Strategy instance. Here is an example:

[8]:
result = strategy.backtest()
Backtesting: 2017-03-01 00:00:00 to 2022-03-01 00:00:00

Loading bar data...
[*********************100%***********************]  3 of 3 completed
Loaded bar data: 0:00:01

Test split: 2017-03-01 00:00:00 to 2022-02-28 00:00:00
100% (1259 of 1259) |####################| Elapsed Time: 0:00:00 Time:  0:00:00

Finished backtest: 0:00:03

That was fast! The backtest method will return an instance of TestResult. You can access various information and metrics about the backtest through this instance. For example, to see the daily balances of the portfolio, you can plot the market value using Matplotlib:

[9]:
import matplotlib.pyplot as plt

chart = plt.subplot2grid((3, 2), (0, 0), rowspan=3, colspan=2)
chart.plot(result.portfolio.index, result.portfolio['market_value'])
[9]:
[<matplotlib.lines.Line2D at 0x7fd9b6a3dfd0>]
../_images/notebooks_2._Backtesting_a_Strategy_18_1.png

You can also access the daily balance of each position that was held, the trades that were made for every entry and exit, and all of the orders that were placed:

[10]:
result.positions
[10]:
long_shares short_shares close equity market_value margin unrealized_pnl
symbol date
MSFT 2017-03-03 1952 0 64.25 125416.00 125416.00 0.00 585.60
2017-03-06 1952 0 64.27 125455.03 125455.03 0.00 624.63
2017-03-07 1952 0 64.40 125708.80 125708.80 0.00 878.40
2017-03-14 1937 0 64.41 124762.18 124762.18 0.00 116.23
TSLA 2017-03-15 0 100 17.05 0.00 1718.00 1704.87 13.13
... ... ... ... ... ... ... ... ...
MSFT 2022-02-22 575 0 287.72 165439.00 165439.00 0.00 -1357.00
AAPL 2022-02-22 991 0 164.32 162841.13 162841.13 0.00 -4003.63
MSFT 2022-02-23 575 0 280.27 161155.24 161155.24 0.00 -5640.76
AAPL 2022-02-23 991 0 160.07 158629.38 158629.38 0.00 -8215.38
TSLA 2022-02-28 0 100 290.14 0.00 28193.00 29014.33 -821.33

938 rows × 7 columns

[11]:
result.trades
[11]:
type symbol entry_date exit_date entry exit shares pnl return_pct agg_pnl bars pnl_per_bar stop
id
1 long MSFT 2017-03-03 2017-03-08 63.95 64.67 1952 1405.44 1.13 1405.44 3 468.48 bar
2 long MSFT 2017-03-14 2017-03-17 64.35 64.96 1937 1181.57 0.95 2587.01 3 393.86 bar
3 short TSLA 2017-03-15 2017-03-17 17.18 17.55 100 -37.00 -2.11 2550.01 2 -18.50 bar
4 short TSLA 2017-03-27 2017-03-29 17.68 18.50 100 -82.00 -4.43 2468.01 2 -41.00 bar
5 short TSLA 2017-04-04 2017-04-06 19.98 19.87 100 11.00 0.55 2479.01 2 5.50 bar
... ... ... ... ... ... ... ... ... ... ... ... ... ...
384 long AAPL 2022-02-11 2022-02-16 170.56 171.69 970 1096.10 0.66 170660.61 3 365.37 bar
385 long MSFT 2022-02-11 2022-02-16 299.26 297.27 552 -1098.48 -0.66 169562.13 3 -366.16 bar
386 short TSLA 2022-02-16 2022-02-18 304.61 287.41 100 1720.00 5.98 171282.13 2 860.00 bar
387 long AAPL 2022-02-18 2022-02-24 168.36 157.43 991 -10831.63 -6.49 160450.50 3 -3610.54 bar
388 long MSFT 2022-02-18 2022-02-24 290.08 283.34 575 -3875.50 -2.32 156575.00 3 -1291.83 bar

388 rows × 13 columns

[12]:
result.orders
[12]:
type symbol date shares limit_price fill_price fees
id
1 buy MSFT 2017-03-03 1952 64.00 63.95 0.0
2 sell MSFT 2017-03-08 1952 NaN 64.67 0.0
3 buy MSFT 2017-03-14 1937 64.70 64.35 0.0
4 sell TSLA 2017-03-15 100 NaN 17.18 0.0
5 sell MSFT 2017-03-17 1937 NaN 64.96 0.0
... ... ... ... ... ... ... ...
773 buy AAPL 2022-02-18 991 168.87 168.36 0.0
774 buy MSFT 2022-02-18 575 290.72 290.08 0.0
775 sell AAPL 2022-02-24 991 NaN 157.43 0.0
776 sell MSFT 2022-02-24 575 NaN 283.34 0.0
777 sell TSLA 2022-02-28 100 NaN 281.93 0.0

777 rows × 7 columns

Additionally, result.metrics_df contains a DataFrame of metrics calculated using the returns of the backtest. You can read about what these metrics mean on the reference documentation.

[13]:
result.metrics_df
[13]:
name value
0 trade_count 388.000000
1 initial_market_value 500000.000000
2 end_market_value 655753.670000
3 total_pnl 156575.000000
4 unrealized_pnl -821.330000
5 total_return_pct 31.315000
6 total_profit 383032.400000
7 total_loss -226457.400000
8 total_fees 0.000000
9 max_drawdown -30181.580000
10 max_drawdown_pct -4.554816
11 win_rate 52.577320
12 loss_rate 47.422680
13 winning_trades 204.000000
14 losing_trades 184.000000
15 avg_pnl 403.543814
16 avg_return_pct 0.279639
17 avg_trade_bars 2.414948
18 avg_profit 1877.609804
19 avg_profit_pct 3.168775
20 avg_winning_trade_bars 2.465686
21 avg_loss -1230.746739
22 avg_loss_pct -2.923533
23 avg_losing_trade_bars 2.358696
24 largest_win 20797.970000
25 largest_win_pct 14.490000
26 largest_win_bars 3.000000
27 largest_loss -10831.630000
28 largest_loss_pct -6.490000
29 largest_loss_bars 3.000000
30 max_wins 7.000000
31 max_losses 7.000000
32 sharpe 0.054488
33 sortino 0.061320
34 profit_factor 1.312935
35 ulcer_index 0.627821
36 upi 0.035531
37 equity_r2 0.893202
38 std_error 63596.828230

Filtering Backtest Data

You can filter the data used for the backtest to only include specific bars. For example, you can limit the strategy to trade only on Mondays by filtering the data to only contain bars for Mondays:

[14]:
result = strategy.backtest(days='mon')
result.orders
Backtesting: 2017-03-01 00:00:00 to 2022-03-01 00:00:00

Loaded cached bar data.

Test split: 2017-03-06 00:00:00 to 2022-02-28 00:00:00
100% (238 of 238) |######################| Elapsed Time: 0:00:00 Time:  0:00:00

Finished backtest: 0:00:00
[14]:
type symbol date shares limit_price fill_price fees
id
1 sell TSLA 2017-03-27 100 NaN 17.68 0.0
2 buy TSLA 2017-04-10 100 NaN 20.75 0.0
3 sell TSLA 2017-04-17 100 NaN 20.09 0.0
4 buy TSLA 2017-05-01 100 NaN 21.40 0.0
5 sell TSLA 2017-05-08 100 NaN 20.65 0.0
... ... ... ... ... ... ... ...
178 sell TSLA 2022-02-07 100 NaN 308.41 0.0
179 sell AAPL 2022-02-14 599 NaN 168.07 0.0
180 buy MSFT 2022-02-14 373 300.94 294.06 0.0
181 buy TSLA 2022-02-28 100 NaN 281.93 0.0
182 buy AAPL 2022-02-28 651 168.87 163.92 0.0

182 rows × 7 columns

The data doesn’t need to be downloaded again from Yahoo Finance because caching is enabled and the cached data only needs to be filtered.

You can also filter the data by time range, such as 9:30-10:30 AM, using the between_time argument.

Although the metrics earlier indicate that we have a profitable strategy, we may be misled by randomness. In the next notebook, we’ll discuss how to use bootstrapping to further evaluate our trading strategies.