Review of Momentum and Markowitz A Golden Combination paper
04 Jun 2015
To install Systematic Investor Toolbox (SIT) please visit About page.
The Momentum and Markowitz: A Golden Combination (2015) by Keller, Butler, Kipnis
paper is a review of practitioner’s tools to make mean variance optimization
portfolio a viable solution. In particular, authors suggest and test:
adding maximum weight limits and
adding target volatility constraint
to control solution of mean variance optimization .
Below I will have a look at the results for the 8 asset universe:
S&P 500
EAFE
Emerging Markets
US Technology Sector
Japanese Equities
10-Year Treasuries
T-Bills
High Yield Bonds
First, let’s load historical data for all assets
#*****************************************************************
# Load historical data
#*****************************************************************
library ( SIT )
load.packages ( 'quantmod' )
# load saved Proxies Raw Data, data.proxy.raw
# please see http://systematicinvestor.github.io/Data-Proxy/ for more details
load ( 'data/data.proxy.raw.Rdata' )
N8.tickers = '
US.EQ = VTI + VTSMX + VFINX
EAFE = EFA + VDMIX + VGTSX
EMER.EQ = EEM + VEIEX
TECH.EQ = QQQ + ^NDX
JAPAN.EQ = EWJ + FJPNX
MID.TR = IEF + VFITX
US.CASH = BIL + TB3M,
US.HY = HYG + VWEHX
'
data = env ()
getSymbols.extra ( N8.tickers , src = 'yahoo' , from = '1970-01-01' , env = data , raw.data = data.proxy.raw , set.symbolnames = T , auto.assign = T )
for ( i in data $ symbolnames ) data [[ i ]] = adjustOHLC ( data [[ i ]], use.Adjusted = T )
bt.prep ( data , align = 'remove.na' , fill.gaps = T )
Next, let’s test the functionality of kellerCLAfun from Appendix A
#*****************************************************************
# Run tests, monthly data - works
#*****************************************************************
data = bt.change.periodicity ( data , periodicity = 'months' )
plota.matplot ( scale.one ( data $ prices ))
prices = data $ prices
res = kellerCLAfun ( prices , returnWeights = T , 0.25 , 0.1 , c ( 'US.CASH' , 'MID.TR' ))
plotbt.transition.map ( res [[ 1 ]][ '2013::' ])
plota ( cumprod ( 1 + res [[ 2 ]]), type = 'l' )
Next, let’s create a benchmark and set up commision structure to be used for all tests.
#*****************************************************************
# Create a benchmark
#*****************************************************************
models = list ()
commission = list ( cps = 0.01 , fixed = 10.0 , percentage = 0.0 )
data $ weight [] = NA
data $ weight $ US.EQ = 1
data $ weight [ 1 : 12 ,] = NA
models $ US.EQ = bt.run.share ( data , clean.signal = T , commission = commission , trade.summary = T , silent = T )
Next, let’s take weights from the kellerCLAfun and use them to create a back-test
#*****************************************************************
# transform kellerCLAfun into model results
#*****************************************************************
#models$CLA = list(weight = res[[1]], ret = res[[2]], equity = cumprod(1 + res[[2]]), type = "weight")
obj = list ( weights = list ( CLA = res [[ 1 ]]), period.ends = index ( res [[ 1 ]]))
models = c ( models , create.strategies ( obj , data , commission = commission , trade.summary = T , silent = T ) $ models )
We can easily replicate same results with base SIT functionality
#*****************************************************************
# Replicate using base SIT functionality
#*****************************************************************
weight.limit = data.frame ( last ( prices ))
weight.limit [] = 0.25
weight.limit $ US.CASH = weight.limit $ MID.TR = 1
obj = portfolio.allocation.helper ( data $ prices ,
periodicity = 'months' , lookback.len = 12 , silent = T ,
const.ub = weight.limit ,
create.ia.fn = function ( hist.returns , index , nperiod ) {
ia = create.ia ( hist.returns , index , nperiod )
ia $ expected.return = ( last ( hist.returns , 1 ) + colSums ( last ( hist.returns , 3 )) +
colSums ( last ( hist.returns , 6 )) + colSums ( last ( hist.returns , 12 ))) / 22
ia
},
min.risk.fns = list (
TRISK = target.risk.portfolio ( target.risk = 0.1 , annual.factor = 12 )
)
)
models = c ( models , create.strategies ( obj , data , commission = commission , trade.summary = T , silent = T ) $ models )
Another idea is to use Pierre Chretien’s Averaged Input Assumptions
#*****************************************************************
# Let's use Pierre's Averaged Input Assumptions
#*****************************************************************
obj = portfolio.allocation.helper ( data $ prices ,
periodicity = 'months' , lookback.len = 12 , silent = T ,
const.ub = weight.limit ,
create.ia.fn = create.ia.averaged ( c ( 1 , 3 , 6 , 12 ), 0 ),
min.risk.fns = list (
TRISK.AVG = target.risk.portfolio ( target.risk = 0.1 , annual.factor = 12 )
)
)
models = c ( models , create.strategies ( obj , data , commission = commission , trade.summary = T , silent = T ) $ models )
Finally we are ready to look at the results
#*****************************************************************
# Plot back-test
#*****************************************************************
models = bt.trim ( models )
#strategy.performance.snapshoot(models, T)
plotbt ( models , plotX = T , log = 'y' , LeftMargin = 3 , main = NULL )
mtext ( 'Cumulative Performance' , side = 2 , line = 1 )
print ( plotbt.strategy.sidebyside ( models , make.plot = F , return.table = T , perfromance.fn = engineering.returns.kpi ))
US.EQ
CLA
TRISK
TRISK.AVG
Period
May1997 - Jun2015
May1997 - Jun2015
May1997 - Jun2015
May1997 - Jun2015
Cagr
7.36
9.57
9.49
10.16
Sharpe
0.53
1.02
1.01
1.04
DVR
0.33
1
0.98
1.01
R2
0.63
0.98
0.97
0.97
Volatility
15.84
9.32
9.42
9.72
MaxDD
-50.84
-13.65
-13.65
-13.65
Exposure
99.08
99.08
99.08
99.08
Win.Percent
100
64.57
59.85
59.28
Avg.Trade
259.48
0.26
0.18
0.19
Profit.Factor
NaN
1.93
1.87
1.93
Num.Trades
1
717
1066
1051
layout ( 1 )
barplot.with.labels ( sapply ( models , compute.turnover , data ), 'Average Annual Portfolio Turnover' )
Our replication results are almost identical results to the results using kellerCLAfun.
Using Averaged Input Assumptions produces slightly better results.
I guess the main point that a reader should remember from reading Momentum and Markowitz: A Golden Combination (2015) by Keller, Butler, Kipnis
paper is that it is a bad idea to blindly use the optimizer. Instead, you should apply
common sense heuristics mentioned in the paper to make solution robust across time and
various universes.
Supporting functions:
#*****************************************************************
# Appendix B. CLA code (in R) by Ilya Kipnis (QuantStratTradeR10) SSRN-id2606884.pdf
#*****************************************************************
require ( quantmod )
require ( PerformanceAnalytics )
require ( TTR )
CCLA <- function ( covMat , retForecast , maxIter = 1000 ,
verbose = FALSE , scale = 252 ,
weightLimit = .7 , volThresh = .1 )
{
if ( length ( retForecast ) > length ( unique ( retForecast ))) {
sequentialNoise <- seq ( 1 : length ( retForecast )) * 1e-12
retForecast <- retForecast + sequentialNoise
}
#initialize original out/in/up status
if ( length ( weightLimit ) == 1 ) {
weightLimit <- rep ( weightLimit , ncol ( covMat ))
}
# sort return forecasts
rankForecast <- length ( retForecast ) - rank ( retForecast ) + 1
remainingWeight <- 1 #have 100% of weight to allocate
upStatus <- inStatus <- rep ( 0 , ncol ( covMat ))
i <- 1
# find max return portfolio
while ( remainingWeight > 0 ) {
securityLimit <- weightLimit [ rankForecast == i ]
if ( securityLimit < remainingWeight ) {
upStatus [ rankForecast == i ] <- 1 #if we can't invest all remaining weight into the security
remainingWeight <- remainingWeight - securityLimit
} else {
inStatus [ rankForecast == i ] <- 1
remainingWeight <- 0
}
i <- i + 1
}
#initial matrices (W, H, K, identity, negative identity)
covMat <- as.matrix ( covMat )
retForecast <- as.numeric ( retForecast )
init_W <- cbind ( 2 * covMat , rep ( -1 , ncol ( covMat )))
init_W <- rbind ( init_W , c ( rep ( 1 , ncol ( covMat )), 0 ))
H_vec <- c ( rep ( 0 , ncol ( covMat )), 1 )
K_vec <- c ( retForecast , 0 )
negIdentity <- -1 * diag ( ncol ( init_W ))
identity <- diag ( ncol ( init_W ))
matrixDim <- nrow ( init_W )
weightLimMat <- matrix ( rep ( weightLimit , matrixDim ), ncol = ncol ( covMat ), byrow = TRUE )
#out status is simply what isn't in or up
outStatus <- 1 - inStatus - upStatus
#initialize expected volatility/count/turning points data structure
expVol <- Inf
lambda <- 100
count <- 0
turningPoints <- list ()
while ( lambda > 0 & count < maxIter ) {
#old lambda and old expected volatility for use with numerical algorithms
oldLambda <- lambda
oldVol <- expVol
count <- count + 1
#compute W, A, B
inMat <- matrix ( rep ( c ( inStatus , 1 ), matrixDim ), nrow = matrixDim , byrow = TRUE )
upMat <- matrix ( rep ( c ( upStatus , 0 ), matrixDim ), nrow = matrixDim , byrow = TRUE )
outMat <- matrix ( rep ( c ( outStatus , 0 ), matrixDim ), nrow = matrixDim , byrow = TRUE )
W <- inMat * init_W + upMat * identity + outMat * negIdentity
inv_W <- solve ( W )
modified_H <- H_vec - rowSums ( weightLimMat * upMat [, - matrixDim ] * init_W [, - matrixDim ])
A_vec <- inv_W %*% modified_H
B_vec <- inv_W %*% K_vec
#remove the last elements from A and B vectors
truncA <- A_vec [ - length ( A_vec )]
truncB <- B_vec [ - length ( B_vec )]
#compute in Ratio (aka Ratio(1) in Kwan.xls)
inRatio <- rep ( 0 , ncol ( covMat ))
inRatio [ truncB > 0 ] <- - truncA [ truncB > 0 ] / truncB [ truncB > 0 ]
#compute up Ratio (aka Ratio(2) in Kwan.xls)
upRatio <- rep ( 0 , ncol ( covMat ))
upRatioIndices <- which ( inStatus == TRUE & truncB < 0 )
if ( length ( upRatioIndices ) > 0 ) {
upRatio [ upRatioIndices ] <- ( weightLimit [ upRatioIndices ] - truncA [ upRatioIndices ]) / truncB [ upRatioIndices ]
}
#find lambda -- max of up and in ratios
maxInRatio <- max ( inRatio )
maxUpRatio <- max ( upRatio )
lambda <- max ( maxInRatio , maxUpRatio )
#compute new weights
wts <- inStatus * ( truncA + truncB * lambda ) + upStatus * weightLimit + outStatus * 0
#compute expected return and new expected volatility
expRet <- t ( retForecast ) %*% wts
expVol <- sqrt ( wts %*% covMat %*% wts ) * sqrt ( scale )
#create turning point data row and append it to turning points
turningPoint <- cbind ( count , expRet , lambda , expVol , t ( wts ))
colnames ( turningPoint ) <- c ( "CP" , "Exp. Ret." , "Lambda" , "Exp. Vol." , colnames ( covMat ))
turningPoints [[ count ]] <- turningPoint
#binary search for volatility threshold -- if the first iteration is lower than the threshold,
#then immediately return, otherwise perform the binary search until convergence of lambda
if ( oldVol == Inf & expVol < volThresh ) {
turningPoints <- do.call ( rbind , turningPoints )
threshWts <- tail ( turningPoints , 1 )
return ( list ( turningPoints , threshWts ))
} else if ( oldVol > volThresh & expVol < volThresh ) {
upLambda <- oldLambda
dnLambda <- lambda
meanLambda <- ( upLambda + dnLambda ) / 2
while ( upLambda - dnLambda > .00001 ) {
#compute mean lambda and recompute weights, expected return, and expected vol
meanLambda <- ( upLambda + dnLambda ) / 2
wts <- inStatus * ( truncA + truncB * meanLambda ) + upStatus * weightLimit + outStatus * 0
expRet <- t ( retForecast ) %*% wts
expVol <- sqrt ( wts %*% covMat %*% wts ) * sqrt ( scale )
#if new expected vol is less than threshold, mean becomes lower bound
#otherwise, it becomes the upper bound, and loop repeats
if ( expVol < volThresh ) {
dnLambda <- meanLambda
} else {
upLambda <- meanLambda
}
}
#once the binary search completes, return those weights, and the corner points
#computed until the binary search. The corner points aren't used anywhere, but they're there.
threshWts <- cbind ( count , expRet , meanLambda , expVol , t ( wts ))
colnames ( turningPoint ) <- colnames ( threshWts ) <- c ( "CP" , "Exp. Ret." , "Lambda" , "Exp. Vol." , colnames ( covMat ))
turningPoints [[ count ]] <- turningPoint
turningPoints <- do.call ( rbind , turningPoints )
return ( list ( turningPoints , threshWts ))
}
#this is only run for the corner points during which binary search doesn't take place
#change status of security that has new lambda
if ( maxInRatio > maxUpRatio ) {
inStatus [ inRatio == maxInRatio ] <- 1 - inStatus [ inRatio == maxInRatio ]
upStatus [ inRatio == maxInRatio ] <- 0
} else {
upStatus [ upRatio == maxUpRatio ] <- 1 - upStatus [ upRatio == maxUpRatio ]
inStatus [ upRatio == maxUpRatio ] <- 0
}
outStatus <- 1 - inStatus - upStatus
}
#we only get here if the volatility threshold isn't reached
#can actually happen if set sufficiently low
turningPoints <- do.call ( rbind , turningPoints )
threshWts <- tail ( turningPoints , 1 )
return ( list ( turningPoints , threshWts ))
}
sumIsNa <- function ( column ) {
return ( sum ( is.na ( column )))
}
returnForecast <- function ( prices ) {
forecast <- ( ROC ( prices , n = 1 , type = "discrete" ) + ROC ( prices , n = 3 , type = "discrete" ) +
ROC ( prices , n = 6 , type = "discrete" ) + ROC ( prices , n = 12 , type = "discrete" )) / 22
forecast <- as.numeric ( tail ( forecast , 1 ))
return ( forecast )
}
kellerCLAfun <- function ( prices , returnWeights = FALSE ,
weightLimit , volThresh , uncappedAssets )
{
if ( sum ( colnames ( prices ) % in % uncappedAssets ) == 0 ) {
stop ( "No assets are uncapped." )
}
#initialize data structure to contain our weights
weights <- list ()
#compute returns
returns <- Return.calculate ( prices )
returns [ 1 ,] <- 0 #impute first month with zeroes
ep <- endpoints ( returns , on = "months" )
for ( i in 2 : ( length ( ep ) - 12 )) {
priceSubset <- prices [ ep [ i ] : ep [ i +12 ]] #subset prices
retSubset <- returns [ ep [ i ] : ep [ i +12 ]] #subset returns
assetNAs <- apply ( retSubset , 2 , sumIsNa )
zeroNAs <- which ( assetNAs == 0 )
priceSubset <- priceSubset [, zeroNAs ]
retSubset <- retSubset [, zeroNAs ]
#remove perfectly correlated assets
retCors <- cor ( retSubset )
diag ( retCors ) <- NA
corMax <- round ( apply ( retCors , 2 , max , na.rm = TRUE ), 7 )
while ( max ( corMax ) == 1 ) {
ones <- which ( corMax == 1 )
valid <- which ( ! names ( corMax ) % in % uncappedAssets )
toRemove <- intersect ( ones , valid )
toRemove <- max ( valid )
retSubset <- retSubset [, - toRemove ]
priceSubset <- priceSubset [, - toRemove ]
retCors <- cor ( retSubset )
diag ( retCors ) <- NA
corMax <- round ( apply ( retCors , 2 , max , na.rm = TRUE ), 7 )
}
covMat <- cov ( retSubset ) #compute covariance matrix
#Dr. Keller's return forecast
retForecast <- returnForecast ( priceSubset )
uncappedIndex <- which ( colnames ( covMat ) % in % uncappedAssets )
weightLims <- rep ( weightLimit , ncol ( covMat ))
weightLims [ uncappedIndex ] <- 1
cla <- CCLA ( covMat = covMat , retForecast = retForecast , scale = 12 ,
weightLimit = weightLims , volThresh = volThresh ) #run CCLA algorithm
CPs <- cla [[ 1 ]] #corner points
wts <- cla [[ 2 ]] #binary search volatility targeting -- change this line and the next
#if using max sharpe ratio golden search
wts <- wts [, 5 : ncol ( wts )] #from 5th column to the end
if ( length ( wts ) == 1 ) {
names ( wts ) <- colnames ( covMat )
}
zeroes <- rep ( 0 , ncol ( prices ) - length ( wts ))
names ( zeroes ) <- colnames ( prices )[ ! colnames ( prices ) % in % names ( wts )]
wts <- c ( wts , zeroes )
wts <- wts [ colnames ( prices )]
#append to weights
wts <- xts ( t ( wts ), order.by = tail ( index ( retSubset ), 1 ))
weights [[ i ]] <- wts
}
weights <- do.call ( rbind , weights )
#compute strategy returns
stratRets <- Return.portfolio ( returns , weights = weights )
if ( returnWeights ) {
return ( list ( weights , stratRets ))
}
return ( stratRets )
}
(this report was produced on: 2015-06-04)