Updated on: 2022-12-25
This project aims to test if the Capital Asset Pricing Model (CAPM) estimated using a rolling window could allow an investor or trader to take advantage of inefficiencies in market pricing. I used a short-term modeling strategy with 30 trading days of historical data per estimation to capture the short-term pricing inefficiencies. It does not conclude if the strategy is effective for all stocks, but applying the CAPM trading strategy on Apple Inc. was able to yield a better risk-return payoff as measured by the Sharpe Ratio. The trading strategy had outperformed the S&P 500 ETF over the years but it could be attributed to having chosen a stock that had outperformed the market significantly during the back-testing period.
library(tidyquant) # Loads quantmod and PerformanceAnalytics packages
library(tidyverse) # Loads dplyr and ggplot2 packages (data manipulation and plotting)
Using the daily returns of Apple Inc. (ticker: AAPL) from January 2018 to October 2022, I estimated the CAPM with a 30-day rolling window. I used the SPDR S&P 500 ETF (ticker: SPY) as a proxy for the market return and the market yield on the 1-month US Treasury security (FRED: DGS1MO) as a proxy for the risk-free rate, which would be divided by 252 to obtain the simple daily rate (yield is stated on an annualized rate). The stock prices are obtained from Yahoo Finance, while the risk-free rate is obtained from the Federal Reserve of St. Louis FRED database. Stock returns are calculated using \[r_t = \ln \bigg( \frac{P_t}{P_{t-1}} \bigg) = \ln(P_t) - \ln(P_{t-1})\]
There are 2 ways to estimate the beta of the stock. The first way is
to regress the stock returns on the market returns to obtain the
coefficient describing the relationship between these two variables, or
the slope of the linear equation, which is the beta of the stock. The
second way is to find the covariance of the market return and the stock
return and divide it by the variance of the market return, which can be
calculated simply by using
PerformanceAnalytics::CAPM.beta()
.
The steps to obtain a trading strategy based on the CAPM is as follows:
If expected return is more than required return, the stock is undervalued and I would take a long position on the stock on the next trading day at the opening price. If the expected return is less than the required return, the stock is overvalued and I would sell the long position (or take a short position for sophisticated traders) on the next trading day. Another point to note, I would not hold a position in the stock if the required return calculated by CAPM is negative. I assumed that trades can be filled at any point in time and a 0.5% transaction cost (on both buy and sell transactions).
I used quantmod::getSymbols()
to obtain
historical prices of AAPL and SPY, which can also be used to obtain the
market yield of the 1-month US T-Bill from the FRED database.
# Set start and end date for data retrieval (does not work when retrieving data from FRED)
<- as.Date("2017-12-31")
startdate <- as.Date("2022-11-01")
enddate
<- quantmod::getSymbols(Symbols = "SPY", src = "yahoo", auto.assign = FALSE,
spy from = startdate, to = enddate, periodicity = "daily")
<- quantmod::getSymbols(Symbols = "AAPL", src = "yahoo", auto.assign = FALSE,
aapl from = startdate, to = enddate, periodicity = "daily")
# Retrieving data from FRED automatically retrieves the whole series available
<- quantmod::getSymbols(Symbols = "DGS1MO", src = "FRED", auto.assign = FALSE)
US1M
<- US1M["2017-12-31/2022-10-31"] rfr
# Merge closing prices of AAPL and SPY with risk-free rate
<- merge(Cl(aapl), Cl(spy), rfr, all = FALSE)
dat
# Rename columns
colnames(dat) <- c("AAPL", "SPY", "RFR")
# Check for missing data
colSums(is.na(dat))
## AAPL SPY RFR
## 0 0 9
# Replace NA in rfr with last observation and convert to daily rate by dividing 252
$RFR <- na.locf(dat$RFR)/252
dat
# Preview data
head(dat)
## AAPL SPY RFR
## 2018-01-02 43.0650 268.77 0.005119048
## 2018-01-03 43.0575 270.47 0.005119048
## 2018-01-04 43.2575 271.61 0.005079365
## 2018-01-05 43.7500 273.42 0.005039683
## 2018-01-08 43.5875 273.92 0.005158730
## 2018-01-09 43.5825 274.54 0.005039683
# Calculate log returns for AAPL and SPY using PerformanceAnalytics::Return.calculate()
# Convert returns to percentage
<- cbind(dat,
dat ::Return.calculate(prices = dat$AAPL, method = "log") * 100,
PerformanceAnalytics::Return.calculate(prices = dat$SPY, method = "log") * 100) %>%
PerformanceAnalytics`colnames<-`(c(colnames(dat), "rAAPL", "rSPY"))
head(dat)
## AAPL SPY RFR rAAPL rSPY
## 2018-01-02 43.0650 268.77 0.005119048 NA NA
## 2018-01-03 43.0575 270.47 0.005119048 -0.01741705 0.6305236
## 2018-01-04 43.2575 271.61 0.005079365 0.46342202 0.4205969
## 2018-01-05 43.7500 273.42 0.005039683 1.13209841 0.6641963
## 2018-01-08 43.5875 273.92 0.005158730 -0.37211549 0.1827018
## 2018-01-09 43.5825 274.54 0.005039683 -0.01147643 0.2260862
The beta can be estimated with
PerformanceAnalytics::CAPM.beta()
and
rollapply()
using the past 30
observations. Proxy for the expected return can also be calculated with
rollapply()
.
$beta <- rollapply(data = dat[-1, 4:5], width = 30, by.column = F,
datfunction(x) CAPM.beta(Ra = x[,1], Rb = x[,2]))
# Calculate the required return for AAPL based on CAPM each day, after obtaining the average market return and risk-free rate:
$mean.RFR <- SMA(x = dat$RFR[-1], n = 30)
dat
$mean.RM <- SMA(x = dat$rSPY[-1], n = 30)
dat
$req.return <- dat$mean.RFR + dat$beta * (dat$mean.RM - dat$mean.RFR)
dat
$exp.return <- SMA(x = dat$rAAPL[-1], n = 30) dat
As mentioned in Section 3, if the expected return is higher than required return, I would long AAPL the next trading day at the opening price, and if it is lower, I would sell the long position at the opening price the next trading day.
This would be denoted by “1” when the CAPM trading strategy indicated a buy signal, and “0” when there is a sell signal and would be recorded under the signal column. I created another column indicating the position which would equal “1” when there is a buy, “-1” when there is a sell and “0” otherwise. (I have hidden the code, but you may un-hide it by clicking the “Code” button to view the full code.)
# Create a new xts object saving only the needed columns of data
<- na.omit(merge(dat[,c("req.return","exp.return")], Op(aapl)))
dat2
# Include one-period lag to indicate buy/sell on the next period
$signal <- Lag(dat2$exp.return > dat2$req.return & dat2$req.return > 0)
dat2
# Create a position column
$position <- 0
dat2
for (i in 2:nrow(dat2)) {
if (i == 2 & dat2$signal[i] == 1) {
$position[i] <- 1
dat2else {
} $position[i] <- 0
dat2
}
if (i > 2 & dat2$signal[i] == 1) {
if (dat2$signal[i-1] == 0) {
$position[i] <- 1
dat2else {
} $position[i] <- 0
dat2
}
}
if (i > 2 & dat2$signal[i] == 0) {
if (dat2$signal[i-1] == 1) {
$position[i] <- -1
dat2else {
} $position[i] <- 0
dat2
}
}
}
# Remove the missing value due to lagging the signal by one period
<- na.omit(dat2)
dat2
head(dat2)
## req.return exp.return AAPL.Open signal position
## 2018-02-15 0.032900923 0.01467681 42.4475 0 0
## 2018-02-16 0.019104631 -0.01157893 43.0900 0 0
## 2018-02-20 -0.026141361 -0.06054641 43.0125 0 0
## 2018-02-21 -0.050003116 -0.06330648 43.2075 0 0
## 2018-02-22 -0.053572249 -0.03517606 42.9500 0 0
## 2018-02-23 0.007266464 0.02306170 43.4175 0 0
I calculated the portfolio value as the sum of the stock value and the cash value. Stock value is obtained by multiplying a number of stocks with the opening price and cash value is the remaining amount after the stock is bought or sold (I have hidden the code, but you may un-hide it by clicking the “Code” button to view the full code.)
# Assume that I started with $10,000 capital and buy as many shares as possible with
# the portfolio value available
# Also assume a 0.5% buy/sell transaction cost
<- xts(matrix(nrow = nrow(dat2), ncol = 2, dimnames = list(index(dat2), c("Stock.Value", "Cash"))), order.by = index(dat2))
portfolio_value
for (i in seq(nrow(dat2))) {
if (i == 1 & dat2$position[i] == 1) {
# Apply 0.5% to stock price when calculating number of shares to buy to ensure that
# there is cash available to pay the transaction cost
$Stock.Value[i] <- floor(10000 / (dat2$AAPL.Open[i] * 1.005)) * dat2$AAPL.Open[i]
portfolio_value
$Cash[i] <- 10000 - floor(10000 / (dat2$AAPL.Open[i] * 1.005)) * dat2$AAPL.Open[i] * 1.005
portfolio_value
else if (i == 1 & dat2$position[i] == 0) {
}
$Stock.Value[i] <- 0
portfolio_value
$Cash[i] <- 10000
portfolio_value
else if (i > 1 & dat2$position[i] == 1) {
}
$Stock.Value[i] <- floor(as.vector(portfolio_value$Cash)[i-1] / (as.vector(dat2$AAPL.Open)[i] * 1.005)) * as.vector(dat2$AAPL.Open)[i]
portfolio_value
$Cash[i] <- as.vector(portfolio_value$Cash)[i-1] - floor(as.vector(portfolio_value$Cash)[i-1] / (as.vector(dat2$AAPL.Open)[i] * 1.005)) * dat2$AAPL.Open[i] * 1.005
portfolio_value
else if (i > 1 & dat2$position[i] == -1) {
}
$Stock.Value[i] <- 0
portfolio_value
$Cash[i] <- as.vector(portfolio_value$Cash)[i-1] + as.vector(portfolio_value$Stock.Value)[i-1] * 0.995
portfolio_value
else if (i > 1 & dat2$position[i] == 0 & dat2$signal[i] == 1) {
}
$Stock.Value[i] <- as.vector(portfolio_value$Stock.Value)[i-1] / as.vector(dat2$AAPL.Open)[i-1] * dat2$AAPL.Open[i]
portfolio_value
$Cash[i] <- portfolio_value$Cash[i-1]
portfolio_value
else {
}
$Stock.Value[i] <- 0
portfolio_value
$Cash[i] <- portfolio_value$Cash[i-1]
portfolio_value
}
}
plot(portfolio_value$Stock.Value + portfolio_value$Cash,
main = "Cumulative Return from CAPM Trading Strategy",
grid.col = NA)
To summarize, I plotted the trading returns together with the actual returns from AAPL on a buy-and-hold strategy and the returns from investing in the S&P 500 ETF and obtained the annualized return and standard deviation to compare the performance of the trading strategy.
<- merge(10000*(exp(cumsum(na.omit(Return.calculate(prices = Ad(aapl), method = "log"))))),
return_plot $Stock.Value + portfolio_value$Cash,
portfolio_value10000*(exp(cumsum(na.omit(Return.calculate(prices = Ad(spy), method = "log"))))))
colnames(return_plot) <- c("Buy-and-Hold", "CAPM Trading Strategy", "S&P 500 ETF")
plot(return_plot,
main = "Comparison of Cumulative Returns",
legend.loc = "topleft",
grid.col = NA)
<- merge(Return.calculate(prices = Ad(aapl), method = "log"),
return_dat Return.calculate(prices = portfolio_value$Stock.Value + portfolio_value$Cash, method = "log"),
Return.calculate(prices = Ad(spy), method = "log"))
colnames(return_dat) <- c("Buy-and-Hold", "CAPM Trading Strategy", "S&P 500 ETF")
table.AnnualizedReturns(R = return_dat, scale = 252, geometric = FALSE)
The CAPM trading strategy had lower cumulative returns but higher reward-to-risk payoff as measured by the Sharpe ratio than the buy-and-hold strategy. The strategy applied on AAPL had also outperformed the SPY over the 5 year period, but this may be due to having selected a stock that had significantly outperformed the market over the years.
The under-performance of the trading strategy from the mid-2020 to the end of the back-testing period could be due to an overvalued stock as indicated by CAPM or due to the price volatility which was not well-captured by the simple buy and sell criteria.
In the future, I would test a different asset pricing model under the same conditions to see if the trading performance can be improved.