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 0x7f453c0a85b0>
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:
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.
Set the limit price of the buy order to 0.01 less than the last close price.
Hold the position for 3 days before liquidating it at market price.
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:000: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 0x7f44544ecfd0>]

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 | 583 | 0 | 287.72 | 167740.76 | 167740.76 | 0.00 | -1375.88 |
AAPL | 2022-02-22 | 1005 | 0 | 164.32 | 165141.61 | 165141.61 | 0.00 | -4060.19 |
MSFT | 2022-02-23 | 583 | 0 | 280.27 | 163397.40 | 163397.40 | 0.00 | -5719.24 |
AAPL | 2022-02-23 | 1005 | 0 | 160.07 | 160870.36 | 160870.36 | 0.00 | -8331.44 |
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 | mae | mfe | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
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 | -0.33 | 0.83 |
2 | long | MSFT | 2017-03-14 | 2017-03-17 | 64.35 | 64.96 | 1937 | 1181.57 | 0.95 | 2587.01 | 3 | 393.86 | bar | -0.20 | 0.61 |
3 | short | TSLA | 2017-03-15 | 2017-03-17 | 17.18 | 17.55 | 100 | -37.00 | -2.11 | 2550.01 | 2 | -18.50 | bar | -0.54 | 0.23 |
4 | short | TSLA | 2017-03-27 | 2017-03-29 | 17.68 | 18.50 | 100 | -82.00 | -4.43 | 2468.01 | 2 | -41.00 | bar | -1.03 | 0.36 |
5 | short | TSLA | 2017-04-04 | 2017-04-06 | 19.98 | 19.87 | 100 | 11.00 | 0.55 | 2479.01 | 2 | 5.50 | bar | -0.35 | 0.37 |
... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
384 | long | AAPL | 2022-02-11 | 2022-02-16 | 170.56 | 171.69 | 984 | 1111.92 | 0.66 | 180139.06 | 3 | 370.64 | bar | -4.00 | 2.52 |
385 | long | MSFT | 2022-02-11 | 2022-02-16 | 299.26 | 297.27 | 560 | -1114.40 | -0.66 | 179024.66 | 3 | -371.47 | bar | -7.91 | 5.03 |
386 | short | TSLA | 2022-02-16 | 2022-02-18 | 304.61 | 287.41 | 100 | 1720.00 | 5.98 | 180744.66 | 2 | 860.00 | bar | -4.20 | 17.20 |
387 | long | AAPL | 2022-02-18 | 2022-02-24 | 168.36 | 157.43 | 1005 | -10984.65 | -6.49 | 169760.01 | 3 | -3661.55 | bar | -10.93 | 2.18 |
388 | long | MSFT | 2022-02-18 | 2022-02-24 | 290.08 | 283.34 | 583 | -3929.42 | -2.32 | 165830.59 | 3 | -1309.81 | bar | -9.98 | 3.78 |
388 rows × 15 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 | 1005 | 168.87 | 168.36 | 0.0 |
774 | buy | MSFT | 2022-02-18 | 583 | 290.72 | 290.08 | 0.0 |
775 | sell | AAPL | 2022-02-24 | 1005 | NaN | 157.43 | 0.0 |
776 | sell | MSFT | 2022-02-24 | 583 | 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 | 665009.260000 |
3 | total_pnl | 165830.590000 |
4 | unrealized_pnl | -821.330000 |
5 | total_return_pct | 33.166118 |
6 | total_profit | 402053.210000 |
7 | total_loss | -236222.620000 |
8 | total_fees | 0.000000 |
9 | max_drawdown | -31619.460000 |
10 | max_drawdown_pct | -4.722785 |
11 | win_rate | 52.577320 |
12 | loss_rate | 47.422680 |
13 | winning_trades | 204.000000 |
14 | losing_trades | 184.000000 |
15 | avg_pnl | 427.398428 |
16 | avg_return_pct | 0.279639 |
17 | avg_trade_bars | 2.414948 |
18 | avg_profit | 1970.849069 |
19 | avg_profit_pct | 3.168775 |
20 | avg_winning_trade_bars | 2.465686 |
21 | avg_loss | -1283.818587 |
22 | avg_loss_pct | -2.923533 |
23 | avg_losing_trade_bars | 2.358696 |
24 | largest_win | 20973.750000 |
25 | largest_win_pct | 14.490000 |
26 | largest_win_bars | 3.000000 |
27 | largest_loss | -10984.650000 |
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.055208 |
33 | sortino | 0.061908 |
34 | profit_factor | 1.317514 |
35 | ulcer_index | 0.659022 |
36 | upi | 0.035627 |
37 | equity_r2 | 0.902314 |
38 | std_error | 65830.655991 |
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:000: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 | 777 | NaN | 168.07 | 0.0 |
180 | buy | MSFT | 2022-02-14 | 457 | 300.94 | 294.06 | 0.0 |
181 | buy | TSLA | 2022-02-28 | 100 | NaN | 281.93 | 0.0 |
182 | buy | AAPL | 2022-02-28 | 811 | 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.