
“该策略基于预测的季节性投资股票,使用过去的盈利数据对公司进行排名。投资者做多排名靠前的股票,做空排名靠后的股票,每日重新平衡。”
资产类别: 股票 | 地区: 美国 | 周期: 每日 | 市场: 股票 | 关键词: 盈利公告、季节性效应
I. 策略概要
投资范围包括在纽约证券交易所、美国证券交易所和纳斯达克上市的普通股,不包括价格低于5美元的股票或缺少市值或盈利数据的股票。为了预测季节性,该策略使用过去20个季度的五年盈利数据。对于20个季度中的每一个季度,公司的每股收益(经股票拆分调整后)都从最大到最小进行排名。季度t的盈利排名(earn-rank)是季度t-4、t-8、t-12、t-16和t-20的平均排名。投资者做多盈利排名最高的5%的公司,做空排名最低的5%的公司。当多头和空头都至少包含十家公司时,形成投资组合。持仓在t-1日开仓,持有至t+1日,t日为盈利公告日。投资组合等权重,每日重新平衡。该策略旨在利用盈利公告的季节性,即历史盈利排名较高的公司预计相对于排名较低的公司表现更好。
II. 策略合理性
近因效应导致投资者对近期信息的重视程度高于较早的数据。在盈利方面,投资者往往会过度强调近期较低的盈利,而忽略去年同期较高的盈利。这会导致对未来盈利公告的过度悲观预期,从而增加积极意外的可能性。虽然投资者会考虑前几个季度的盈利数据,但最近的盈利往往更为突出,使近期信息在形成他们的预期和决策时更具影响力。
III. 来源论文
Being Surprised by the Unsurprising: Earnings Seasonality and Stock Returns [点击查看论文]
- 汤·Y·张(Tom Y. Chang)、塞缪尔·M·哈茨马克(Samuel M. Hartzmark)、大卫·H·所罗门(David H. Solomon)和尤金·F·索尔特斯(Eugene F. Soltes), 南加州大学 – 马歇尔商学院 – 金融与商业经济学系, 波士顿学院 – 卡罗尔管理学院, 波士顿学院 – 卡罗尔管理学院, 哈佛大学 – 商学院(HBS)
<摘要>
我们提供的证据表明,市场未能正确定价季节性盈利模式中的信息。在一年中某个季度历史盈利较高的公司(“正季节性季度”),在通常公布这些盈利时,其回报更高。分析师在正季节性季度有更积极的预测误差,这与回报是由错误的盈利估计驱动的观点一致。我们表明,投资者似乎对正季节性季度后近期较低的盈利赋予了过高的权重,导致在随后的正季节性季度中出现悲观的预测。回报不能用基于风险的解释、公司特定信息、交易量增加或特质波动率来解释。


