Quant BuffetRelax, Not Over Thinking

Combining Seasonality and Momentum in US Equity Sectors

Log in to collect

Academic paper

Global Tactical Sector Allocation: A Quantitative Approach

AuthorsRonald Q. Doeswijk; Pim van Vliet

Institute
  • ?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"))