# Global Market Rotation Enhanced (GMRE) - Roger & Satchell Volatility
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# ATTRIBUTION:
# GMRE Strategy: Frank Grossman, 2013-08-09
# SeekingAlpha http://seekingalpha.com/article/1622832-a-global-market-rotation-strategy-with-an-annual-performance-of-41-4-since-2003
# Logical-Invest http://www.logical-invest.com/strategy/GlobalMarketRotationEnhancedStrategy.htm
# Quantopian Author: Ed Bartosh, 2013-09-13
# Quantopian Author: David Quast, 2013-09-13
# Quantopian Author: James Crocker, 2013-11-14 [email protected]
# NOTE: record() can ONLY handle five elements in the graph. Any more than that will runtime error once 5 are exceeded.
# NOTE: With default vaules for the ta_lib functions
# 2013-11-05 PLUS_DI|MINUS_DI provide good indicators - KEEP PLUS_DI|MINUS_DI
# 2013-11-05 RSI trends closely toPLUS_DI with a bit more volatility; - IGNORE RSI
# 2013-11-05 ROCR100 trends PLUS_DI in shape but is more volatile; lags PLUS_DI|MINUS_DI cross over; lags PPO - ignore ROCR100
# 2013-11-05 PPO trends PLUS_DI but tempers whipsaw of PLUS_DI|MINUS_DI crossovers - KEEP PPO to temper +-DI
# 2013-11-05 TSF trends PPO by 10x. Even factoring by 10 TSF trend doesn't appear to be a good indicator - IGNORE TSF
# 2013-11-05 STOCH/STOCHF to get SlowD and FastD for signal - KEEP STOCH/STOCHF for now. - May presage corrections of other trends.
# 2013-11-05 VAR in for future reference (original GMRE metric); may also need to reconsider ROCR100 unless PLUS_DI fits.
# 2013-11-14 Removing the DI signal as it has no impact on backtest. Week stock already weakest even with -1 for DI signal.
# 2013-12-02 Added 0.5 factor for EDV as per original Seeking Alpha post by Mr. Grossman. EDV volatility is about 50% higher than the other ETF's.
# 2013-12-06 Added verbose logging switches
# 2013-12-09 Tried weighting the volatility AND performance months (e.g. 1/5, 1/3, 1/2) but didn't impact the performance enough to warrant.
# Adapted Global Market Rotation Strategy
#
# This strategy rotates between six global market ETFs on a monthly
# basis. Each month the performance and mean 20-day volatility over
# the last 13 weeks are used to rank which ETF should be invested
# in for the coming month.
import math
import pandas
performance_lookback = 63
# window_length SHOULD EQUAL context.metricPeriod
@batch_transform(window_length=performance_lookback)
def accumulateData(data):
return data
def initialize(context):
# Set commission model
set_commission(commission.PerTrade(cost=7.00))
# Trade on boundary of first trading day of MONTH or set DAYS
# DAYS|MONTH
#context.boundaryTrade = 'DAYS'
#context.boundaryDays = 14
context.boundaryTrade = 'MONTH'
# Set the last date for the FORECAST BEST Buy
context.lastForecastYear = 2015
context.lastForecastMonth = 12
context.lastForecastDay = 30
# Set Performance vs. Volatility factors (7.0, 3.0 from Grossman GMRE
context.factorPerformance = 0.8
context.factorVolatility = 0.2
# Period Volatility and Performance period in DAYS
context.metricPeriod = performance_lookback # 3 months LOOKBACK
context.periodVolatility = 21 # Volatility period. Chose a MULTIPLE of metricPeriod
#context.volatilityMethod = "simple"
context.volatilityMethod = "rs"
# To prevent going 'negative' on cash account set stop, limit and price factor >= stop
#context.orderBuyLimits = False
#context.or#derSellLimits = False
##context.priceBuyStop = None
##context.priceBuyLimit = None
##context.priceSellStop = None
##context.priceSellLimit = None
#context.priceBuyFactor = 3.03 # Buffering since buys and sells DON'T occur on the same day.
# Re-enact pricing from original Quast code
context.orderBuyLimits = False
context.orderSellLimits = False
context.priceBuyFactor = 0.0
# Factor commission cost
#set_commission(commission.PerShare(cost=0.03))
#set_commission(commission.PerTrade(cost=15.00))
context.basket = {
12915: sid(12915), # MDY (SPDR S&P MIDCAP 400)
21769: sid(21769), # IEV (ISHARES EUROPE ETF)
24705: sid(24705), # EEM (ISHARES MSCI EMERGING MARKETS)
23134: sid(23134), # ILF (ISHARES LATIN AMERICA 40)
23118: sid(23118), # EPP (ISHARES MSCI PACIFIC EX JAPAN)
32279: sid(32279), # XOP (SPDRÂ® S&PÂ® Oil & Gas Exploration & Production ETF)
22887: sid(22887), # EDV (VANGUARD EXTENDED DURATION TREASURY)
#23870: sid(23870), # IEF (VANGUARD 7-10 years bond)
40513: sid(40513), # ZIV (VelocityShares Inverse VIX Medium-Term)
#26432: sid(26432), # FEZ
#23911: sid(23911), # SHY
#8554: sid(8554) # SPY
}
# Set/Unset logging features for verbosity levels
context.logWarn = False
context.logBuy = False
context.logSell = False
context.logHold = True
context.logRank = False
context.logDebug = True
# SHOULDN'T NEED TO MODIFY REMAINING VARIABLES
# Keep track of the current month.
context.currentDayNum = None
context.currentMonth = None
context.currentStock = None
context.nextStock = None
context.oidBuy = None
context.oidSell = None
context.buyCount = 0
context.sellCount = 0
def getMinMax(arr):
return min(arr.values()), max(arr.values())
def simpleVolatility(period, prices):
# HVdaily = sqrt( sum[1..n](x_t - Xbar)^2 / n - 1)
# Start by calculating Xbar = 1/n sum[1..n] (ln(P_t / P_t-1))
#print period, len(prices)
r = []
for i in xrange(1, period + 1):
r.append(math.log(prices[i] / prices[i-1]))
# Find the average of all returns
rMean = sum(r) / period;
# Determine the difference of each return from the mean, then square
d = []
for i in xrange(0, period):
d.append(math.pow((r[i] - rMean), 2))
# Take the square root of the sum over the period - 1. Then mulitply
# that by the square root of the number of trading days in a year
vol = math.sqrt(sum(d) / (period - 1)) * math.sqrt(252/period)
return vol
def rsVolatility(period, openPrices, closePrices, highPrices, lowPrices):
# Rogers and Satchell (1991)
r = []
for i in xrange(0, period):
a = math.log(highPrices[i] / closePrices[i])
b = math.log(highPrices[i] / openPrices[i])
c = math.log(lowPrices[i] / closePrices[i])
d = math.log(lowPrices[i] / openPrices[i])
r.append( a*b + c*d )
# Take the square root of the sum over the period - 1. Then mulitply
# that by the square root of the number of trading days in a year
vol = math.sqrt(sum(r) / period) * math.sqrt(252/period)
return vol
def getStockMetrics(context, openPrices, closePrices, highPrices, lowPrices):
# Get the prices
# Frank GrossmannComments (114)
# You can use the 20 day volatility averaged over 3 month.
# For the ranking I calculate the 3 month performance of all ETF's and normalise between 0-1.
# The best will have 1. Then I calculate the medium 3 month 20 day volatility and also normalize from 0-1.
# Then I used Ranking= 0.7*performance +0.3*volatility.
# This will give me a ranking from 0-1 from which I will take the best.
period = context.metricPeriod
periodV = context.periodVolatility
volDays = periodV
volRange = period / volDays
# Calculate the period performance
start = closePrices[-period] # First item
end = closePrices[-1] # Last item
performance = (end - start) / start
# Calculate 20-day volatility for the given period
v = []
x = 0
for i in xrange(-volRange, 0):
x = i * periodV
y = x + volDays
#if context.logDebug is True:
# log.debug('period %s, pV %s, volDays %s, i %s, x %s, y %s, lenopenprices %s' % (period, periodV, volDays, i, x, y, len(openPrices)))
if context.volatilityMethod == 'simple':
if (y<0):
v.append(simpleVolatility(volDays, closePrices[x:(y+1)]))
else:
v.append(simpleVolatility(volDays, closePrices[(x-1):]))
else:
if (y<0):
v.append(rsVolatility(volDays, openPrices[x:y], closePrices[x:y], highPrices[x:y], lowPrices[x:y]))
else:
v.append(rsVolatility(volDays, openPrices[x:], closePrices[x:], highPrices[x:], lowPrices[x:]))
volatility = sum(v) / volRange
return performance, volatility
def getBestStock(context, data, stocks):
# Frank GrossmannComments (114)
# For the ranking, I also use the volatility of the ETFs.
# While this is not so important for the 5 Global market ETFs, it is important to lower the EDV ranking
# a little bit, according to the higher volatility of the EDV ETF. EDV has a medium 20-day volatility,
# which is roughly 50% higher than the volatility of the 5 global market ETFs. This results in higher
# spikes during small market turbulence and the model would switch too early between shares (our 5 ETFs)
# and treasuries .
performances = {}
volatilities = {}
# Get performance and volatility for all the stocks
for s in stocks:
p, v = getStockMetrics(context, data['open_price'][s.sid], data['close_price'][s.sid], data['high'][s.sid], data['low'][s.sid])
performances[s.sid] = p
volatilities[s.sid] = v
# Determine min/max of each. NOTE: volatility is switched
# since a low volatility should be weighted highly.
minP, maxP = getMinMax(performances)
maxV, minV = getMinMax(volatilities)
# Normalize the performance and volatility values to a range
# between [0..1] then rank them based on a 70/30 weighting.
stockRanks = {}
for s in stocks:
p = (performances[s.sid] - minP) / (maxP - minP)
v = (volatilities[s.sid] - minV) / (maxV - minV)
if context.logDebug is True:
log.debug('[%s] p %s, v %s' % (s, p, v))
log.debug('[%s] perf %s, vol %s' % (s, performances[s.sid], volatilities[s.sid]))
pFactor = context.factorPerformance
vFactor = context.factorVolatility
if math.isnan(p) or math.isnan(v):
rank = None
else:
# Adjust volatility for EDV by 50%
#if s.sid == 22887:
# rank = (p * pFactor) + ((v * 0.5) * vFactor)
#else:
rank = (p * pFactor) + (v * vFactor)
if rank is not None:
stockRanks[s] = rank
bestStock = None
if len(stockRanks) > 0:
if context.logDebug is True and len(stockRanks) < len(stocks):
log.debug('FEWER STOCK RANKINGS THAN IN STOCK BASKET!')
if context.logRank is True:
for s in sorted(stockRanks, key=stockRanks.get, reverse=True):
log.info('RANK [%s] %s' % (s, stockRanks[s]))
bestStock = max(stockRanks, key=stockRanks.get)
else:
if context.logDebug is True:
log.debug('NO STOCK RANKINGS FOUND IN BASKET; BEST STOCK IS: NONE')
return bestStock
def sellPositions(context):
oid = None
positions = context.portfolio.positions
try:
priceSellStop = context.priceSellStop
except:
priceSellStop = 0.0
try:
priceSellLimit = context.priceSellLimit
except:
priceSellLimit = 0.0
for p in positions.values():
if (p.amount > 0):
amount = p.amount
price = p.last_sale_price
if context.logSell is True:
orderValue = price * amount
log.info('SELL [%s] (%s) @ $%s (%s)' % (p.sid, -amount, price, orderValue))
stop = price - priceSellStop
limit = stop - priceSellLimit
if context.orderSellLimits is True:
oid = order(p.sid, -amount, limit_price = limit, stop_price = stop)
else:
oid = order(p.sid, -amount)
context.sellCount += 1
return oid
def buyPositions(context, data):
oid = None
cash = context.portfolio.cash
s = context.nextStock
try:
priceBuyFactor = context.priceBuyFactor
except:
priceBuyFactor = 0.0
try:
priceBuyStop = context.priceBuyStop
except:
priceBuyStop = 0.0
try:
priceBuyLimit = context.priceBuyLimit
except:
priceBuyLimit = 0.0
price = data[s.sid].open_price
amount = math.floor(cash / (price + priceBuyFactor))
orderValue = price * amount
stop = price + priceBuyStop
limit = stop + priceBuyLimit
if cash <= 0 or cash < orderValue:
log.info('BUY ABORT! cash $%s < orderValue $%s' % (cash, orderValue))
else:
if context.logBuy is True:
log.info('BUY [%s] %s @ $%s ($%s of $%s)' % (s, amount, price, orderValue, cash))
if context.orderBuyLimits is True:
oid = order(s, amount, limit_price = limit, stop_price = stop)
else:
oid = order(s, amount)
context.buyCount += 1
return oid
'''
The main proccessing function. This is called and passed data
'''
def handle_data(context, data):
date = get_datetime()
month = int(date.month)
day = int(date.day)
year = int(date.year)
#dayNum = int(date.strftime("%j"))
fYear = context.lastForecastYear
fMonth = context.lastForecastMonth
fDay = context.lastForecastDay
if context.logWarn is True and context.portfolio.cash < 0:
log.warn('NEGATIVE CASH %s' % context.portfolio.cash)
if context.oidSell is not None:
orderObj = get_order(context.oidSell)
if orderObj.filled == orderObj.amount:
# Good to buy next holding
if context.logSell is True:
log.info('SELL ORDER COMPLETED')
context.oidSell = None
context.oidBuy = buyPositions(context, data)
context.currentStock = context.nextStock
context.nextStock = None
else:
if context.logSell is True:
log.info('SELL ORDER *NOT* COMPLETED')
return
if context.oidBuy is not None:
orderObj = get_order(context.oidBuy)
if orderObj.filled == orderObj.amount:
if context.logBuy is True:
log.info('BUY ORDER COMPLETED')
context.oidBuy = None
else:
if context.logBuy is True:
log.info('BUY ORDER *NOT* COMPLETED')
return
datapanel = accumulateData(data)
if datapanel is None:
# There is insufficient data accumulated to process
if context.logWarn is True:
log.warn('INSUFFICIENT DATA!')
return
if context.boundaryTrade == "MONTH":
if not context.currentMonth or context.currentMonth != month or (year == fYear and month == fMonth and day == fDay):
#context.currentDayNum = dayNum
context.currentMonth = month
else:
return
else:
if context.boundaryTrade == "DAYS":
if not context.currentMonth or not context.currentDayNum or context.currentMonth != month or \
(day - context.currentDayNum)>=context.boundaryDays or (year == fYear and month == fMonth and day == fDay):
context.currentDayNum = day
context.currentMonth = month
else:
return
# At this point the stocks need to be ranked.
# Ensure stocks are only traded if possible.
# (e.g) EDV doesn't start trading until late 2007, without
# this, any backtest run before that date would fail.
stocks = []
for s in context.basket.values():
if date > s.security_start_date:
stocks.append(s)
best = getBestStock(context, datapanel, stocks)
if best is not None:
if (context.currentStock == best):
# Hold current
if context.logHold is True:
log.info('HOLD [%s]' % context.currentStock)
return
elif (context.currentStock is None):
# Buy best
context.currentStock = best
context.nextStock = best
context.oidBuy = buyPositions(context, data)
else:
# Sell ALL and Buy best
context.nextStock = best
#log.info('SELLING ALL POSITIONS - BUYING [%s]' % best)
log.info('BUYING [%s]' % best)
context.oidSell = sellPositions(context)
else:
if context.logWarn is True:
log.warn('COULD NOT FIND A BEST STOCK! BEST STOCK IS *NONE*')
record(buy=context.buyCount, sell=context.sellCount, cash=context.portfolio.cash, pnl=context.portfolio.pnl)
if (year == fYear and month == fMonth and day == fDay):
log.info('PNL $%s, CASH $%s, PORTFOLIO $%s' % (context.portfolio.pnl, context.portfolio.cash, context.portfolio.portfolio_value))