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.

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 Return12.24%
Volatility17.78%
Beta0.541
Sharpe Ratio0.46
Sortino Ratio0.163
Maximum DrawdownN/A
Win Rate77%

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

Leave a Reply

Discover more from Quant Buffet

Subscribe now to keep reading and get access to the full archive.

Continue reading