Is ‘risk’ rewarded in the equity markets?

This post looks to examine if the well known phrase “the higher the risk the higher the reward” applies to the FTSE 100 constituents. Numerous models have tried to capture risk reward metrics, the best known is the Capital Allocation Pricing Model (CAPM). CAPM tries to quantify the return on an investment an investor must receive in order to be adequately compensated for the risk they’ve taken.

The code below calculates the rolling standard deviation of returns, ‘the risk’, for the FTSE 100 constituents. It then groups stocks into quartiles by this risk metric, the groups are updated daily. Quartile 1 is the lowest volatility stocks, quartile 2 the highest. An equally weighted ($ amt) index is created for each quartile. According to the above theory Q4 (high vol) should produce the highest cumulative returns.

When using a 1 month lookback for the stdev calculation there is a clear winning index, the lowest vol index (black). Interestingly the 2nd best index is the highest vol index (blue). The graph above is calculated using arithmetic returns.

When using a longer lookback of 250 days, a trading year, the highest vol index is the best performer and the lowest vol index the worst performer.

For short lookback (30days) low vol index was the best performer

For long lookback (250days) high vol index was the best performer

One possible explanation (untested) is that for a short lookback the volatility risk metric is more sensitive to moves in the stock and hence on a news announcement / earnings the stock has a higher likelihood of moving from it’s current index into a higher vol index. Perhaps it isn’t unreasonable to assume that the high vol index contains only the stocks that have had a recent announcement / temporary volatility and are in a period of consolidation or mean reversion. Or to put it another way for short lookbacks the high vol index doesn’t contain the stocks that are permanently highly vol, whereas for long lookbacks any temporary vol deviations are smoothed out.

Below are the same charts as above but for geometric returns.

On to the code:

?View Code RSPLUS
library("quantmod")
library("PerformanceAnalytics")
library("zoo")
 
#Script parameters
symbolLst <- c("ADN.L","ADM.L","AGK.L","AMEC.L","AAL.L","ANTO.L","ARM.L","ASHM.L","ABF.L","AZN.L","AV.L","BA.L","BARC.L","BG.L","BLT.L","BP.L","BATS.L","BLND.L","BSY.L","BNZL.L","BRBY.L","CSCG.L","CPI.L","CCL.L","CNA.L","CPG.L","CRH.L","CRDA.L","DGE.L","ENRC.L","EXPN.L","FRES.L","GFS.L","GKN.L","GSK.L","HMSO.L","HL.L","HSBA.L","IAP.L","IMI.L","IMT.L","IHG.L","IAG.L","IPR.L","ITRK.L","ITV.L","JMAT.L","KAZ.L","KGF.L","LAND.L","LGEN.L","LLOY.L","EMG.L","MKS.L","MGGT.L","MRW.L","NG.L","NXT.L","OML.L","PSON.L","PFC.L","PRU.L","RRS.L","RB.L","REL.L","RSL.L","REX.L","RIO.L","RR.L","RBS.L","RDSA.L","RSA.L","SAB.L","SGE.L","SBRY.L","SDR.L","SRP.L","SVT.L","SHP.L","SN.L","SMIN.L","SSE.L","STAN.L","SL.L","TATE.L","TSCO.L","TLW.L","ULVR.L","UU.L","VED.L","VOD.L","WEIR.L","WTB.L","WOS.L","WPP.L","XTA.L")
#Specify dates for downloading data
startDate = as.Date("2000-01-01") #Specify what date to get the prices from
symbolData <- new.env() #Make a new environment for quantmod to store data in
clClRet <- new.env()
downloadedSymbols <- list()
for(i in 1:length(symbolLst)){
  #Download one stock at a time
  print(paste(i,"/",length(symbolLst),"Downloading",symbolLst[i]))
  tryCatch({
    getSymbols(symbolLst[i], env = symbolData, src = "yahoo", from = startDate)
     cleanName <- sub("^","",symbolLst[i],fixed=TRUE)
     mktData <- get(cleanName,symbolData)
     print(paste("-Calculating close close returns for:",cleanName))
      ret <-(Cl(mktData)/Lag(Cl(mktData)))-1
      if(max(abs(ret),na.rm=TRUE)>0.5){
      print("-There is a abs(return) > 50% the data is odd lets not use this stock")
      next;
      }
      downloadedSymbols <- c(downloadedSymbols,symbolLst[i])
 
      assign(cleanName,ret,envir = clClRet)
    }, error = function(e) {
    print(paste("Couldn't download: ", symbolLst[i]))
    })
 
 
}
 
 
#Combine all the returns into a zoo object (joins the returns by date)
#Not a big fan of this loop, think it's suboptimal
zooClClRet <- zoo()
for(i in 1:length(downloadedSymbols)){
  cleanName <- sub("^","",downloadedSymbols[i],fixed=TRUE)
  print(paste("Combining the close close returns to the zoo:",cleanName))
  if(length(zooClClRet)==0){
    zooClClRet <- as.zoo(get(cleanName,clClRet))
  } else {
    zooClClRet <- merge(zooClClRet,as.zoo(get(cleanName,clClRet)))
  }
}
print(head(zooClClRet))
 
 
#This will take inzoo or data frame
#And convert each row into quantiles
#Quantile 1 = 0-0.25
#Quantile 2 = 0.25-0.5 etc...
quasiQuantileFunction <- function(dataIn){
    quantileFun <- function(rowIn){
        quant <- quantile(rowIn,na.rm=TRUE)
        #print(quant)
        a <- (rowIn<=quant[5])
        b <- (rowIn<=quant[4])
        c <- (rowIn<=quant[3])
        d <- (rowIn<=quant[2])
        rowIn[a] <- 4
        rowIn[b] <- 3
        rowIn[c] <- 2
        rowIn[d] <- 1
        return(rowIn)
    }
 
  return (apply(dataIn,2,quantileFun))
}
 