IV. 回测表现
| 年化回报 | 37.18% |
| 波动率 | N/A |
| β值 | -0.01 |
| 夏普比率 | N/A |
| 索提诺比率 | -0.576 |
| 最大回撤 | N/A |
| 胜率 | 48% |
V. 完整的 Python 代码
from AlgorithmImports import *
from data_tools import TradeManager
import numpy as np
from collections import deque
from pandas.tseries.offsets import BDay
from dateutil.relativedelta import relativedelta
from typing import List, Dict
#endregion
class EarningsAnnouncementSeasonalityEffectinEquities(QCAlgorithm):
def Initialize(self) -> None:
self.SetStartDate(2010, 1, 1)
self.SetCash(100_000)
self.period: int = 20
self.long_symbols: int = 10
self.short_symbols: int = 10
self.holding_period: int = 4
self.leverage: int = 5
self.quantile: int = 20
self.prev_month: int = -1
self.prev_month_year: int = -1
symbol: Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
self.long: List[Symbol] = []
self.short: List[Symbol] = []
self.eps: Dict[Symbol, deque] = {}
self.earnings: Dict[datetime.date, List[str]] = {}
self.eps_data: Dict[int, Dict[int, Dict[str, Dict[datetime.date, float]]]] = {}
# parse earnings dataset - Source: https://www.nasdaq.com/market-activity/earnings
earnings_data: str = self.Download('data.quantpedia.com/backtesting_data/economic/earnings_dates_eps.json')
earnings_data_json: List[dict] = json.loads(earnings_data)
for obj in earnings_data_json:
date: datetime.date = datetime.strptime(obj['date'], '%Y-%m-%d').date()
year: int = date.year
month: int = date.month
self.earnings[date] = []
for stock_data in obj['stocks']:
ticker: str = stock_data['ticker']
self.earnings[date].append(ticker)
if stock_data['eps'] == '':
continue
if year not in self.eps_data:
self.eps_data[year] = {}
if month not in self.eps_data[year]:
self.eps_data[year][month] = {}
if ticker not in self.eps_data[year][month]:
self.eps_data[year][month][ticker] = {}
self.eps_data[year][month][ticker][date] = float(stock_data['eps'])
# equally weighted brackets for traded symbols
# hodling period 3 days + 1 day due to QC midnight offset (00:00 trading)
self.trade_manager: TradeManager = TradeManager(self, self.long_symbols, self.short_symbols, self.holding_period)
self.selection_flag: bool = False
self.settings.daily_precise_end_time = False
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelectionFunction)
self.Schedule.On(self.DateRules.MonthStart(symbol), self.TimeRules.AfterMarketOpen(symbol), 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]:
if not self.selection_flag:
return Universe.Unchanged
self.selection_flag = False
prev_month_date: datetime.date = (self.Time - relativedelta(months=1)).date()
self.prev_month_year: int = prev_month_date.year
self.prev_month: int = prev_month_date.month
if self.prev_month_year not in self.eps_data or self.prev_month not in self.eps_data[self.prev_month_year]:
return Universe.Unchanged
# select stocks, which has earning in previous month
stocks_with_prev_month_eps: Dict[str, Dict[datetime.date, float]] = self.eps_data[self.prev_month_year][self.prev_month]
selected: List[Fundamental] = [x for x in fundamental if x.Symbol.Value in stocks_with_prev_month_eps]
for stock in selected:
symbol: Symbol = stock.Symbol
ticker: str = symbol.Value
# store eps data
if symbol not in self.eps:
self.eps[symbol] = deque(maxlen=self.period)
# get all stock's eps from previous month
stock_prev_month_eps: Dict[datetime.date, float] = self.eps_data[self.prev_month_year][self.prev_month][ticker]
# get the date of the latest eps in previous month
stock_latest_eps_date:datetime.date = list(stock_prev_month_eps.keys())[-1]
# store stock's latest eps date in previous month and it's eps value
data_eps: List[datetime.date, float] = [stock_latest_eps_date, stock_prev_month_eps[stock_latest_eps_date]]
self.eps[symbol].append(data_eps)
# Earn rank calc
earn_rank: Dict[Symbol, float] = {}
for symbol in self.eps:
if len(self.eps[symbol]) != self.eps[symbol].maxlen:
continue
relevant_earnings_dates:List[datetime.date] = [self.eps[symbol][-4][0], self.eps[symbol][-8][0], \
self.eps[symbol][-12][0] ,self.eps[symbol][-16][0], self.eps[symbol][-20][0]]
# stock with potencial upcoming month earnings
eps_months: List[int] = [x.month for x in relevant_earnings_dates]
if self.Time.month in eps_months:
# rank the 20 quarters of earnings data from largest to smallest
ranked_earnings: List[Tuple[datetime.date, float]] = [i for i in sorted(list(self.eps[symbol]), key=lambda x: x[1], reverse=True)]
ranks: List[int] = [ranked_earnings.index(earnings_data)+1 for earnings_data in ranked_earnings if earnings_data[0] in relevant_earnings_dates]
earn_rank[symbol] = np.mean(ranks)
if len(earn_rank) < self.quantile:
return Universe.Unchanged
sorted_by_earn_rank: List[Tuple[Symbol, float]] = sorted(earn_rank.items(), key=lambda x: x[1], reverse=True)
quantile: int = int(len(sorted_by_earn_rank) / self.quantile)
# symbols to trade this month
self.long = [x[0] for x in sorted_by_earn_rank[:quantile]]
self.short = [x[0] for x in sorted_by_earn_rank[-quantile:]]
return self.long + self.short
def OnData(self, data: Slice) -> None:
earnings_date: datetime.date = (self.Time + BDay(1)).date()
if earnings_date in self.earnings:
for symbol in self.long + self.short:
if symbol not in data or not data[symbol]:
continue
ticker:str = symbol.Value
# symbol has earnings in 1 day
if ticker not in self.earnings[earnings_date]:
continue
if symbol in self.long:
self.trade_manager.Add(symbol, True)
else:
self.trade_manager.Add(symbol, False)
self.trade_manager.TryLiquidate()
def Selection(self) -> None:
self.long.clear()
self.short.clear()
self.selection_flag = True
# Custom fee model
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters: OrderFeeParameters) -> OrderFee:
fee: float = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))