Basics of Stock Backtesting in Python
Stock Backtesting in Python is way of testing our strategy in a historical data to see if our strategy makes any money or not. Let’s start with a simple story.
John and Joe are two best friends. They both earned some money from their hard working corporate job and wanted to invest it in a stock market. Unlike Joe, John is clever and does not fall for any influence of stock’s price increasing and decreasing. They studied some Statistics and Probability along with Economics in college and and they love their money. Joe followed trend and bought some stock of X and felt glad that his stock’s price increased by some % in few days. John was calm person and thought that John’s stock position is increased but he is not earning any money and only way to earn is by selling it. John wanted to get back in time and questioned himself what will happen if I try to buy some stock of X and sell it if price increased by 10% or decrease by 5%. Then I will buy as much stock as possible from the amount I have. How much would have I earned today? Well he did not know but what he tried to do is a simple stock backtesting example.
Here in this stock backtesting blog, we will start with our very simple strategy and then try to use of the most popular stock backtesting Python package Backtesting.py
. But first, let’s install it.
!pip install backtesting
Requirement already satisfied: backtesting in c:\programdata\anaconda3\lib\site-packages (0.3.3)
Requirement already satisfied: numpy>=1.17.0 in c:\programdata\anaconda3\lib\site-packages (from backtesting) (1.19.2)
Requirement already satisfied: pandas!=0.25.0,>=0.25.0 in c:\users\viper\appdata\roaming\python\python38\site-packages (from backtesting) (1.3.5)
Requirement already satisfied: bokeh>=1.4.0 in c:\users\viper\appdata\roaming\python\python38\site-packages (from backtesting) (2.4.3)
Requirement already satisfied: python-dateutil>=2.7.3 in c:\programdata\anaconda3\lib\site-packages (from pandas!=0.25.0,>=0.25.0->backtesting) (2.8.1)
Requirement already satisfied: pytz>=2017.3 in c:\programdata\anaconda3\lib\site-packages (from pandas!=0.25.0,>=0.25.0->backtesting) (2020.1)
Requirement already satisfied: Jinja2>=2.9 in c:\programdata\anaconda3\lib\site-packages (from bokeh>=1.4.0->backtesting) (2.11.2)
Requirement already satisfied: tornado>=5.1 in c:\programdata\anaconda3\lib\site-packages (from bokeh>=1.4.0->backtesting) (6.0.4)
Requirement already satisfied: packaging>=16.8 in c:\programdata\anaconda3\lib\site-packages (from bokeh>=1.4.0->backtesting) (20.4)
Requirement already satisfied: typing-extensions>=3.10.0 in c:\users\viper\appdata\roaming\python\python38\site-packages (from bokeh>=1.4.0->backtesting) (4.3.0)
Requirement already satisfied: pillow>=7.1.0 in c:\programdata\anaconda3\lib\site-packages (from bokeh>=1.4.0->backtesting) (8.0.1)
Requirement already satisfied: PyYAML>=3.10 in c:\users\viper\appdata\roaming\python\python38\site-packages (from bokeh>=1.4.0->backtesting) (6.0)
Requirement already satisfied: six>=1.5 in c:\programdata\anaconda3\lib\site-packages (from python-dateutil>=2.7.3->pandas!=0.25.0,>=0.25.0->backtesting) (1.15.0)
Requirement already satisfied: MarkupSafe>=0.23 in c:\programdata\anaconda3\lib\site-packages (from Jinja2>=2.9->bokeh>=1.4.0->backtesting) (1.1.1)
Requirement already satisfied: pyparsing>=2.0.2 in c:\users\viper\appdata\roaming\python\python38\site-packages (from packaging>=16.8->bokeh>=1.4.0->backtesting) (3.0.9)
Before going into stock backtesting, lets choose the data of any stock. We will choose data of AAPL from yfinance
. If it is not installed, we can do so by pip install yfinance
.
!pip install yfinance --user
Requirement already satisfied: yfinance in c:\users\viper\appdata\roaming\python\python38\site-packages (0.1.87)
Requirement already satisfied: requests>=2.26 in c:\users\viper\appdata\roaming\python\python38\site-packages (from yfinance) (2.28.1)
Requirement already satisfied: pandas>=0.24.0 in c:\users\viper\appdata\roaming\python\python38\site-packages (from yfinance) (1.3.5)
Requirement already satisfied: multitasking>=0.0.7 in c:\users\viper\appdata\roaming\python\python38\site-packages (from yfinance) (0.0.11)
Requirement already satisfied: appdirs>=1.4.4 in c:\programdata\anaconda3\lib\site-packages (from yfinance) (1.4.4)
Requirement already satisfied: lxml>=4.5.1 in c:\programdata\anaconda3\lib\site-packages (from yfinance) (4.6.1)
Requirement already satisfied: numpy>=1.15 in c:\programdata\anaconda3\lib\site-packages (from yfinance) (1.19.2)
Requirement already satisfied: charset-normalizer<3,>=2 in c:\users\viper\appdata\roaming\python\python38\site-packages (from requests>=2.26->yfinance) (2.1.0)
Requirement already satisfied: urllib3<1.27,>=1.21.1 in c:\users\viper\appdata\roaming\python\python38\site-packages (from requests>=2.26->yfinance) (1.25.11)
Requirement already satisfied: certifi>=2017.4.17 in c:\programdata\anaconda3\lib\site-packages (from requests>=2.26->yfinance) (2020.6.20)
Requirement already satisfied: idna<4,>=2.5 in c:\programdata\anaconda3\lib\site-packages (from requests>=2.26->yfinance) (2.10)
Requirement already satisfied: pytz>=2017.3 in c:\programdata\anaconda3\lib\site-packages (from pandas>=0.24.0->yfinance) (2020.1)
Requirement already satisfied: python-dateutil>=2.7.3 in c:\programdata\anaconda3\lib\site-packages (from pandas>=0.24.0->yfinance) (2.8.1)
Requirement already satisfied: six>=1.5 in c:\programdata\anaconda3\lib\site-packages (from python-dateutil>=2.7.3->pandas>=0.24.0->yfinance) (1.15.0)
import pandas as pd
import yfinance as yf
Next is to download data. We can download data as following.
data = yf.download("AAPL", start="2015-01-01", end="2022-04-30")
del data['Adj Close']
del data['Volume']
data
[*********************100%***********************] 1 of 1 completed
Open | High | Low | Close | |
---|---|---|---|---|
Date | ||||
2015-01-02 | 27.847500 | 27.860001 | 26.837500 | 27.332500 |
2015-01-05 | 27.072500 | 27.162500 | 26.352501 | 26.562500 |
2015-01-06 | 26.635000 | 26.857500 | 26.157499 | 26.565001 |
2015-01-07 | 26.799999 | 27.049999 | 26.674999 | 26.937500 |
2015-01-08 | 27.307501 | 28.037500 | 27.174999 | 27.972500 |
... | ... | ... | ... | ... |
2022-04-25 | 161.119995 | 163.169998 | 158.460007 | 162.880005 |
2022-04-26 | 162.250000 | 162.339996 | 156.720001 | 156.800003 |
2022-04-27 | 155.910004 | 159.789993 | 155.380005 | 156.570007 |
2022-04-28 | 159.250000 | 164.520004 | 158.929993 | 163.639999 |
2022-04-29 | 161.839996 | 166.199997 | 157.250000 | 157.649994 |
1845 rows × 4 columns
Our data will be daily floorsheet data and we will make stock backtesting strategy on it. Alternatively we could get data for testing from backtesting.py too but it only allows GOOG.
import backtesting.test as btest
btest.GOOG
Open | High | Low | Close | Volume | |
---|---|---|---|---|---|
2004-08-19 | 100.00 | 104.06 | 95.96 | 100.34 | 22351900 |
2004-08-20 | 101.01 | 109.08 | 100.50 | 108.31 | 11428600 |
2004-08-23 | 110.75 | 113.48 | 109.05 | 109.40 | 9137200 |
2004-08-24 | 111.24 | 111.60 | 103.57 | 104.87 | 7631300 |
2004-08-25 | 104.96 | 108.00 | 103.88 | 106.00 | 4598900 |
... | ... | ... | ... | ... | ... |
2013-02-25 | 802.30 | 808.41 | 790.49 | 790.77 | 2303900 |
2013-02-26 | 795.00 | 795.95 | 784.40 | 790.13 | 2202500 |
2013-02-27 | 794.80 | 804.75 | 791.11 | 799.78 | 2026100 |
2013-02-28 | 801.10 | 806.99 | 801.03 | 801.20 | 2265800 |
2013-03-01 | 797.80 | 807.14 | 796.15 | 806.19 | 2175400 |
2148 rows × 5 columns
Preparing SMA
We will work on our data from yfinance next. There is a good availability of classes and modules for stock backtesting and lets use them instead of writing our own indicators. But I have written many indicators from scratch and you can find them here. Here, SMA stands for Simple Moving Average.
We start by making a class that inherits Strategy
class inside backtesting and we do not need anything at all at this time but lets use crossover
and SMA
too. But this will be covered later. First lets take a look into our data and try to plot SMA of two periods, one longer and one shorter. One SMA of 20 days and another of 40 days. Our simple stock backtesting strategy will be to buy when small SMA crosses over bigger SMA.
n1,n2=20,40
ndata = data.copy()
ndata[f'SMA_{n1}'] = ndata.Close.rolling(n1).mean()
ndata[f'SMA_{n2}'] = ndata.Close.rolling(n2).mean()
ndata[[f'SMA_{n1}', f'SMA_{n2}']].plot(figsize=(15,10))
<AxesSubplot:xlabel='Date'>
We can see that SMA_20 and SMA_40 are crossing over each other in multiple times. But the plot looks little huge so lets take data of last 200 days only.
last = 200
n1,n2=20,40
tdata = data.copy().tail(last)
tdata[f'SMA_{n1}'] = tdata.Close.rolling(n1).mean()
tdata[f'SMA_{n2}'] = tdata.Close.rolling(n2).mean()
tdata[[f'SMA_{n1}', f'SMA_{n2}']].plot(figsize=(15,10))
<AxesSubplot:xlabel='Date'>
Our Simple Strategy
Now let’s make our stock backtesting strategy. If the short SMA crosses over large SMA, we buy and hold positions because we saw that it has increased the value of price recently and could increase in future too. But if short SMA crosses below large SMA, we sell our holding positions because there has been recent price drops. In above example we will do trades whenever crossover happens. A simple way to find a crossover is by comparing difference between current price and previous. If the difference was positive in past and negative now then we do trade and vice versa. Note that we buy on the Open price of next day.
tdata['sma1_gt_sma2'] = tdata[f'SMA_{n1}']>tdata[f'SMA_{n2}']
tdata['crossed'] = (tdata.sma1_gt_sma2!=tdata.sma1_gt_sma2.shift(1))
print(f"Num Corssed: {tdata.crossed.sum()-1}")
tdata
Num Corssed: 7
Open | High | Low | Close | SMA_20 | SMA_40 | sma1_gt_sma2 | crossed | |
---|---|---|---|---|---|---|---|---|
Date | ||||||||
2021-07-16 | 148.460007 | 149.759995 | 145.880005 | 146.389999 | NaN | NaN | False | True |
2021-07-19 | 143.750000 | 144.070007 | 141.669998 | 142.449997 | NaN | NaN | False | False |
2021-07-20 | 143.460007 | 147.100006 | 142.960007 | 146.149994 | NaN | NaN | False | False |
2021-07-21 | 145.529999 | 146.130005 | 144.630005 | 145.399994 | NaN | NaN | False | False |
2021-07-22 | 145.940002 | 148.199997 | 145.809998 | 146.800003 | NaN | NaN | False | False |
... | ... | ... | ... | ... | ... | ... | ... | ... |
2022-04-25 | 161.119995 | 163.169998 | 158.460007 | 162.880005 | 170.435000 | 166.72550 | True | False |
2022-04-26 | 162.250000 | 162.339996 | 156.720001 | 156.800003 | 169.495000 | 166.51750 | True | False |
2022-04-27 | 155.910004 | 159.789993 | 155.380005 | 156.570007 | 168.375500 | 166.35175 | True | False |
2022-04-28 | 159.250000 | 164.520004 | 158.929993 | 163.639999 | 167.668999 | 166.27875 | True | False |
2022-04-29 | 161.839996 | 166.199997 | 157.250000 | 157.649994 | 166.820999 | 166.06425 | True | False |
200 rows × 8 columns
In above code, we made new column where we checked if SMA1 is higher than SMA2 or not and in next crossed column we checked if the status of SMA1>SMA2 still holds same from the previous time. And when it is false, we do trade. We should ignore the first one because it will give us NaN value on shift. Let’s assume that we have USD 10000 in cash and want to do trade. Since we have SMA1>SMA2 column, we buy only when there is crossed
True and sma1_gt_sma2
True as well. And we sell only when there is crossed
True and sma1_gt_sma2
is False.
Trading Result
To find stock backtesting trades data, we loop through the data and if yesterday’s SMA1>SMA2 then we buy on today’s Open price and selling happens on same way.
- If crossed and SMA1>SMA2: buy positions based on remaining amount and add positions.
- If crossed and SMA1<SMA2: sell available positions and add remaining amount.
- On last day sell all positions and add remaining amount.
ntdata = tdata.reset_index().copy()
ntdata['crossed']=ntdata.crossed.shift(1)
ntdata['sma1_gt_sma2']=ntdata.sma1_gt_sma2.shift(1)
positions = 0
rem_amt=10000
lr = len(ntdata)-1
trades = []
tinfo=[]
for i, row in ntdata.iterrows():
if i!=0:
if row.crossed and row.sma1_gt_sma2:
positions=int(rem_amt/row.Open)
rem_amt= rem_amt-row.Open*positions
tinfo.append(positions)
tinfo.append(row.Open)
tinfo.append(row.Date)
if row.crossed==True and row.sma1_gt_sma2==False and positions>0:
rem_amt = rem_amt+row.Open*positions
tinfo.append(row.Date)
tinfo.append(row.Open)
trades.append(tinfo)
tinfo=[]
positions = 0
if i==lr and positions>0:
rem_amt=rem_amt + positions*row.Open
tinfo.append(row.Date)
tinfo.append(row.Open)
trades.append(tinfo)
positions = 0
ntdata.loc[i, 'positions'] = positions
ntdata.loc[i, 'rem_amount'] = rem_amt
trades = pd.DataFrame(trades, columns=['Positions', 'Buy', 'Entry', 'Exit', 'Sell'])
trades['return']=((trades['Sell']-trades['Buy'])*trades.Positions).cumsum()
trades
Positions | Buy | Entry | Exit | Sell | return | |
---|---|---|---|---|---|---|
0 | 66 | 150.630005 | 2021-09-13 | 2021-10-01 | 141.899994 | -576.180725 |
1 | 62 | 150.389999 | 2021-11-03 | 2022-01-27 | 162.449997 | 171.539124 |
2 | 61 | 164.699997 | 2022-03-01 | 2022-03-02 | 164.389999 | 152.629272 |
3 | 58 | 172.360001 | 2022-04-06 | 2022-04-29 | 161.839996 | -457.530975 |
Looking over the above table, in return column, we are in 457 loss overall. What if we did this testing with larger period of data?
Our Strategy in a Larger Period
Lets start from the last 1000 day and forth.
n1,n2=20,40
last = 1000
tdata = data.copy().tail(last)
tdata[f'SMA_{n1}'] = tdata.Close.rolling(n1).mean()
tdata[f'SMA_{n2}'] = tdata.Close.rolling(n2).mean()
tdata['sma1_gt_sma2'] = tdata[f'SMA_{n1}']>tdata[f'SMA_{n2}']
tdata['crossed'] = (tdata.sma1_gt_sma2!=tdata.sma1_gt_sma2.shift(1))
print(f"Num Corssed: {tdata.crossed.sum()-1}")
ntdata = tdata.reset_index().copy()
ntdata['crossed']=ntdata.crossed.shift(1)
ntdata['sma1_gt_sma2']=ntdata.sma1_gt_sma2.shift(1)
positions = 0
rem_amt=10000
lr = len(ntdata)-1
trades = []
tinfo=[]
for i, row in ntdata.iterrows():
if i!=0:
if row.crossed and row.sma1_gt_sma2:
positions=int(rem_amt/row.Open)
rem_amt= rem_amt-row.Open*positions
tinfo.append(positions)
tinfo.append(row.Open)
tinfo.append(row.Date)
if row.crossed==True and row.sma1_gt_sma2==False and positions>0:
rem_amt = rem_amt+row.Open*positions
tinfo.append(row.Date)
tinfo.append(row.Open)
trades.append(tinfo)
tinfo=[]
positions = 0
if i==lr and positions>0:
rem_amt=rem_amt + positions*row.Open
tinfo.append(row.Date)
tinfo.append(row.Open)
trades.append(tinfo)
positions = 0
ntdata.loc[i, 'positions'] = positions
ntdata.loc[i, 'rem_amount'] = rem_amt
trades = pd.DataFrame(trades, columns=['Positions', 'Buy', 'Entry', 'Exit', 'Sell'])
trades['return']=((trades['Sell']-trades['Buy'])*trades.Positions).cumsum()
trades
Num Corssed: 23
Positions | Buy | Entry | Exit | Sell | return | |
---|---|---|---|---|---|---|
0 | 205 | 48.652500 | 2018-07-26 | 2018-10-26 | 53.974998 | 1091.112156 |
1 | 257 | 43.099998 | 2019-02-07 | 2019-05-23 | 44.950001 | 1566.562744 |
2 | 232 | 49.669998 | 2019-06-28 | 2019-08-28 | 51.025002 | 1880.923523 |
3 | 228 | 52.097500 | 2019-09-04 | 2020-03-02 | 70.570000 | 6092.653488 |
4 | 232 | 69.300003 | 2020-04-24 | 2020-09-29 | 114.550003 | 16590.653488 |
5 | 233 | 114.010002 | 2020-10-26 | 2020-11-18 | 118.610001 | 17662.453133 |
6 | 228 | 121.010002 | 2020-12-01 | 2021-02-26 | 122.589996 | 18022.691811 |
7 | 207 | 134.940002 | 2021-04-14 | 2021-05-24 | 126.010002 | 16174.181747 |
8 | 194 | 134.449997 | 2021-06-24 | 2021-10-01 | 141.899994 | 17619.481155 |
9 | 183 | 150.389999 | 2021-11-03 | 2022-01-27 | 162.449997 | 19826.460709 |
10 | 181 | 164.699997 | 2022-03-01 | 2022-03-02 | 164.389999 | 19770.351151 |
11 | 172 | 172.360001 | 2022-04-06 | 2022-04-29 | 161.839996 | 17960.910416 |
It looks like we actually made some money while testing on larger period.
Strategy with Backtesting
Until now we designed a very simple strategy and did trading and to do so, we had to write too many codes but why do we need to struggle that hard while there is already one open source package available which handles our struggles? Following is a modified version of our strategy and it is modified from the Quick Start page.
from backtesting import Strategy
from backtesting.lib import crossover
from backtesting.test import SMA
class SmaCross(Strategy):
# Define the two MA lags as *class variables*
# for later optimization
n1 = 20
n2 = 40
def init(self):
# Precompute the two moving averages
self.sma1 = self.I(SMA, self.data.Close, self.n1)
self.sma2 = self.I(SMA, self.data.Close, self.n2)
def next(self):
# If sma1 crosses above sma2, close any existing
# short trades, and buy the asset
if crossover(self.sma1, self.sma2):
self.position.close()
self.buy()
# Else, if sma1 crosses below sma2, close any existing
# long trades, and sell the asset
elif crossover(self.sma2, self.sma1):
self.position.close()
self.sell()
from backtesting import Backtest
bt = Backtest(data.tail(last), SmaCross, cash=10000, commission=0)
stats = bt.run()
stats
Start 2018-05-11 00:00:00
End 2022-04-29 00:00:00
Duration 1449 days 00:00:00
Exposure Time [%] 94.8
Equity Final [$] 21836.199413
Equity Peak [$] 34464.403912
Return [%] 118.361994
Buy & Hold Return [%] 234.376153
Return (Ann.) [%] 21.751022
Volatility (Ann.) [%] 38.394298
Sharpe Ratio 0.566517
Sortino Ratio 1.031964
Calmar Ratio 0.562183
Max. Drawdown [%] -38.690305
Avg. Drawdown [%] -5.507696
Max. Drawdown Duration 458 days 00:00:00
Avg. Drawdown Duration 34 days 00:00:00
# Trades 23
Win Rate [%] 52.173913
Best Trade [%] 65.295812
Worst Trade [%] -10.50055
Avg. Trade [%] 3.457111
Max. Trade Duration 180 days 00:00:00
Avg. Trade Duration 60 days 00:00:00
Profit Factor 2.831255
Expectancy [%] 4.500417
SQN 0.873935
_strategy SmaCross
_equity_curve ...
_trades Size EntryB...
dtype: object
We start by importing necessary classes and methods. We create a new class for our own strategy which inherits Strategy. We initialize variables and then SMA. When doing run()
, the next()
method loops through the data rows and perform checks inside it. We can pass commission percent to calculate how much commission do we have to pay to our broker.
Trades
The trades table using backtesting is different than ours.
stats['_trades'] # Contains individual trade data
Size | EntryBar | ExitBar | EntryPrice | ExitPrice | PnL | ReturnPct | EntryTime | ExitTime | Duration | |
---|---|---|---|---|---|---|---|---|---|---|
0 | 205 | 52 | 117 | 48.652500 | 53.974998 | 1091.112156 | 0.109398 | 2018-07-26 | 2018-10-26 | 92 days |
1 | -205 | 117 | 186 | 53.974998 | 43.099998 | 2229.375000 | 0.201482 | 2018-10-26 | 2019-02-07 | 104 days |
2 | 309 | 186 | 259 | 43.099998 | 44.950001 | 571.650707 | 0.042923 | 2019-02-07 | 2019-05-23 | 105 days |
3 | -309 | 259 | 284 | 44.950001 | 49.669998 | -1458.479198 | -0.105006 | 2019-05-23 | 2019-06-28 | 36 days |
4 | 250 | 284 | 326 | 49.669998 | 51.025002 | 338.750839 | 0.027280 | 2019-06-28 | 2019-08-28 | 61 days |
5 | -250 | 326 | 330 | 51.025002 | 52.097500 | -268.124580 | -0.021019 | 2019-08-28 | 2019-09-04 | 7 days |
6 | 240 | 330 | 453 | 52.097500 | 70.570000 | 4433.399963 | 0.354576 | 2019-09-04 | 2020-03-02 | 180 days |
7 | -240 | 453 | 491 | 70.570000 | 69.300003 | 304.799194 | 0.017996 | 2020-03-02 | 2020-04-24 | 53 days |
8 | 248 | 491 | 600 | 69.300003 | 114.550003 | 11222.000000 | 0.652958 | 2020-04-24 | 2020-09-29 | 158 days |
9 | -248 | 600 | 619 | 114.550003 | 114.010002 | 133.920227 | 0.004714 | 2020-09-29 | 2020-10-26 | 27 days |
10 | 250 | 619 | 636 | 114.010002 | 118.610001 | 1149.999619 | 0.040347 | 2020-10-26 | 2020-11-18 | 23 days |
11 | -250 | 636 | 644 | 118.610001 | 121.010002 | -600.000381 | -0.020234 | 2020-11-18 | 2020-12-01 | 13 days |
12 | 240 | 644 | 703 | 121.010002 | 122.589996 | 379.198608 | 0.013057 | 2020-12-01 | 2021-02-26 | 87 days |
13 | -240 | 703 | 735 | 122.589996 | 134.940002 | -2964.001465 | -0.100742 | 2021-02-26 | 2021-04-14 | 47 days |
14 | 196 | 735 | 763 | 134.940002 | 126.010002 | -1750.280060 | -0.066178 | 2021-04-14 | 2021-05-24 | 40 days |
15 | -196 | 763 | 785 | 126.010002 | 134.449997 | -1654.238983 | -0.066979 | 2021-05-24 | 2021-06-24 | 31 days |
16 | 172 | 785 | 854 | 134.449997 | 141.899994 | 1281.399475 | 0.055411 | 2021-06-24 | 2021-10-01 | 99 days |
17 | -172 | 854 | 877 | 141.899994 | 150.389999 | -1460.280945 | -0.059831 | 2021-10-01 | 2021-11-03 | 33 days |
18 | 152 | 877 | 935 | 150.389999 | 162.449997 | 1833.119629 | 0.080191 | 2021-11-03 | 2022-01-27 | 85 days |
19 | -152 | 935 | 957 | 162.449997 | 164.699997 | -342.000000 | -0.013850 | 2022-01-27 | 2022-03-01 | 33 days |
20 | 148 | 957 | 958 | 164.699997 | 164.389999 | -45.879639 | -0.001882 | 2022-03-01 | 2022-03-02 | 1 days |
21 | -148 | 958 | 983 | 164.389999 | 172.360001 | -1179.560181 | -0.048482 | 2022-03-02 | 2022-04-06 | 35 days |
22 | 134 | 983 | 999 | 172.360001 | 161.839996 | -1409.680573 | -0.061035 | 2022-04-06 | 2022-04-29 | 23 days |
Plotting
We can even plot our trading with bokeh plot. It is interactive just like plotly
bt.plot()
Stop Profit and Stop Loss
Profit and stop loss are often used to stay in the safe side. We exit from the trade when there is increase in price and take a profit but reversely, we exit from the trade when there is decrease in price and realize loss.
from backtesting import Strategy
from backtesting.lib import crossover
from backtesting.test import SMA
class SmaCross(Strategy):
# Define the two MA lags as *class variables*
# for later optimization
n1 = 20
n2 = 40
def init(self):
# Precompute the two moving averages
self.sma1 = self.I(SMA, self.data.Close, self.n1)
self.sma2 = self.I(SMA, self.data.Close, self.n2)
def next(self):
# If sma1 crosses above sma2, close any existing
# short trades, and buy the asset
if crossover(self.sma1, self.sma2):
# self.position.close()
self.buy(tp=self.data.Close[-1]*1.2, sl=self.data.Close[-1]*0.95)
# Else, if sma1 crosses below sma2, close any existing
# long trades, and sell the asset
elif crossover(self.sma2, self.sma1):
self.position.close()
# self.sell()
from backtesting import Backtest
bt = Backtest(data.tail(last), SmaCross, cash=10000, commission=0)
stats = bt.run()
stats
Start 2018-05-11 00:00:00
End 2022-04-29 00:00:00
Duration 1449 days 00:00:00
Exposure Time [%] 39.7
Equity Final [$] 25715.433767
Equity Peak [$] 26697.815822
Return [%] 157.154338
Buy & Hold Return [%] 234.376153
Return (Ann.) [%] 26.872895
Volatility (Ann.) [%] 19.883748
Sharpe Ratio 1.351501
Sortino Ratio 2.733988
Calmar Ratio 2.457561
Max. Drawdown [%] -10.934783
Avg. Drawdown [%] -2.628023
Max. Drawdown Duration 163 days 00:00:00
Avg. Drawdown Duration 20 days 00:00:00
# Trades 12
Win Rate [%] 66.666667
Best Trade [%] 21.740146
Worst Trade [%] -5.359055
Avg. Trade [%] 8.20145
Max. Trade Duration 99 days 00:00:00
Avg. Trade Duration 47 days 00:00:00
Profit Factor 8.893546
Expectancy [%] 8.683464
SQN 2.395319
_strategy SmaCross
_equity_curve ...
_trades Size EntryB...
dtype: object
In above example, we exit the trade once price increases by 20% or decreases by 5%. Doing so we made some profit as well.
Our Own Strategy in Backtesting
Lets make our own strategy here and implement it on backtesting. I want to do something like below:
- If EMA 9 > EMA 20 or EMA 50 > EMA 100 then buy.
- If EMA 9 < EMA 20 or EMA 50 < EMA 100 then close positions.
For calculation of EMA, we can use pandas_ta
. We can install it like pip install pandas-ta
.
import pandas_ta as ta
from backtesting import Backtest
from backtesting import Strategy
from backtesting.lib import crossover
class EmaCross(Strategy):
def init(self):
self.ema9 = self.I(ta.ema, pd.Series(self.data.Close), 9)
self.ema20 = self.I(ta.ema, pd.Series(self.data.Close), 20)
self.ema50 = self.I(ta.ema, pd.Series(self.data.Close), 50)
self.ema100 = self.I(ta.ema, pd.Series(self.data.Close), 100)
def next(self):
if crossover(self.ema9, self.ema20) or crossover(self.ema50, self.ema100):
self.buy()
elif crossover(self.ema20, self.ema9) or crossover(self.ema100, self.ema50):
self.position.close()
# self.sell()
bt = Backtest(data, EmaCross, cash=10000, commission=0.02)
stats = bt.run()
bt.plot()
stats
Start 2015-01-02 00:00:00
End 2022-04-29 00:00:00
Duration 2674 days 00:00:00
Exposure Time [%] 63.631436
Equity Final [$] 24777.296183
Equity Peak [$] 30443.052752
Return [%] 147.772962
Buy & Hold Return [%] 476.785846
Return (Ann.) [%] 13.193633
Volatility (Ann.) [%] 21.81465
Sharpe Ratio 0.604806
Sortino Ratio 1.02413
Calmar Ratio 0.463525
Max. Drawdown [%] -28.463721
Avg. Drawdown [%] -4.544362
Max. Drawdown Duration 776 days 00:00:00
Avg. Drawdown Duration 53 days 00:00:00
# Trades 30
Win Rate [%] 46.666667
Best Trade [%] 60.672271
Worst Trade [%] -7.85946
Avg. Trade [%] 3.077886
Max. Trade Duration 190 days 00:00:00
Avg. Trade Duration 56 days 00:00:00
Profit Factor 2.584764
Expectancy [%] 3.953845
SQN 1.328035
_strategy EmaCross
_equity_curve ...
_trades Size EntryB...
dtype: object
Looks like we made some money. But this is just another bad strategy we tested.
Testing Percentage Price Oscillator
Following is taken from my another blog.
- This is a momentum indicator (determines the strength or weakness of a value). But we can view the volatility too.
- Two EMAs, 26 period and 12 periods are used to calculate PPO.
- It contains 2 lines, PPO line and signal line. Signal line is an EMA of the 9 Period PPO, so it moves slower than PPO.
- When PPO line crosses the signal line, it is the time for rise/fall of the price or stock.
- When PPO line crosses over the signal line from below, then it is a buy signal. Reversely, it is a sell signal when PPO line crosses belo the signal line from above.
- When PPO line is below the 0, the short term average is below the longer-term average average, which helps indicate a fall of price.
- Conversely, when PPO line is above 0, the short term average is above the long term average, which helps indicate rise of price.
pandas_ta
has PPO too so we do not have to write our own code for it.
ta.ppo(data.Close)
PPO_12_26_9 | PPOh_12_26_9 | PPOs_12_26_9 | |
---|---|---|---|
2015-01-02 | NaN | NaN | NaN |
2015-01-05 | NaN | NaN | NaN |
2015-01-06 | NaN | NaN | NaN |
2015-01-07 | NaN | NaN | NaN |
2015-01-08 | NaN | NaN | NaN |
... | ... | ... | ... |
2022-04-25 | -1.987252 | -2.241286 | 0.254034 |
2022-04-26 | -2.580172 | -2.267365 | -0.312807 |
2022-04-27 | -3.049812 | -2.189604 | -0.860208 |
2022-04-28 | -3.039588 | -1.743504 | -1.296084 |
2022-04-29 | -3.256113 | -1.568023 | -1.688090 |
1845 rows × 3 columns
class PPO(Strategy):
def init(self):
self.ppo = self.I(ta.ppo, pd.Series(self.data.Close))
def next(self):
if crossover(self.ppo[0], self.ppo[2]):
# if crossover(self.ppo[1], 0):
# self.position.close()
self.buy()
elif crossover(self.ppo[2], self.ppo[0]):
#if crossover(0,self.ppo[1]):
self.position.close()
# self.sell()
bt = Backtest(data, PPO, cash=10000, commission=0.02)
stats = bt.run()
bt.plot()
print(stats)
Start 2015-01-02 00:00:00
End 2022-04-29 00:00:00
Duration 2674 days 00:00:00
Exposure Time [%] 51.00271
Equity Final [$] 12163.349445
Equity Peak [$] 14453.298656
Return [%] 21.633494
Buy & Hold Return [%] 476.785846
Return (Ann.) [%] 2.711015
Volatility (Ann.) [%] 18.61385
Sharpe Ratio 0.145645
Sortino Ratio 0.22052
Calmar Ratio 0.067051
Max. Drawdown [%] -40.432272
Avg. Drawdown [%] -7.414292
Max. Drawdown Duration 1942 days 00:00:00
Avg. Drawdown Duration 169 days 00:00:00
# Trades 56
Win Rate [%] 44.642857
Best Trade [%] 17.890366
Worst Trade [%] -15.387924
Avg. Trade [%] 0.353242
Max. Trade Duration 70 days 00:00:00
Avg. Trade Duration 23 days 00:00:00
Profit Factor 1.242225
Expectancy [%] 0.592473
SQN 0.433939
_strategy PPO
_equity_curve ...
_trades Size EntryB...
dtype: object
Looks like we again made some money. There is not a golden rule that will make a money, its kind of hit and trial.
PPO on BABA
bdata = yf.download("BABA", start="2015-01-01", end="2022-11-30")
del bdata['Adj Close']
del bdata['Volume']
bt = Backtest(bdata, PPO, cash=10000, commission=0.02)
stats = bt.run()
bt.plot()
print(stats)
[*********************100%***********************] 1 of 1 completed
Start 2015-01-02 00:00:00
End 2022-11-18 00:00:00
Duration 2877 days 00:00:00
Exposure Time [%] 52.265861
Equity Final [$] 1485.461341
Equity Peak [$] 11330.885306
Return [%] -85.145387
Buy & Hold Return [%] -22.316598
Return (Ann.) [%] -21.491087
Volatility (Ann.) [%] 21.490062
Sharpe Ratio 0.0
Sortino Ratio 0.0
Calmar Ratio 0.0
Max. Drawdown [%] -88.917088
Avg. Drawdown [%] -28.010671
Max. Drawdown Duration 2571 days 00:00:00
Avg. Drawdown Duration 705 days 00:00:00
# Trades 71
Win Rate [%] 26.760563
Best Trade [%] 26.501533
Worst Trade [%] -22.092189
Avg. Trade [%] -2.73311
Max. Trade Duration 56 days 00:00:00
Avg. Trade Duration 20 days 00:00:00
Profit Factor 0.496783
Expectancy [%] -2.346556
SQN -2.054515
_strategy PPO
_equity_curve ...
_trades Size EntryB...
dtype: object
In first PPO strategy, we tested with AAPL and in second we tested with BABA. In BABA, we lost money but in AAPL we made some.
There are many features and stock backtesting strategy to try on stock backtesting using Backtesting.py and those will be covered in next part. Thank you :)
Comments