
“分析美国股票的季节性回报和盈利公告,通过做多具有先前公告的季节性赢家和做空没有先前公告的季节性输家进行交易,使用价值加权头寸每月重新平衡。”
资产类别: 股票 | 地区: 美国 | 周期: 每月 | 市场: 股票 | 关键词: 季节性、信息周期
I. 策略概要
投资范围包括CRSP数据库中拥有普通股的美国股票,以及Compustat的盈利公告数据(RDQ)。为了衡量回报季节性,股票每月根据其过去五年同一日历月的平均回报分为五分位,从而识别季节性赢家和输家。每个季节性五分位中的股票进一步分为两组:那些有先前季节性盈利公告的(过去大多数季节性月份有公告)和那些没有的。如果一只股票的RDQ比例超过50%,则认为其很可能公布盈利。该策略做多有先前信息发布的季节性赢家,做空没有信息发布的季节性输家,每月进行价值加权头寸的再平衡。
II. 策略合理性
季节性异象通常通过比较有信息事件和无信息事件的月份来解释,结果发现两种情况下都存在显著的季节性,从而否定信息事件是其成因。然而,本研究采用了不同的方法,比较与信息周期一致的季节性与不一致的模式。研究使用季度财报公告作为计划性信息发布的代理变量,发现当季节性与信息周期一致时,其效应更为显著。在有事件的月份中,季节性赢家股票和在无事件的月份中,季节性输家股票共同驱动了这一异象。这与计划性公告期间不确定性解除的机制一致——投资者因不确定性而要求风险溢价。计划性事件消除了公司层面上的不确定性,导致公告期间收益较高,而在公告前的累积期间收益较低。此发现凸显了公司层面信息周期在驱动季节性异象中的重要作用。
III. 来源论文
The information cycle aand return seasonability [点击查看论文]
- Haoyuan, Liy, Roger K. Lohz。对外经济贸易大学 (UIBE)。南洋理工大学 – 南洋商学院
<摘要>
Heston和Sadka(2008)发现横截面股票回报取决于其历史同期日历月回报。我们为这种季节性异常现象提出了一个信息周期解释——即公司的季节性信息发布导致在信息不确定性消除的月份中获得更高的回报,而在没有信息发布的月份中获得更低的回报。我们使用过去的盈利公告和隐含波动率的下降作为预定信息事件的代理,确实发现事件月份的季节性赢家和非事件月份的季节性输家推动了季节性异常。因此,回报季节性实际上可以与投资者对信息不确定性的理性反应相一致。


