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:
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) |