Quarterly Tactical Strategy

To install Systematic Investor Toolbox (SIT) please visit About page.

Another interesting post by QuantStrat TradeR: The Quarterly Tactical Strategy (aka QTS)

The Quarterly Tactical Strategy was published by Cliff Smith.

Below I will try to adapt a code from the post:

#*****************************************************************
# Load historical data
#*****************************************************************
library(SIT)
load.packages('quantmod')

tickers = '
US.SC = VB + NAESX # U.S. Small Cap
EM.BOND = VWOB + PREMX # Emerging Market Government Bond
EM.EQ = VWO + VEIEX # Emerging Markets
US.CORP.BOND = VCIT + VFICX # Intermediate Corporate Bond
US.MBS = VMBS + VFIIX # Mortgage-Backed Bonds
US.LC = SPY + VFINX # S&P 500
US.REIT = VNQ +  VGSIX # MSCI U.S. REIT
INTL.EQ = VEU + VGTSX # FTSE All-World ex-U.S. 
CASH = TLT + VUSTX # Long-Tern Treasury
'
           
data <- new.env()
getSymbols.extra(tickers, src = 'yahoo', from = '1970-01-01', env = data, set.symbolnames = T, auto.assign = T)
for(i in data$symbolnames) data[[i]] = adjustOHLC(data[[i]], use.Adjusted=T)

#print(bt.start.dates(data))
bt.prep(data, align='remove.na', fill.gaps = T)

# Check data
plota.matplot(scale.one(data$prices),main='Asset Perfromance')

plot of chunk plot-2

#*****************************************************************
# Setup
#*****************************************************************
data$universe = data$prices > 0
  # do not allocate to CASH, or BENCH
  data$universe$CASH = NA

prices = data$prices * data$universe
  n = ncol(prices)
  nperiods = nrow(prices)


frequency = 'quarters'
# find period ends, can be 'weeks', 'months', 'quarters', 'years'
period.ends = endpoints(prices, frequency)
  period.ends = period.ends[period.ends > 0]

models = list()

commission = list(cps = 0.01, fixed = 10.0, percentage = 0.0)

# lag prices by 1 day
#prices = mlag(prices)
#*****************************************************************
# Equal Weight each re-balancing period
#******************************************************************
data$weight[] = NA
  data$weight[period.ends,] = ntop(prices[period.ends,], n)
models$ew = bt.run.share(data, clean.signal=F, commission = commission, trade.summary=T, silent=T)

#*****************************************************************
# Strategy:
#
# Select the top-ranked asset each quarter based on 
# 5-month and 20-day total returns weighted 50% each.
#
# A cash filter must be passed for the top-ranked mutual fund to be selected 
# in any given period. The cash filter is the 3-month moving average. 
#******************************************************************
mom.5m = prices / mlag(prices, 5*21) -1
mom.20d = prices / mlag(prices, 20) -1 

# compute 3 month moving average
sma = bt.apply.matrix(prices, SMA, 3*21)

# go to cash if prices falls below 3 month moving average
go2cash = prices <= sma
  go2cash.d = ifna(go2cash, T)


# compute moving average in months
sma = bt.apply.matrix(prices, SMA, 3, periodicity='months')

go2cash = prices <= sma
  go2cash.m = ifna(go2cash, T)


# all logic below is done at period.ends
mom.5m = mom.5m[period.ends,]
mom.20d = mom.20d[period.ends,]
go2cash.d = go2cash.d[period.ends,]
go2cash.m = go2cash.m[period.ends,]

#*****************************************************************
# Rank total score
#*****************************************************************
# target allocation
target.allocation = ntop(0.5 * mom.5m + 0.5 * mom.20d ,1)

# If asset is above it's 3 month moving average it gets allocation
weight = iif(go2cash.d, 0, target.allocation)

# otherwise, it's weight is allocated to cash
weight$CASH = 1 - rowSums(weight)

data$weight[] = NA
  data$weight[period.ends,] = weight
models$QTS.d = bt.run.share(data, clean.signal=F, commission = commission, trade.summary=T, silent=T)


# same but using monthly moving average to trigger go to cash
weight = iif(go2cash.m, 0, target.allocation)
weight$CASH = 1 - rowSums(weight)

data$weight[] = NA
  data$weight[period.ends,] = weight
models$QTS.m = bt.run.share(data, clean.signal=F, commission = commission, trade.summary=T, silent=T)

#*****************************************************************
# Rank each component of total score first
#*****************************************************************
# target allocation
target.allocation[] = ntop(1.01 * bt.rank(mom.5m, F,T) + bt.rank(mom.20d, F,T) ,1)

# If asset is above it's 3 month moving average it gets allocation
weight = iif(go2cash.d, 0, target.allocation)

# otherwise, it's weight is allocated to cash
weight$CASH = 1 - rowSums(weight)

data$weight[] = NA
  data$weight[period.ends,] = weight
