Updated on: 2022-12-25

1 Introduction

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.

2 Packages Required

library(tidyquant) # Loads quantmod and PerformanceAnalytics packages
library(tidyverse) # Loads dplyr and ggplot2 packages (data manipulation and plotting)

3 Methodology and Data

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:

  1. Estimate the beta using past 30 days of data
  2. Estimate the required rate of return using the CAPM formula, where \(R_M\) is the average daily log-return of the market index in the period used to estimate the beta and \(R_f\) is the average daily return of the 1-month T-Bill
  3. Estimate the expected rate of return, which is based on a naive method of using the average actual daily log-return of the stock during the estimation period
  4. Compare the expected return to the required return

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)
startdate <- as.Date("2017-12-31")
enddate <- as.Date("2022-11-01")

spy <- quantmod::getSymbols(Symbols = "SPY", src = "yahoo", auto.assign = FALSE,
                            from = startdate, to = enddate, periodicity = "daily")

aapl <- quantmod::getSymbols(Symbols = "AAPL", src = "yahoo", auto.assign = FALSE,
                            from = startdate, to = enddate, periodicity = "daily")
# Retrieving data from FRED automatically retrieves the whole series available
US1M <- quantmod::getSymbols(Symbols = "DGS1MO", src = "FRED", auto.assign = FALSE)

rfr <- US1M["2017-12-31/2022-10-31"]
# Merge closing prices of AAPL and SPY with risk-free rate
dat <- merge(Cl(aapl), Cl(spy), rfr, all = FALSE)

# 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
dat$RFR <- na.locf(dat$RFR)/252

# 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
dat <- cbind(dat, 
             PerformanceAnalytics::Return.calculate(prices = dat$AAPL, method = "log") * 100, 
             PerformanceAnalytics::Return.calculate(prices = dat$SPY, method = "log") * 100) %>%
  `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

4 Estimating the Rolling-Window CAPM

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().

dat$beta <- rollapply(data = dat[-1, 4:5], width = 30, by.column = F, 
                      function(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:
dat$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)

5 Implementing the Trading Strategy

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
dat2 <- na.omit(merge(dat[,c("req.return","exp.return")], Op(aapl)))

# Include one-period lag to indicate buy/sell on the next period
dat2$signal <- Lag(dat2$exp.return > dat2$req.return & dat2$req.return > 0)

# Create a position column
dat2$position <- 0

for (i in 2:nrow(dat2)) {
  if (i == 2 & dat2$signal[i] == 1) {
    dat2$position[i] <- 1
  } else {
    dat2$position[i] <- 0
  }
  
  if (i > 2 & dat2$signal[i] == 1) {
    if (dat2$signal[i-1] == 0) {
      dat2$position[i] <- 1
    } else {
      dat2$position[i] <- 0
    }
  }
  
  if (i > 2 & dat2$signal[i] == 0) {
    if (dat2$signal[i-1] == 1) {
      dat2$position[i] <- -1
    } else {
      dat2$position[i] <- 0
    }
  }
}

# Remove the missing value due to lagging the signal by one period
dat2 <- na.omit(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
portfolio_value <- xts(matrix(nrow = nrow(dat2), ncol = 2, dimnames = list(index(dat2), c("Stock.Value", "Cash"))), order.by = index(dat2))

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
    portfolio_value$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
    
  } else if (i == 1 & dat2$position[i] == 0) {
    
    portfolio_value$Stock.Value[i] <- 0
    
    portfolio_value$Cash[i] <- 10000
    
  } else if (i > 1 & dat2$position[i] == 1) {
    
    portfolio_value$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
    
  } else if (i > 1 & dat2$position[i] == -1) {
    
    portfolio_value$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
    
  } else if (i > 1 & dat2$position[i] == 0 & dat2$signal[i] == 1) {
    
    portfolio_value$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]
    
  } else {
    
    portfolio_value$Stock.Value[i] <- 0
    
    portfolio_value$Cash[i] <- portfolio_value$Cash[i-1]
    
  }
}

plot(portfolio_value$Stock.Value + portfolio_value$Cash, 
     main = "Cumulative Return from CAPM Trading Strategy",
     grid.col = NA)

6 Summary and Final Remarks

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.

return_plot <- merge(10000*(exp(cumsum(na.omit(Return.calculate(prices = Ad(aapl), method = "log"))))), 
                     portfolio_value$Stock.Value + portfolio_value$Cash, 
                     10000*(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)

return_dat <- merge(Return.calculate(prices = Ad(aapl), method = "log"), 
                    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.