
“该策略使用9月份的CAPE比率来指导股票持有量,在低CAPE月份全年投资,在高CAPE月份季节性投资,并进行半年一次的重新平衡以优化回报。”
资产类别: 差价合约、ETFs、基金、期货 | 地区: 美国 | 周期: 6个月 | 市场: 股票 | 关键词: 万圣节效应
I. 策略概要
该策略投资于CRSP价值加权股票,并根据9月份的CAPE比率确定持有期。如果9月份的CAPE低于36个月的中位数,则为低CAPE月份,股票持有期为11月至次年10月。如果9月份的CAPE较高,则股票持有期为11月至4月,夏季持有现金。投资组合每六个月重新平衡一次,提供了一种简单的基于季节性和估值的方法,使用CAPE作为指导来优化回报。该策略可以通过ETF、差价合约、期货或指数基金实施。
II. 策略合理性
万圣节效应主要源于高CAPE月份之后的负夏季回报,从而导致这些时期的年度回报较低。高CAPE月份之后的夏季回报(无论是等权重回报还是价值加权回报)都比冬季回报风险更高。乐观周期假说解释了这种现象:由于自我归因偏差,投资者对经济的乐观情绪在最后一季度达到顶峰,从而推动了冬季的高回报。随着现实的到来,乐观情绪消退,导致夏季回报不佳。然而,万圣节效应在高CAPE月份之后更为明显,这表明错误定价和乐观周期假说共同作用。季节性假期模式和季节性情感障碍无法解释高CAPE月份之后夏季回报较低的原因。重要的是,该效应并非由1929年华尔街崩盘或2007-2008年金融危机等极端事件驱动,这突显了其稳健性。总之,这些因素说明了投资者心理和市场估值在塑造季节性股票回报模式方面的相互作用。
III. 来源论文
Stock Return Predictability and Seasonality [点击查看论文]
- 金根洙(Keunsoo Kim)与卞鎮鎬(Jinho Byun)。泛太平洋国际研究研究生院,梨花女子大学商学院。
<摘要>
对席勒周期性调整市盈率(CAPE)比率的考察揭示了其对12个月CRSP等权重(EW)超额回报和价值加权(VW)超额回报的预测能力。1927年至2016年期间,低CAPE比率之后的12个月EW超额回报平均比高CAPE比率之后的超额回报高20.7%。席勒CAPE比率的这种二分法比一月晴雨表具有更可靠的预测性。先前的研究报告称,在20世纪50年代之前,美国股市的万圣节指标较弱或为负。我们发现,即使在1926年至1971年期间,高CAPE比率之后万圣节效应也强烈存在。我们的结果推荐了一种实用的投资策略。更具体地说,如果9月份的CAPE比率低于CAPE比率的36个月中位数,则从11月到次年10月投资股票市场;否则,从11月到4月投资六个月,并在5月卖出并离场。


IV. 回测表现
| 年化回报 | 12.24% |
| 波动率 | 17.78% |
| β值 | 0.541 |
| 夏普比率 | 0.46 |
| 索提诺比率 | 0.163 |
| 最大回撤 | N/A |
| 胜率 | 77% |
V. 完整的 Python 代码
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