Quant BuffetRelax, Not Over Thinking

Halloween Effect during Low and High CAPE Months

Log in to collect

Academic paper

Stock Return Predictability and Seasonality

AuthorsKeun‐Soo Kim; Jinho Byun

Institute
  • JPInternational Pacific University
  • ?Graduate School of Pan-Pacific International Studies
  • LVCollege of Business Administration
  • KREwha Womans University Medical Center
  • ?Ewha Womans University - College of Business Administration

Strategy in a nutshell

Invests in CRSP value-weighted stocks, adjusting holding periods based on September's CAPE: low CAPE months hold November–October; high CAPE months hold November–April and stay in cash during summer. Portfolio is rebalanced semiannually.

Economic rationale

Captures the Halloween effect: summer returns are weaker after high CAPE months due to mispricing and investor optimism cycles, while winter returns benefit from peak optimism. Effect is robust across weighting schemes and not driven by extreme market events.

Backtest performance

Annualised return12.24%
Volatility17.78%
Beta0.541
Sharpe ratio0.46
Sortino ratio0.163
Win rate77%

Full Python code

from AlgorithmImports import *
from dateutil.relativedelta import relativedelta
class HalloweenEffectCAPEMonths(QCAlgorithm):
def initialize(self) -> None:
 self.set_start_date(2000, 1, 1)
 self.set_cash(100_000)
 self._period: int = 36
 self._traded_symbol: Symbol = self.add_equity('SPY', Resolution.Daily).symbol
 
 self.cape: Symbol = self.add_data(QuantpediaMonthlyData, 'SHILLER_PE_RATIO_MONTH').symbol
 self.cape_data: RollingWindow = RollingWindow[float](self._period)
 
 self._rebalance_flag: bool = False
 self._close_month: int = 0
 self._trading_month: int = 11
 
 self.settings.minimum_order_margin_portfolio_percentage = 0.
 self.settings.daily_precise_end_time = False
 self.schedule.on(
     self.date_rules.month_start(self._traded_symbol), 
     self.time_rules.after_market_open(self._traded_symbol), 
     self._rebalance
 )
def on_data(self, slice: Slice) -> None:
 custom_data_last_update_date: Dict[Symbol, datetime.date] = LastDateHandler.get_last_update_date()
 if self.securities[self.cape].get_last_data() and self.time.date() > custom_data_last_update_date[self.cape]:
     self.liquidate()
     return
 
 if self.cape in slice and slice[self.cape]:
     cape: float = slice[self.cape].value
     self.cape_data.add(cape)
     
 if slice.contains_key(self._traded_symbol) and slice[self._traded_symbol]:
     if not self.cape_data.is_ready: return
     
     if self.time.month == self._close_month:
         self.liquidate(self._traded_symbol)
     
     if self.time.month == self._trading_month:
         if self._rebalance_flag:
             self.set_holdings(self._traded_symbol, 1)
             self._rebalance_flag = False
def _rebalance(self) -> None:
 self._rebalance_flag = True
 # Trade in October in order to have September CAPE data.
 if self.time.month != 10: return

 cape_values: List[float] = list(self.cape_data)
 cape_median: float = median(cape_values)
 cape: float = self.cape_data[0]
 
 if cape < cape_median:
     self._close_month = 10
 else:
     self._close_month = 4
 
class LastDateHandler():
_last_update_date: Dict[Symbol, datetime.date] = {}

@staticmethod
def get_last_update_date() -> Dict[Symbol, datetime.date]:
return LastDateHandler._last_update_date
# Quantpedia monthly custom data.
# NOTE: IMPORTANT: Data order must be ascending (datewise)
class QuantpediaMonthlyData(PythonData):
def GetSource(self, config: SubscriptionDataConfig, date: datetime, isLiveMode: bool) -> SubscriptionDataSource:
 return SubscriptionDataSource(f'data.quantpedia.com/backtesting_data/economic/{config.Symbol.Value}.csv', SubscriptionTransportMedium.RemoteFile, FileFormat.Csv)
def Reader(self, config: SubscriptionDataConfig, line: str, date: datetime, isLiveMode: bool) -> BaseData:
 data = QuantpediaMonthlyData()
 data.Symbol = config.Symbol
 
 if not line[0].isdigit(): return None
 split: str = line.split(';')
 
 data.Time = datetime.strptime(split[0], "%Y-%m-%d") + relativedelta(months=1)
 data.Value = float(split[1])
 if config.Symbol not in LastDateHandler._last_update_date:
     LastDateHandler._last_update_date[config.Symbol] = datetime(1,1,1).date()
 if data.Time.date() > LastDateHandler._last_update_date[config.Symbol]:
     LastDateHandler._last_update_date[config.Symbol] = data.Time.date()
 return data