IV. 回测表现
| 年化回报 | 12.95% |
| 波动率 | 14.74% |
| β值 | -0.563 |
| 夏普比率 | 0.88 |
| 索提诺比率 | -0.697 |
| 最大回撤 | N/A |
| 胜率 | 42% |
V. 完整的 Python 代码
from AlgorithmImports import *
from dateutil.relativedelta import relativedelta
from pandas.core.frame import dataframe
class ReturnSeasonalityAndInformationCycle(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2000, 1, 1)
self.SetCash(100000)
self.data:Dict[Symbol, RollingWindow] = {}
self.weight:Dict[Symbol, float] = {}
self.seasonal_data:Dict[Symbol, SymbolData] = {}
self.period:int = 21
self.seasonal_period:int = 5
self.require_file_dates:int = 3
self.leverage:int = 5
market:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
self.fundamental_count:int = 3000
self.fundamental_sorting_key = lambda x: x.MarketCap
self.selection_flag:int = False
self.UniverseSettings.Resolution = Resolution.Daily
self.settings.daily_precise_end_time = False
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.AddUniverse(self.FundamentalSelectionFunction)
self.Schedule.On(self.DateRules.MonthStart(market), self.TimeRules.AfterMarketOpen(market), self.Selection)
def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
for security in changes.AddedSecurities:
security.SetFeeModel(CustomFeeModel())
security.SetLeverage(self.leverage)
def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
# Update the rolling window every day.
for stock in fundamental:
symbol:Symbol = stock.Symbol
# Store daily price.
if symbol in self.data:
self.data[symbol].Add(stock.AdjustedPrice)
if not self.selection_flag:
return Universe.Unchanged
selected:List[Fundamental] = [x for x in fundamental if x.HasFundamentalData and x.Market == 'usa' and x.MarketCap != 0]
if len(selected) > self.fundamental_count:
selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]
stock_file_dates:Dict[Fundamental, int] = {}
avg_performances:Dict[Fundamental, float] = {}
current_year:int = self.Time.year
current_month_year:str = str(self.Time.month) + '-' + str(current_year)
# Warmup price rolling windows.
for stock in selected:
symbol:Symbol = stock.Symbol
stock_file_date:datetime.date = stock.EarningReports.FileDate
if symbol not in self.data:
self.data[symbol] = RollingWindow[float](self.period)
history:dataframe = self.History(symbol, self.period, Resolution.Daily)
if history.empty:
self.Log(f"Not enough data for {symbol} yet")
continue
closes:pd.Series = history.loc[symbol].close
for time, close in closes.items():
self.data[symbol].Add(close)
# calculate metrics
if self.data[symbol].IsReady:
if symbol not in self.seasonal_data:
self.seasonal_data[symbol] = SymbolData(self.seasonal_period)
performance:float = self.data[symbol][0] / self.data[symbol][self.data[symbol].Count - 1] - 1
self.seasonal_data[symbol].months_perfs[current_month_year] = performance
self.seasonal_data[symbol].file_dates[current_month_year] = stock_file_date # Check value
if self.seasonal_data[symbol].last_year != current_year:
self.seasonal_data[symbol].last_year = current_year
self.seasonal_data[symbol].update(current_year)
if self.seasonal_data[symbol].is_ready():
seasonal_file_dates:int = 1
seasonal_perfs:List[float] = [performance]
look_date:datetime.date = self.Time.date()
while len(seasonal_perfs) < self.seasonal_period:
look_date:datetime.date = look_date - relativedelta(years=1)
look_month_year:str = str(look_date.month) + '-' + str(look_date.year)
if look_month_year in self.seasonal_data[symbol].months_perfs:
month_performance:float = self.seasonal_data[symbol].months_perfs[look_month_year]
seasonal_perfs.append(month_performance)
month_file_date:datetime.date = self.seasonal_data[symbol].file_dates[look_month_year]
if stock_file_date != month_file_date:
stock_file_date = month_file_date
seasonal_file_dates += 1
else:
break
if len(seasonal_perfs) < self.seasonal_period: # We don't have enough data for this stock
continue
stock_file_dates[stock] = seasonal_file_dates
avg_performances[stock] = mean(seasonal_perfs)
if len(avg_performances) == 0:
return Universe.Unchanged
long:List[Fundamental] = []
short:List[Fundamental] = []
for stock, avg in avg_performances.items():
if avg >= 0 and stock in stock_file_dates and stock_file_dates[stock] >= self.require_file_dates:
long.append(stock)
elif avg < 0 and stock in stock_file_dates and stock_file_dates[stock] < self.require_file_dates:
short.append(stock)
# value weighting
for i, portfolio in enumerate([long, short]):
mc_sum:float = sum(map(lambda x: x.MarketCap, portfolio))
for stock in portfolio:
self.weight[stock.Symbol] = ((-1) ** i) * stock.MarketCap / mc_sum
return list(self.weight.keys())
def OnData(self, data: Slice) -> None:
if not self.selection_flag:
return
self.selection_flag = False
# Trade execution.
portfolio:List[PortfolioTarget] = [PortfolioTarget(symbol, w) for symbol, w in self.weight.items() if symbol in data and data[symbol]]
self.SetHoldings(portfolio, True)
self.weight.clear()
def Selection(self) -> None:
self.selection_flag = True
class SymbolData():
def __init__(self, period: int):
self._years:RollingWindow = RollingWindow[int](period)
self.months_perfs = {}
self.file_dates = {}
self.last_year = -1
def update(self, year: int) -> None:
self._years.Add(year)
def is_ready(self) -> bool:
return self._years.IsReady
# Custom fee model
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters):
fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))