
The strategy uses September’s CAPE ratio to guide stock holdings, investing year-round during low CAPE months and seasonally during high CAPE months, with semi-annual rebalancing for optimized returns.
ASSET CLASS: CFDs, ETFs, funds, futures | REGION: United States | FREQUENCY:
6 Months | MARKET: equities | KEYWORD: Halloween Effect, CAPE
I. 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.
II. 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.
III. SOURCE PAPER
Stock Return Predictability and Seasonality [Click to Open PDF]
Keunsoo Kim — Graduate School of Pan-Pacific International Studies; Jinho Byun — Ewha Womans University – College of Business Administration.
<Abstract>
An examination of the Shiller cyclically adjusted pricing-earnings (CAPE) ratio reveals its forecasting power for 12-month CRSP equally weighted (EW) excess returns and value weighted (VW) excess returns. The 12-month EW excess returns following low CAPE ratios are, on average, 20.7% higher than those following high CAPE ratios for the period of 1927-2016. This dichotomy in the Shiller CAPE ratio has a more reliable predictability than the January barometer. Previous studies report that the Halloween indicator was weak or negative in the US stock market prior to the 1950s. We find that the Halloween effect is strongly present following high CAPE ratios, even for the period of 1926-1971. Our results recommend a practical investment strategy. More specifically, if the CAPE ratio in September is lower than the 36-month median of the CAPE ratio, invest in stock markets from November to October of the following year; otherwise, invest for six months from November to April and sell in May and go away.


IV. BACKTEST PERFORMANCE
| Annualised Return | 12.24% |
| Volatility | 17.78% |
| Beta | 0.541 |
| Sharpe Ratio | 0.46 |
| Sortino Ratio | 0.163 |
| Maximum Drawdown | N/A |
| Win Rate | 77% |
V. 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