avgReturnPerQuantile <- function(returnsData,quantileData){
      q1index <- (clClQuantiles==1)
      q2index <- (clClQuantiles==2)
      q3index <- (clClQuantiles==3)
      q4index <- (clClQuantiles==4)
 
      q1dat <- returnsData
      q1dat[!q1index] <- NaN
      q2dat <- returnsData
      q2dat[!q2index] <- NaN
      q3dat <- returnsData
      q3dat[!q3index] <- NaN
      q4dat <- returnsData
      q4dat[!q4index] <- NaN
 
      avgFunc <- function(x) {
           #apply(x,1,median,na.rm=TRUE) #median is more resistant to outliers
            apply(x,1,mean,na.rm=TRUE)
      }
      res <- returnsData[,1:4] #just to maintain the time series (there must be a better way)
      res[,1] <- avgFunc(q1dat)
      res[,2] <- avgFunc(q2dat)
      res[,3] <- avgFunc(q3dat)
      res[,4] <- avgFunc(q4dat)
 
      colnames(res) <- c("Q1","Q2","Q3","Q4")
      return(res)
}
 
nLookback <- 250 #~1year trading calendar
clClVol <- rollapply(zooClClRet,nLookback,sd,na.rm=TRUE)
clClQuantiles <- quasiQuantileFunction(clClVol)
returnPerVolQuantile <- avgReturnPerQuantile(zooClClRet,clClQuantiles)
colnames(returnPerVolQuantile) <- c("Q1 min vol","Q2","Q3","Q4 max vol")
returnPerVolQuantile[is.nan(returnPerVolQuantile)]<-0 #Assume if there is no return data that it's return is 0
#returnPerVolQuantile[returnPerVolQuantile>0.2] <- 0 #I was having data issues leading to days with 150% returns! This filters them out
cumulativeReturnsByQuantile <- apply(returnPerVolQuantile,2,cumsum)
dev.new()
charts.PerformanceSummary(returnPerVolQuantile,main=paste("Arithmetic Cumulative Returns per Vol Quantile - Lookback=",nLookback),geometric=FALSE)
print(table.Stats(returnPerVolQuantile))
cat("Sharpe Ratio")
print(SharpeRatio.annualized(returnPerVolQuantile))
 
dev.new()
par(oma=c(0,0,2,0))
par(mfrow=c(3,3))
 
for(i in seq(2012,2004,-1)){
print(as.Date(paste(i,"-01-01",sep="")))
print(as.Date(paste(i+1,"-01-01",sep="")))
  windowedData <- window(as.zoo(returnPerVolQuantile),start=as.Date(paste(i,"-01-01",sep="")),end=as.Date(paste(i+1,"-01-01",sep="")))
  chart.CumReturns(windowedData,main=paste("Year",i,"to",i+1),geometric=FALSE)
}
title(main=paste("Arithmetic Cumulative Returns per Vol Quantile - Lookback=",nLookback),outer=T)
 
dev.new()
charts.PerformanceSummary(returnPerVolQuantile,main=paste("Geometric Cumulative Returns per Vol Quantile - Lookback=",nLookback),geometric=TRUE)
print(table.Stats(returnPerVolQuantile))
cat("Sharpe Ratio")
print(SharpeRatio.annualized(returnPerVolQuantile))
 
dev.new()
par(oma=c(0,0,2,0))
par(mfrow=c(3,3))
 
for(i in seq(2012,2004,-1)){
print(as.Date(paste(i,"-01-01",sep="")))
print(as.Date(paste(i+1,"-01-01",sep="")))
  windowedData <- window(as.zoo(returnPerVolQuantile),start=as.Date(paste(i,"-01-01",sep="")),end=as.Date(paste(i+1,"-01-01",sep="")))
  chart.CumReturns(windowedData,main=paste("Year",i,"to",i+1),geometric=TRUE)
}
title(main=paste("Geometric Cumulative Returns per Vol Quantile - Lookback=",nLookback),outer=T)