One of the most powerful forces that influence stock prices is momentum. As stocks reach or hit new highs, investors gravitate to the popular stock, increasing buying pressure, causing the stock price to rise even higher. This effect is particularly noticeable in growth stocks where a level of exponential momentum is already expected.
There are a number of trading strategies that use the 52-week high of a stock price as a momentum indicator. Many of these look at whether a stock is reaching 80% of it’s 52-week high indicating renewed buying pressure. An interesting trading strategy is to examine the 52-week high and low in relation to the current stock price to identify positive momentum. The belief is that stocks that score 82% or better in this formula tend to outperform the market.
The formula is relatively simple: (Current Stock Price - 52-Week Low) / (52-Week High - 52-Week Low)
The formula provides the 52-Week price range and the relative placement of the current stock price within that range.
This project will be a simple test for momentum using a 52-Week Price Range strategy with a threshold of 82%. There are 251 trading days in a calendar year so we will use that number as our range for determining the 52-Week High and Low. A 3 month return on the stock will be calculated along with an optimal return which identifies the highest return possible within the 3 month period.
The source data will be the backtest environment created in the previous project, Build Backtest Environment. Recall that the environment contains historical pricing data from 2007-2016. We also have a symbols table that contains all of the stocks that were successfully loaded in the environment.
As before, we will be using the quantmod
package to retrieve data from the backtest environment.
Libraries Required
library(quantmod) # Quantitative financial strategies
library(dplyr) # Data manipulation
library(lubridate) # Date and time processing
library(knitr) # Dynamic report generation
Working directory
setwd("U:/Equity Backtesting")
We will require the two files previously created in order to perform the backtest. To access the environment we created we simply need to load
the environment file. The symbols table is in the form of an .rds.
Load Backtest Environment
load("Environments/bt_env_2007_2016.Rdata")
Read Backtest Symbols Table
bt_env_symbols <- readRDS("Symbols/bt_env_symbols.rds")
We can check to make sure that the data is loaded correctly and contains expected values. This time we can look for symbols that start with “VM”.
Verify Backtest Environment
ls(bt_env_2007_2016, pattern = "^VM")
## [1] "VMC" "VMI" "VMW"
Verify Data in Backtest Environment
kable(head(bt_env_2007_2016$VMW, 10))
VMW.Open | VMW.High | VMW.Low | VMW.Close | VMW.Volume | VMW.Adjusted |
---|---|---|---|---|---|
52.11 | 59.87 | 51.50 | 57.71 | 10678500 | 57.71 |
60.99 | 61.49 | 52.71 | 56.99 | 6919500 | 56.99 |
59.00 | 59.00 | 54.45 | 55.55 | 3086100 | 55.55 |
56.05 | 57.50 | 55.61 | 57.33 | 2140900 | 57.33 |
57.25 | 66.59 | 56.50 | 65.99 | 7369700 | 65.99 |
70.18 | 73.95 | 66.56 | 66.85 | 10144400 | 66.85 |
68.07 | 70.88 | 65.01 | 70.20 | 4347000 | 70.20 |
70.20 | 72.65 | 69.45 | 71.30 | 3812400 | 71.30 |
71.73 | 73.35 | 71.01 | 72.30 | 2393300 | 72.30 |
71.90 | 72.10 | 66.50 | 69.78 | 3664100 | 69.78 |
Verify Backtest Symbols
kable(head(bt_env_symbols, 10))
Symbol | Sector | Industry | Name | Exchange |
---|---|---|---|---|
AAAP | Health Care | Major Pharmaceuticals | Advanced Accelerator Applications S.A. | NASDAQ |
AAL | Transportation | Air Freight/Delivery Services | American Airlines Group, Inc. | NASDAQ |
AAME | Finance | Life Insurance | Atlantic American Corporation | NASDAQ |
AAOI | Technology | Semiconductors | Applied Optoelectronics, Inc. | NASDAQ |
AAON | Capital Goods | Industrial Machinery/Components | AAON, Inc. | NASDAQ |
AAPC | Consumer Services | Services-Misc. Amusement & Recreation | Atlantic Alliance Partnership Corp. | NASDAQ |
AAPL | Technology | Computer Manufacturing | Apple Inc. | NASDAQ |
AAWW | Transportation | Transportation Services | Atlas Air Worldwide Holdings | NASDAQ |
ABAC | Consumer Non-Durables | Farming/Seeds/Milling | Aoxin Tianli Group, Inc. | NASDAQ |
ABAX | Capital Goods | Industrial Machinery/Components | ABAXIS, Inc. | NASDAQ |
## [1] "Number of Backtest Symbols: 4667"
The first step in the processing is to retrieve the desired symbol data from the backtest environment. This is accomplished using the following:
get(symbol_name, source_environment)
The next steps are to generate the 52-Week High and Low stock prices. We will use the runMax()
and runMin()
functions in quantmod
to determine the respective values using a range of 251 days to create an equitable period for all stocks.
Using the returned values, the 52-Week price range value will be calculated and a signal threshold of 82% will be set. All calculations will be made using the adjusted closing price.
We do need to determine if there are enough observations to perform the calculations so we will exclude any stocks that do not have 251 observation points from the analysis.
If the number of observations is sufficient for the analysis the results will be placed in a data frame and then processed. The processing consists of the following:
Date
field from the row namesYear
and Month
columns from Date
Year
and Month
Date
, Symbol
, and momentum signal columns of the data frameThe data is grouped and filtered to keep only one occurrence of the signal threshold in order to test the signal as a trigger for initiating a buy condition for the stock. The data frame created is then joined to a master data frame which will contain all occurrences of the signal threshold being met for the first time in a given month.
Create Symbol Array
bt_symbols <- bt_env_symbols$Symbol
Price Range Signal Gather Loop
j <- 1
for(i in 1:length(bt_symbols)) {
sym_sum <- get(bt_symbols[i], envir = bt_env_2007_2016)
if(nrow(sym_sum) >= 251) {
sym_sum <- data.frame(na.approx(sym_sum))
colnames(sym_sum) <- c("Open", "High", "Low", "Close", "Volume", "AdjClose")
sym_sum <- sym_sum %>%
mutate(Date = as.Date(rownames(.)),
Symbol = bt_symbols[i],
Hi_52wk = runMax(AdjClose, n = 251, cumulative = FALSE),
Lo_52wk = runMin(AdjClose, n = 251, cumulative = FALSE),
Mo_52wk = (AdjClose - Lo_52wk) / (Hi_52wk - Lo_52wk)) %>%
filter(Mo_52wk >= .82) %>%
mutate(Year = year(Date),
Mnth = month(Date)) %>%
group_by(Year, Mnth) %>%
slice(1L) %>%
ungroup() %>%
select(Symbol, Date, Mo_52wk)
if(j == 1) { mmt_frame <- sym_sum } else
{ mmt_frame <- bind_rows(mmt_frame, sym_sum) }
j <- j + 1
}
}
Symbol | Date | Mo_52wk |
---|---|---|
AAL | 2010-01-28 | 0.8342105 |
AAL | 2010-02-01 | 0.9552632 |
AAL | 2010-03-01 | 1.0000000 |
AAL | 2010-04-01 | 0.9069374 |
AAL | 2010-05-03 | 0.9052453 |
AAL | 2010-06-01 | 0.9720177 |
AAL | 2010-07-07 | 0.8367816 |
AAL | 2010-08-02 | 0.9663342 |
AAL | 2010-09-01 | 0.8312500 |
AAL | 2010-10-12 | 0.8300000 |
## [1] "Symbols meeting threshold: 4147"
The goal for the backtest is to determine if a 52-Week price range trigger of 82%, indicating a buy signal, is a potential predictor for positive returns based on the expectation of increased momentum and buying pressure for a stock. For this example, we will look at a 3 month return from the date of the trigger.
Using the backtest environment again, we will retrieve the adjusted closing prices beginning with the date of the trigger and ending 3 months after. If the trigger date is such that the calculated end data is greater than 12/30/2016, the last trading day of 2016 and the last date in our backtest environment, the recorded returns will be NA
.
During the 3 month testing window, it can be expected that the stock price will have volatility and that the starting and ending prices for the window may not reflect the low and high stock prices observed. To determine the potential for returns, we will also record the highest stock price during the window. It is possible that a 3 month return may show a negative value but there is an opportunity for a positive return within the window.
The momentum data frame will be updated with the following:
Add Fields to mmt_frame
mmt_frame <- mmt_frame %>%
mutate(Trigger_Cl = 0,
Period_Cl = 0,
Return_3mt = 0,
Period_Hi = 0,
Return_Hi = 0,
Volume_Min = 0,
Volume_Avg = 0)
Symbol | Date | Mo_52wk | Trigger_Cl | Period_Cl | Return_3mt | Period_Hi | Return_Hi | Volume_Min | Volume_Avg |
---|---|---|---|---|---|---|---|---|---|
AAL | 2010-01-28 | 0.8342105 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
AAL | 2010-02-01 | 0.9552632 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
AAL | 2010-03-01 | 1.0000000 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
AAL | 2010-04-01 | 0.9069374 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
AAL | 2010-05-03 | 0.9052453 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
Generating the returns requires a processing loop to retrieve the symbol and date of the trigger. A target end date is generated for the 3 month window. Each month, on average, contains 4.33 weeks. So a 3 month testing window will consist of 13 weeks.
If the calculated end date falls outside the range of the backtest environment, we simply set a value of NA
to the desired fields. If the date range is within the backtest environment range, we can process the record and retrieve/calculate the statistics. We will be using the adjusted close field for all of the data points.
Generate Returns
for(i in 1:nrow(mmt_frame)) {
Symbol <- mmt_frame$Symbol[i]
Beg_Date <- as.Date(mmt_frame$Date[i])
End_Date <- Beg_Date + dweeks(13)
if(End_Date > "2016-12-31") {
mmt_frame$Trigger_Cl[i] <- NA
mmt_frame$Period_Cl[i] <- NA
mmt_frame$Return_3mt[i] <- NA
mmt_frame$Period_Hi[i] <- NA
mmt_frame$Return_Hi[i] <- NA
mmt_frame$Volume_Min[i] <- NA
mmt_frame$Volume_Avg[i] <- NA
}
else {
sym_sum <- get(Symbol, envir = bt_env_2007_2016)
date_range <- paste(as.character(Beg_Date),"/",as.character(End_Date), sep = "")
sym_sum <- data.frame(sym_sum[date_range])
colnames(sym_sum) <- c("Open", "High", "Low", "Close", "Volume", "AdjClose")
mmt_frame$Trigger_Cl[i] <- as.numeric(sym_sum$AdjClose[1])
mmt_frame$Period_Cl[i] <- as.numeric(sym_sum$AdjClose[nrow(sym_sum)])
mmt_frame$Return_3mt[i] <- round((mmt_frame$Period_Cl[i] - mmt_frame$Trigger_Cl[i])
/ mmt_frame$Trigger_Cl[i], 3)
mmt_frame$Period_Hi[i] <- max(sym_sum$AdjClose)
mmt_frame$Return_Hi[i] <- round((mmt_frame$Period_Hi[i] - mmt_frame$Trigger_Cl[i])
/ mmt_frame$Trigger_Cl[i], 3)
mmt_frame$Volume_Min[i] <- min(sym_sum$Volume)
mmt_frame$Volume_Avg[i] <- round(mean(sym_sum$Volume),0)
}
}
Symbol | Date | Mo_52wk | Trigger_Cl | Period_Cl | Return_3mt | Period_Hi | Return_Hi | Volume_Min | Volume_Avg |
---|---|---|---|---|---|---|---|---|---|
AAL | 2010-01-28 | 0.8342105 | 5.14 | 7.33 | 0.426 | 7.95 | 0.547 | 2837000 | 11838827 |
AAL | 2010-02-01 | 0.9552632 | 5.60 | 7.39 | 0.320 | 7.95 | 0.420 | 2837000 | 11569186 |
AAL | 2010-03-01 | 1.0000000 | 7.69 | 8.83 | 0.148 | 8.83 | 0.148 | 2837000 | 11914875 |
AAL | 2010-04-01 | 0.9069374 | 7.40 | 8.65 | 0.169 | 10.74 | 0.451 | 3534900 | 13088125 |
AAL | 2010-05-03 | 0.9052453 | 7.39 | 10.64 | 0.440 | 10.91 | 0.476 | 5781100 | 11206086 |
AAL | 2010-06-01 | 0.9720177 | 8.64 | 9.04 | 0.046 | 10.91 | 0.263 | 3411600 | 9376537 |
AAL | 2010-07-07 | 0.8367816 | 9.32 | 9.23 | -0.010 | 10.91 | 0.171 | 3230200 | 7538443 |
AAL | 2010-08-02 | 0.9663342 | 10.64 | 11.66 | 0.096 | 12.00 | 0.128 | 2231500 | 6826200 |
AAL | 2010-09-01 | 0.8312500 | 9.56 | 11.26 | 0.178 | 12.07 | 0.263 | 2231500 | 5930728 |
AAL | 2010-10-12 | 0.8300000 | 9.55 | 10.97 | 0.149 | 12.07 | 0.264 | 1899200 | 5516156 |
Once the data has been retrieved and the desired information stored in the data frame we can filter to remove records that do not meet our acceptance criteria. We will define the rejection criteria as the following:
NA
values$5.00
$1,000
100,000
50,000
on trigger dateThe purpose of the acceptance criteria is to eliminate irregularly traded stocks which might otherwise skew the results.
Clean Momentum Frame
mmt_final <- mmt_frame %>%
filter(complete.cases(.),
Trigger_Cl >= 5,
Trigger_Cl < 1000,
Volume_Min > 50000,
Volume_Avg >= 100000)
Symbol | Date | Mo_52wk | Trigger_Cl | Period_Cl | Return_3mt | Period_Hi | Return_Hi | Volume_Min | Volume_Avg |
---|---|---|---|---|---|---|---|---|---|
AAL | 2010-01-28 | 0.8342105 | 5.14 | 7.33 | 0.426 | 7.95 | 0.547 | 2837000 | 11838827 |
AAL | 2010-02-01 | 0.9552632 | 5.60 | 7.39 | 0.320 | 7.95 | 0.420 | 2837000 | 11569186 |
AAL | 2010-03-01 | 1.0000000 | 7.69 | 8.83 | 0.148 | 8.83 | 0.148 | 2837000 | 11914875 |
AAL | 2010-04-01 | 0.9069374 | 7.40 | 8.65 | 0.169 | 10.74 | 0.451 | 3534900 | 13088125 |
AAL | 2010-05-03 | 0.9052453 | 7.39 | 10.64 | 0.440 | 10.91 | 0.476 | 5781100 | 11206086 |
AAL | 2010-06-01 | 0.9720177 | 8.64 | 9.04 | 0.046 | 10.91 | 0.263 | 3411600 | 9376537 |
AAL | 2010-07-07 | 0.8367816 | 9.32 | 9.23 | -0.010 | 10.91 | 0.171 | 3230200 | 7538443 |
AAL | 2010-08-02 | 0.9663342 | 10.64 | 11.66 | 0.096 | 12.00 | 0.128 | 2231500 | 6826200 |
AAL | 2010-09-01 | 0.8312500 | 9.56 | 11.26 | 0.178 | 12.07 | 0.263 | 2231500 | 5930728 |
AAL | 2010-10-12 | 0.8300000 | 9.55 | 10.97 | 0.149 | 12.07 | 0.264 | 1899200 | 5516156 |
## [1] "Total Remaining Records: 78937"
The first thing we will review is the overall 3 month totals based on the adjusted closing prices from the beginning and end of the test window.
The function, Three_Month_Stats()
, will generate the summary statistics for each of the final signal data frames and print the results.
3 Month Return Function
Three_Month_Stats <- function(temp_frame) {
pos_ret_3mt <- nrow(temp_frame %>%
filter(Return_3mt > 0))
neg_ret_3mt <- nrow(temp_frame %>%
filter(Return_3mt <= 0))
pos_pct_3mt <- round(pos_ret_3mt / nrow(temp_frame), 3)
avg_ret_3mt <- round(mean(temp_frame$Return_3mt), 3)
avg_pos_ret <- round(mean(temp_frame$Return_3mt[temp_frame$Return_3mt > 0]), 3)
avg_neg_ret <- round(mean(temp_frame$Return_3mt[temp_frame$Return_3mt <= 0]), 3)
print(paste(" Total Positive Returns:", pos_ret_3mt))
print(paste(" Total Negative Returns:", neg_ret_3mt))
print(paste("Percent Positive Returns:", pos_pct_3mt))
print(paste(" Average Return:", avg_ret_3mt))
print(paste(" Average Positive Return:", avg_pos_ret))
print(paste(" Average Negative Return:", avg_neg_ret))
}
3 Month Returns
Three_Month_Stats(mmt_final)
## [1] " Total Positive Returns: 44514"
## [1] " Total Negative Returns: 34423"
## [1] "Percent Positive Returns: 0.564"
## [1] " Average Return: 0.021"
## [1] " Average Positive Return: 0.124"
## [1] " Average Negative Return: -0.112"
The 52 Week Price Range signal resulted in an average return of 2.1%
with 56.4%
of observations resulting in a positive return at the end of the testing window.
Now we can look at whether there was a potential for higher returns by looking at the period high stock price observed within the testing period instead of using the testing window closing price.
The function, Three_Month_Period()
, will generate the summary statistics using the high stock price in the testing period to determine returns.
3 Month Period Returns
Three_Month_Period <- function(temp_frame) {
pos_ret_per <- nrow(temp_frame %>%
filter(Return_Hi > 0))
neg_ret_per <- nrow(temp_frame %>%
filter(Return_Hi <= 0))
pos_pct_per <- round(pos_ret_per / nrow(temp_frame), 3)
avg_ret_per <- round(mean(temp_frame$Return_Hi[temp_frame$Return_Hi > 0]), 3)
print(paste(" Total Positive Returns:", pos_ret_per))
print(paste(" Total Negative Returns:", neg_ret_per))
print(paste("Percent Positive Returns:", pos_pct_per))
print(paste(" Average Positive Return:", avg_ret_per))
}
3 Month Period Returns
Three_Month_Period(mmt_final)
## [1] " Total Positive Returns: 73461"
## [1] " Total Negative Returns: 5476"
## [1] "Percent Positive Returns: 0.931"
## [1] " Average Positive Return: 0.127"
The 52 Week Price Range signal yielded a potential average return for positive stocks of 12.7%
. This is a slight improvement on the same statistic observed when using the ending price to calculate returns. There is also an improvement in the percentage of positive returns to 93.1%
.
Of course, using the average return potentially creates a false expectation of returns because large outliers can skew the statistical result. So we can look at the positive returns broken out by percentages of occurence. This helps identify a potential threshold for returns within the 3 month window that could be used as an exit point for the stock. That is, if we employ a strategy that sought a 5% return from the entry point of the stock, triggered by the 52-Week Price Range threshold, what would be the percentage of investments that would yield the desired return?
52 Week Quantiles for Positive Period Returns
quantile(mmt_final$Return_Hi[mmt_final$Return_Hi > 0], prob = seq(0, 1, length = 11), type = 5)
## 0% 10% 20% 30% 40% 50% 60% 70% 80% 90% 100%
## 0.001 0.018 0.036 0.054 0.073 0.093 0.117 0.146 0.189 0.265 5.722
More than 70%
of all observations had a 5%
return or better. And around 45%
had a return of 10%
or better.
Finally, let’s look at the average positive returns by the price range signal using three levels:
52 Week Signal: 82-88%
round(mean(mmt_final$Return_Hi[mmt_final$Return_Hi > 0 &
mmt_final$Mo_52wk < .88]), 3)
## [1] 0.13
There is a 13.0%
return for stocks meeting the threshold range between 82% and 88%.
52 Week Signal: 88-94%
round(mean(mmt_final$Return_Hi[mmt_final$Return_Hi > 0 &
mmt_final$Mo_52wk >= .88 &
mmt_final$Mo_52wk < .94]), 3)
## [1] 0.129
There is a 12.9%
return for stocks meeting the threshold range between 88% and 94%.
52 Week Signal: 94-100%
round(mean(mmt_final$Return_Hi[mmt_final$Return_Hi > 0 &
mmt_final$Mo_52wk >= .94]), 3)
## [1] 0.122
There is a 12.2%
return for stocks meeting the threshold range between 94% and 100%.
For the case of the 52-Week Price Range trigger, it appears that the lower range of the trigger values generates the highest potential for returns. This makes sense in that the higher values are already closer to the 52-Week high and would have, in theory, less room to realize gains as the 52-Week high acts as a potential barrier.
Overall, using the price range formula does result in a large percentage of potential gains and a reasonable expectation of returns. As before, using the information from the backtest symbol table, a comparison can be made across industries and sectors to determine if there are differences worth exploring.
Another interesting possibility would be to shorten the range from 52-Weeks to 26-Weeks to capture shorter term momentum swings.
Lastly, since this type of momentum strategy is particularly useful to growth stocks, restricting the sampling to those stocks with positive Earnings Per Share growth seems appropriate to test.
sessionInfo()
## R version 3.4.0 (2017-04-21)
## Platform: x86_64-w64-mingw32/x64 (64-bit)
## Running under: Windows 10 x64 (build 15063)
##
## Matrix products: default
##
## locale:
## [1] LC_COLLATE=English_United States.1252
## [2] LC_CTYPE=English_United States.1252
## [3] LC_MONETARY=English_United States.1252
## [4] LC_NUMERIC=C
## [5] LC_TIME=English_United States.1252
##
## attached base packages:
## [1] stats graphics grDevices utils datasets methods base
##
## other attached packages:
## [1] knitr_1.16 lubridate_1.6.0 dplyr_0.5.0 quantmod_0.4-9
## [5] TTR_0.23-1 xts_0.9-7 zoo_1.8-0
##
## loaded via a namespace (and not attached):
## [1] Rcpp_0.12.11 magrittr_1.5 lattice_0.20-35 R6_2.2.1
## [5] rlang_0.1.1 stringr_1.2.0 highr_0.6 tools_3.4.0
## [9] grid_3.4.0 DBI_0.6-1 htmltools_0.3.6 lazyeval_0.2.0
## [13] yaml_2.1.14 rprojroot_1.2 digest_0.6.12 assertthat_0.2.0
## [17] tibble_1.3.3 evaluate_0.10 rmarkdown_1.5 stringi_1.1.5
## [21] compiler_3.4.0 backports_1.1.0