投资宇宙包括在纽约证券交易所、美国证券交易所和纳斯达克上市的股票,其每日价格数据来自CRSP数据库,财报发布日期来自Compustat数据库。投资者首先根据公司规模将股票分为五分位组,然后在规模最大的组中,按财报发布日前3天的平均回报率进行排序。在财报发布日的前一天到后一天(t-1、t、t+1),做多过去表现最差的五分位组,做空表现最好的五分位组,股票按等权重分配。

策略概述

投资宇宙包括在纽约证券交易所、美国证券交易所和纳斯达克上市的股票,其每日价格数据可从CRSP数据库获取。财报发布日期来源于Compustat数据库。首先,投资者根据公司规模将股票分为五分位组。然后,他进一步将规模最大的五分位组股票按财报发布日前3天(t-4至t-2)期间的平均回报率进行排序,其中t为财报发布日。投资者在t-1、t、t+1这3天内,做多最低五分位组(过去的亏损者)并做空最高五分位组(过去的赢家)。投资组合中的股票按等权重分配。

策略合理性

通常,资产价格的反转发生在投资者对资产相关消息的过度反应后,价格随之进行修正。在本案例中,根据作者的观点,这种现象的最可能原因是市场做市商对库存风险的厌恶,这些风险在财报发布前的期间显著增加。因此,做市商因更高的风险要求提供流动性的更高补偿,从而提高价格,预期在财报发布后价格会反转。

论文来源

News-Driven Return Reversals: Liquidity Provision Ahead of Earnings Announcements [点击浏览原文]

<摘要>

本研究记录了财报发布期间短期回报反转率相对于非公告期的六倍增加。按照先前的研究,我们将反转视为市场做市商为提供流动性而要求的预期回报的代理。我们的研究结果强调了短期回报反转幅度的显著时间序列变化,并表明市场做市商在财报发布前由于持有净头寸而面临的库存风险增加,因而要求更高的预期回报。总体而言,我们的研究表明,市场对预期信息事件的不确定性引发了流动性供应预期回报的可预测性增加,并且这些增加显著影响了市场价格的动态性和信息内容。

回测表现

年化收益率6.5%
波动率3.76%
Beta0.09
夏普比率0.126
索提诺比率0.111
最大回撤65.1%
胜率51%

完整python代码

import data_tools
from AlgoLib import *
import numpy as np
from typing import Dict, List
from collections import deque

class ReversalDuringEarningsAnnouncements(XXX):
    
    def Initialize(self):
        self.SetStartDate(2010, 1, 1) # earnings dates start in 2010
        self.SetCash(100000)

        self.long_count:int = 20
        self.short_count:int = 20
        self.holding_period:int = 3

        self.leverage:int = 5
        self.ear_period:int = 4
        self.symbol:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
        
        # Daily price data.
        self.data:Dict[Symbol, RollingWindow] = {}
        
        # Import earnigns data.
        self.earnings_data:Dict[datetime, List[str]] = {}
        
        # Available symbols from earning_dates dataset.
        self.tickers:Set(str) = set()
        
        self.first_date:Union[None, datetime.date] = None

        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()
            self.earnings_data[date] = []
            
            if not self.first_date: self.first_date = date

            for stock_data in obj['stocks']:
                ticker:str = stock_data['ticker']

                self.earnings_data[date].append(ticker)
                self.tickers.add(ticker)

        # EAR history for previous quarter used for statistics. 
        self.ear_previous_quarter:List[float] = []
        self.ear_actual_quarter:List[float] = []
    
        # 5 equally weighted brackets for traded symbols. - 20 symbols long , 20 for short, 3 days of holding.
        self.trade_manager = data_tools.TradeManager(self, self.long_count, self.short_count, self.holding_period)
        
        self.month:int = 0
        self.fundamental_sorting_key = lambda x: x.DollarVolume
        self.fundamental_count:int = 1000
        self.selection_flag = False
        self.rebalance_flag = False
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.FundamentalSelectionFunction)
        self.Schedule.On(self.DateRules.MonthEnd(self.symbol), self.TimeRules.AfterMarketOpen(self.symbol), self.Selection)

    def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
        for security in changes.AddedSecurities:
            security.SetFeeModel(data_tools.CustomFeeModel())
            security.SetLeverage(self.leverage)

    def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
        # update daily prices
        for stock in fundamental:
            symbol:Symbol = stock.Symbol
            
            if symbol in self.data:
                self.data[symbol].Add(stock.AdjustedPrice)
        
        if not self.selection_flag:
            return Universe.Unchanged
        self.selection_flag = False
    
        selected:List[Symbol] = [x for x in fundamental if x.HasFundamentalData and x.Market == 'usa' and x.Symbol.Value in self.tickers]

        if len(selected) > self.fundamental_count:
            selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]

        for stock in selected:
            symbol:Symbol = stock.Symbol
            if symbol in self.data:
                continue
            
            self.data[symbol] = RollingWindow[float](self.ear_period)
            history:DataFrame = self.History(symbol, self.ear_period, Resolution.Daily)
            if history.empty:
                self.Log(f"Not enough data for {symbol} yet")
                continue
            
            closes:Series = history.loc[symbol].close
            for time, close in closes.items():
                self.data[symbol].Add(close)
        
        return list(map(lambda x: x.Symbol, selected))

    def OnData(self, data: Slice) -> None:
        date_to_lookup = (self.Time + timedelta(days=1)).date()
        
        # Liquidate opened symbols after three days.
        self.trade_manager.TryLiquidate()
        
        ret_t4_t2 = {}
        
        for symbol in self.data:
            # Data is ready.
            if self.data[symbol].IsReady:
                # Earnings is in next two day for the symbol.
                if date_to_lookup in self.earnings_data and symbol.Value in self.earnings_data[date_to_lookup]:
                    closes = [x for x in self.data[symbol]]
                    if closes[-1] != 0:
                        # Calculate t-4 to t-2 return.
                        ret = (closes[0] - closes[-1]) / closes[-1]
                        ret_t4_t2[symbol] = ret
                        
                        # Store return in this month's history.
                        self.ear_actual_quarter.append(ret)
            
        # Wait until we have history data for previous three months.
        if len(self.ear_previous_quarter) != 0:
            # Sort by EAR.
            ear_values = self.ear_previous_quarter
            top_ear_quintile = np.percentile(ear_values, 80)
            bottom_ear_quintile = np.percentile(ear_values, 20)
            
            # Store symbol to set.
            long = [x[0] for x in ret_t4_t2.items() if x[1] <= bottom_ear_quintile and x[0] in data and data[x[0]]]
            short = [x[0] for x in ret_t4_t2.items() if x[1] >= top_ear_quintile and x[0] in data and data[x[0]]]
            
            # Open new trades.
            for symbol in long:
                self.trade_manager.Add(symbol, True)
            for symbol in short:
                self.trade_manager.Add(symbol, False)

    def Selection(self) -> None:
        # There is no earnings data yet.
        if self.Time.date() < self.first_date:
            return

        self.selection_flag = True
        
        # Every three months.    
        if self.month % 3 == 0:
            # Save quarter history.
            self.ear_previous_quarter = [x for x in self.ear_actual_quarter]
            self.ear_actual_quarter.clear()

        self.month += 1

Leave a Reply

Discover more from Quant Buffet

Subscribe now to keep reading and get access to the full archive.

Continue reading