models$QTS.RANK.d = bt.run.share(data, clean.signal=F, commission = commission, trade.summary=T, silent=T)


# same but using monthly moving average to trigger go to cash
weight = iif(go2cash.m, 0, target.allocation)
weight$CASH = 1 - rowSums(weight)

data$weight[] = NA
  data$weight[period.ends,] = weight
models$QTS.RANK.m = bt.run.share(data, clean.signal=F, commission = commission, trade.summary=T, silent=T)

#*****************************************************************
# Report
#*****************************************************************
#strategy.performance.snapshoot(models, T)
plotbt(models, plotX = T, log = 'y', LeftMargin = 3, main = NULL)
	mtext('Cumulative Performance', side = 2, line = 1)

plot of chunk plot-2

print(plotbt.strategy.sidebyside(models, make.plot=F, return.table=T))
  ew QTS.d QTS.m QTS.RANK.d QTS.RANK.m
Period Jun1996 - Mar2015 Jun1996 - Mar2015 Jun1996 - Mar2015 Jun1996 - Mar2015 Jun1996 - Mar2015
Cagr 8.13 16.08 19.32 16.57 20.12
Sharpe 0.67 0.91 1.07 0.96 1.15
DVR 0.61 0.76 0.81 0.82 0.86
Volatility 12.89 18.28 17.99 17.54 17.32
MaxDD -44.61 -26.78 -26.78 -25 -19.39
AvgDD -1.55 -3.09 -3.01 -2.87 -2.77
VaR -1.16 -1.79 -1.75 -1.71 -1.69
CVaR -1.96 -2.82 -2.72 -2.71 -2.61
Exposure 99.98 99.98 99.98 99.98 99.98

Given that each quarter only one top fund is selected it is natural that strategy is sensitive to input parameters. However, the change in performance from go to cash rule based on 63 days vs 3 months is staggering.

Finally, let’s zoom in on various periods:

dates.range = c('2002-12-31::2014-08-15', '::2002-12-31', '2014-08-15::')

for(dates in dates.range) {
models1 = bt.trim(models, dates=dates)
plotbt(models1, plotX = T, log = 'y', LeftMargin = 3, main = NULL)
	mtext('Cumulative Performance', side = 2, line = 1)
print(plotbt.strategy.sidebyside(models1, make.plot=F, return.table=T))
}

plot of chunk plot-3

  ew QTS.d QTS.m QTS.RANK.d QTS.RANK.m
Period Dec2002 - Aug2014 Dec2002 - Aug2014 Dec2002 - Aug2014 Dec2002 - Aug2014 Dec2002 - Aug2014
Cagr 10.2 23.66 29.32 23.79 29.96
Sharpe 0.74 1.15 1.4 1.2 1.48
DVR 0.64 1.07 1.25 1.13 1.32
Volatility 14.5 20.3 19.86 19.34 19
MaxDD -44.61 -25.99 -25.99 -25 -19.13
AvgDD -1.43 -2.89 -2.79 -2.61 -2.49
VaR -1.36 -2.06 -1.98 -1.86 -1.81
CVaR -2.26 -3.15 -3.01 -3.01 -2.86
Exposure 100 100 100 100 100

plot of chunk plot-3

  ew QTS.d QTS.m QTS.RANK.d QTS.RANK.m
Period Jun1996 - Dec2002 Jul1996 - Dec2002 Jul1996 - Dec2002 Jul1996 - Dec2002 Jul1996 - Dec2002
Cagr 5.18 5.19 5.11 6.27 6.2
Sharpe 0.56 0.43 0.42 0.51 0.5
DVR 0.4 0.32 0.32 0.41 0.41
Volatility 9.81 14.21 14.23 13.93 13.95
MaxDD -20.72 -26.78 -26.78 -19.39 -19.39
AvgDD -2.05 -3.18 -3.37 -3.53 -3.9
VaR -0.98 -1.37 -1.37 -1.36 -1.36
CVaR -1.43 -2.15 -2.15 -2.11 -2.11
Exposure 99.94 100 100 100 100

plot of chunk plot-3

  ew QTS.d QTS.m QTS.RANK.d QTS.RANK.m
Period Aug2014 - Mar2015 Aug2014 - Mar2015 Aug2014 - Mar2015 Aug2014 - Mar2015 Aug2014 - Mar2015
Cagr 0.83 -1.52 -1.52 -1.52 -1.52
Sharpe 0.15 -0.03 -0.03 -0.03 -0.03
DVR 0.04 -0.01 -0.01 -0.01 -0.01
Volatility 8.15 15.71 15.71 15.71 15.71
MaxDD -5.29 -14.33 -14.33 -14.33 -14.33
AvgDD -1.03 -3.38 -3.38 -3.38 -3.38
VaR -0.89 -1.94 -1.94 -1.94 -1.94
CVaR -1.04 -2.35 -2.35 -2.35 -2.35
Exposure 100 100 100 100 100

(this report was produced on: 2015-03-12)