“该策略基于预测的季节性投资股票,使用过去的盈利数据对公司进行排名。投资者做多排名靠前的股票,做空排名靠后的股票,每日重新平衡。”

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 [点击查看论文]

<摘要>

我们提供的证据表明,市场未能正确定价季节性盈利模式中的信息。在一年中某个季度历史盈利较高的公司(“正季节性季度”),在通常公布这些盈利时,其回报更高。分析师在正季节性季度有更积极的预测误差,这与回报是由错误的盈利估计驱动的观点一致。我们表明,投资者似乎对正季节性季度后近期较低的盈利赋予了过高的权重,导致在随后的正季节性季度中出现悲观的预测。回报不能用基于风险的解释、公司特定信息、交易量增加或特质波动率来解释。

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

发表评论

了解 Quant Buffet 的更多信息

立即订阅以继续阅读并访问完整档案。

继续阅读