Combining Seasonality and Momentum in US Equity Sectors
Log in to collectAcademic paper
Global Tactical Sector Allocation: A Quantitative Approach
Ronald Q. Doeswijk; Pim van Vliet
- ?Independent
- ?Robeco Quantitative Investments
Strategy in a nutshell
Classify sectors as cyclical, defensive, or neutral. Each month, assign scores based on 12-month momentum, 1-month momentum, and seasonality. Go long sectors scoring >9, short those <3; close positions when scores exceed 6.
Economic rationale
Seasonal return patterns arise from investor psychology (SAD, year-end optimism) and behavioral biases (herding, overreaction, underreaction), which drive momentum persistence in sector performance.
Backtest performance
Annualised return12.9%
Volatility17%
Beta-0.734
Sharpe ratio0.52
Maximum drawdown-29.9%
Win rate37%
Full Python code
from AlgorithmImports import *
#endregion
class SeasonalityandMomentum(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2007, 1, 1)
self.SetCash(100000)
self.cyclical = ["VAW", "XLI", "XLY"]
self.defensive = ["XLP", "XLV", "VGT", "XLU"]
self.neutral = ["XLK", "XLF", "XLE", "VNQ"]
self.symbols = self.cyclical + self.defensive + self.neutral
self.period = 21
self.SetWarmUp(self.period)
self.short_momentum = {}
self.long_momentum = {}
for symbol in self.symbols:
data = self.AddEquity(symbol, Resolution.Daily)
data.SetLeverage(10)
data.SetFeeModel(CustomFeeModel())
self.short_momentum[symbol] = self.ROC(symbol, self.period, Resolution.Daily)
self.long_momentum[symbol] = self.ROC(symbol, 12*self.period, Resolution.Daily)
self.recent_month = -1
def OnData(self, data):
if self.IsWarmingUp: return
if self.Time.month == self.recent_month:
return
self.recent_month = self.Time.month
returns_12M = { x : self.long_momentum[x].Current.Value for x in self.symbols if self.long_momentum[x].IsReady and x in data and data[x] }
returns_1M = { x : self.short_momentum[x].Current.Value for x in self.symbols if self.short_momentum[x].IsReady and x in data and data[x] }
if len(returns_12M) < 4 and len(returns_1M) < 4:
self.Liquidate()
return
score = { x : 0 for x in self.symbols }
# 12M Momentum Sorting
count = 4
sorted_by_12M = sorted(returns_12M.items(), key=lambda x: x[1], reverse = True)
sorted_by_12M = [x[0] for x in sorted_by_12M][:count]
points = count
for symbol in sorted_by_12M:
score[symbol] += points
points -= 1
# 1M Momentum Sorting
sorted_by_1M = sorted(returns_1M.items(), key=lambda x: x[1], reverse = True)
sorted_by_1M = [x[0] for x in sorted_by_1M][:count]
points = count
for symbol in sorted_by_1M:
score[symbol] += points
points -= 1
# Seasonality score
for symbol in self.neutral:
score[symbol] += 2
if self.Time.month <= 4 or self.Time.month >= 11 :
for symbol in self.cyclical:
score[symbol] += 4
elif self.Time.month >= 5 and self.Time.month <= 10:
for symbol in self.defensive:
score[symbol] += 4
# Trade execution
long = [x[0] for x in score.items() if x[1] > 9]
short = [x[0] for x in score.items() if x[1] < 3]
invested = [x.Key.Value for x in self.Portfolio if x.Value.Invested]
for symbol in invested:
if symbol not in long + short:
self.Liquidate(symbol)
for symbol in long:
self.SetHoldings(symbol, 1 / len(long))
for symbol in short:
self.SetHoldings(symbol, -1 / len(short))
# Custom fee model.
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters):
fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))