Quant Buffet放轻松,别过度思虑

股票市场中的盈利公告季节性效应

登录后收藏

学术论文

Being Surprised by the Unsurprising: Earnings Seasonality and Stock Returns

作者Tom Chang; Samuel M. Hartzmark; David H. Solomon; Eugene F. Soltes

机构
  • University of Southern California
  • ?University of Southern California - Marshall School of Business - Finance and Business Economics Department
  • University of Chicago
  • ?University of Chicago - Booth School of Business
  • Boston College
  • ?Boston College - Carroll School of Management
  • Harvard University
  • ?Harvard University - Business School (HBS)

策略概要

投资范围包括在纽约证券交易所、美国证券交易所和纳斯达克上市的普通股,不包括价格低于5美元的股票或缺少市值或盈利数据的股票。为了预测季节性,该策略使用过去20个季度的五年盈利数据。对于20个季度中的每一个季度,公司的每股收益(经股票拆分调整后)都从最大到最小进行排名。季度t的盈利排名(earn-rank)是季度t-4、t-8、t-12、t-16和t-20的平均排名。投资者做多盈利排名最高的5%的公司,做空排名最低的5%的公司。当多头和空头都至少包含十家公司时,形成投资组合。持仓在t-1日开仓,持有至t+1日,t日为盈利公告日。投资组合等权重,每日重新平衡。该策略旨在利用盈利公告的季节性,即历史盈利排名较高的公司预计相对于排名较低的公司表现更好。

II. 策略合理性

近因效应导致投资者对近期信息的重视程度高于较早的数据。在盈利方面,投资者往往会过度强调近期较低的盈利,而忽略去年同期较高的盈利。这会导致对未来盈利公告的过度悲观预期,从而增加积极意外的可能性。虽然投资者会考虑前几个季度的盈利数据,但最近的盈利往往更为突出,使近期信息在形成他们的预期和决策时更具影响力。

回测表现

索提诺比率-0.576
胜率48%

完整 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"))