Attached is a pair trading algo that allows the user to toggle on/off different tests for cointegration/mean-reversion of the pair's spread prior to taking any trades. If you choose to turn on one of the tests, the value from the test is recorded as a timeseries viewable from the backtest results page.
The pair being traded in this algo is the oil and gold ETFs (USO and GLD), but you can modify these as you wish.
The 3 different tests are:
- Augmented Dickey-Fuller test p-value
-- Effectively, this is a unit-root test for determining whether the spread is cointegrated
-- As well, a function is included showing how to use the critical-values from the ADF-test instead of p-value - Half-life of mean-reversion, computed from an Ornstein–Uhlenbeck process
-- This is the theoretically computed time, based on a historical window of data, that it will take for the spread to mean-revert half of its distance after having diverged from the mean of the spread - Hurst exponent
-- Effectively this returns a value between 0 and 1 that tells you whether a time-series is trending or mean-reverting. The closer the value is to 0.5 means the more "random" the time-series has behaved historically. Values below 0.5 imply the time-series is mean-reverting, and above 0.5 imply trending. The closer the value is to 0 implies greater levels of mean-reversion.
-- Trading literature is conflicted as to the usefulness of Hurst exponent, but I included it nonetheless, and have set the default switch to False in the algo.
The backtest results below incorporate two of these tests:
- ADF-test p-value, computed over a 63-day (e.g. 3-months) lookback window, with a required minimum p-value of 0.20
- Half-life days, computed over 126-day (6-month) lookback window, with a requirement of the half-life being between 1 and 42-days (2-months)
To modify the parameter values of the tests just look in the initialize function, for blocks of code that look like this. Here is how the ADF-test p-value parameters are defined:
context.stat_filter = {}
context.stat_filter['adf_p_value'] = {}
context.stat_filter['adf_p_value']['use'] = True
context.stat_filter['adf_p_value']['lookback'] = 63
context.stat_filter['adf_p_value']['function'] = adf_p_value
context.stat_filter['adf_p_value']['test_condition_min'] = 0.0
context.stat_filter['adf_p_value']['test_condition_max'] = 0.20
Here you see how there is a dictionary defined called 'stat_filter' which you can use to store the parameters of each test. First I create another dictionary inside of 'stat_filter' named 'adf_p_value' and then I load in all of the parameter values relevent to the ADF-test that I want to define when it is acceptable to enter a trade. These exact 5 parameters (e.g. keys of the dictionary) will be defined for all of the tests, as you'll see if you look at the algo code, and notice the adf_critical_value, half_life, hurst_exponent ones are defined following it. The 5 parameters are:
- 'use': Boolean, True if you want the algo to use this test
- 'lookback': Integer, value of how many lookback periods of the timeseries to be used in running the computation
- 'function': Function, this is the name of the function that will be called and return a value back to be compared to the _min and _max conditions below
- 'test_condition_min': Integer or Float, the minimum value returned by 'function' to determine if a trade can be triggered
- 'test_condition_max': Integer or Float, the maximum returned by 'function' to determine if a trade can be triggered
Support for Intraday Frequency
(Let me know if you run into issues with this, as I haven't done as much testing with it as I have with just daily freq)
You can configure this algo to be run on intraday minutely data as well. E.g. construct a pair spread using 15-min bar closing prices.
First, change the variable 'context.trade_freq' from 'daily' to 'intraday':
context.trade_freq = 'daily' # 'daily' or 'intraday'
Then, look for this code block below in the initialize() function, and specify the 'intraday_freq' value for the frequency of closing prices to use (E.g. 15 minute bars). Then, set 'run_trading_logic' to be how frequently you want the logic to be applied to market data. I chose 60 which means, run this logic every 60-minutes, but if you wish, change it to 1, and the logic will be run every single minute (beware though, as this will result in really long backtest times).
The variable 'check_exit_every_minute' can be set to True if you want the logic to be run every minute if-and-only-if you are currently in a trade. E.g. it checks to see whether you need to exit the trade every minute rather than waiting to the next N periods (e.g. 60 minutes, as specified in the 'run_trading_logic_freq' variable)
### START: INTRADAY FREQUENCY PARAMETERS
context.intraday_freq = 15 # only used if context.trade_freq='intraday'
context.run_trading_logic_freq = 60 # only used if context.trade_freq='intraday'
context.check_exit_every_minute = False # if True, and if in a trade, will check every minute whether to exit
### END: INTRADAY FREQUENCY PARAMETERS