“该策略交易纳斯达克100指数成分股,根据调整后的开盘至收盘价格反应,对超过60天波动率五倍的显著财报日波动,采取2%权重的40天头寸。”

I. 策略概要

该策略针对纳斯达克100指数成分股,识别有财报公告电话会议的股票。投资者将公告日股票调整后的开盘至收盘波动与其60天历史波动进行比较。如果股票的相对波动超过其60天已实现日波动率的五倍,则建立头寸(多头或空头)。每个头寸的权重为投资组合的2%,持有40天,利用财报公告的显著价格反应,同时通过一致的权重和持有期管理风险。

II. 策略合理性

学术研究指出了该策略成功的两个主要原因:对冲基金和股票分析师的延迟反应。由于董事会召集,拥有大量市场波动资金流的对冲基金通常反应缓慢。同样,以滞后反应而闻名的还有股票分析师,尤其是来自大型银行的分析师。在发布有影响力的出版物之后,分析师需要时间重新评估模型、投资案例并重写笔记,从而延迟了市场影响。这些延迟造成了策略所利用的低效性,因为随着时间的推移,大量资金流和修订后的分析会影响股价,而不是在初始信息发布后立即产生影响。

III. 来源论文

财报公告后漂移,一种价格信号?[点击查看论文]

<摘要>

本文研究了基于价格信号的财报后异常波动(PEAD)的稳健性,这与侧重于基本面信号的传统文献不同。研究期间为2003-2015年,针对美国四大主要指数。结果表明,尽管一些经济主体仍然具有重大的市场影响力,但他们整合信息的速度太慢。我们发现了强有力的经验证据,表明这种偏差对动量股而非蓝筹股或非动量小盘股更为突出。即使对该策略提出质疑,结论仍然很强,与这种市场低效率相关的异常回报,正信号的回报优于负信号。我们选择纳斯达克综合指数作为我们发展的基础,因为它最接近Uncia的专业领域。对于被称为动量指数的指数,我们发现系统净敞口的强烈可预测性,后者是收益信号所暗示的多头和空头头寸的结果。

IV. 回测表现

年化回报5.64%
波动率4.03%
β值-0.11
夏普比率1.4
索提诺比率-0.217
最大回撤-7.79%
胜率52%

V. 完整的 Python 代码

from data_tools import CustomFeeModel, TradeManager, SymbolData
from AlgorithmImports import *
import numpy as np
from collections import deque
from typing import Dict, List, Set
from pandas.tseries.offsets import BDay
class PostEarningsAnnouncementDrift(QCAlgorithm):
    def Initialize(self):
        self.SetStartDate(2010, 1, 1) # earnings data start in 2010
        self.SetCash(100000)
        self.period:int = 61
        self.leverage:int = 5
        self.long:List[Symbol] = []
        self.short:List[Symbol] = []
        self.long_count:int = 5
        self.short_count:int = 5
        self.holding_period:int = 40
        
        # monthly selected universe
        self.last_selection:List[Symbol] = []
        
        self.data:Dict[Symbol, SymbolData] = {}
        self.tickers:Set(str) = set()
        # EPS quarterly data
        self.eps:Dict[Symbol, deque] = {}
        self.earnings_data:Dict[datetime.date, List[str]] = {} 
        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_data[date] = []
            for stock_data in obj['stocks']:
                ticker:str = stock_data['ticker']
                self.earnings_data[date].append(ticker)
                self.tickers.add(ticker)
        
        # equally weighted brackets for traded symbols
        self.trade_manager:TradeManager = TradeManager(self, self.long_count, self.short_count, self.holding_period)
        
        self.market:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
        self.fundamental_sorting_key = lambda x: x.DollarVolume
        self.fundamental_count:int = 500
        self.last_month:int = -1
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.FundamentalSelectionFunction)
        self.settings.daily_precise_end_time = False
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
    
    def OnSecuritiesChanged(self, changes:SecurityChanges) -> None:
        for security in changes.AddedSecurities:
            security.SetFeeModel(CustomFeeModel())
            security.SetLeverage(self.leverage)
        
        for security in changes.RemovedSecurities:
            if security.Symbol in self.data:
                if security.Symbol != self.market:
                    del self.data[security.Symbol]
    def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
        if self.Time.month != self.last_month:
            self.last_month = self.Time.month
        
            # in fundamental always select whole universe (stocks, which are in QP earnings data),
            # because prices of each stock in this universe are updated in OnData (due to open price)
            selected:List[Fundamental] = [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]]
            self.last_selection = [x.Symbol for x in selected]
        # warm up prices
        for symbol in self.last_selection + [self.market]:
            if symbol in self.data:
                continue
            
            self.data[symbol] = SymbolData(self.period)
            history:dataframe = self.History(symbol, self.period+1, Resolution.Daily)
            if history.empty:
                continue
            
            closes:Series = history.close
            opens:Series = history.open
            
            for (_, open_price), (_, close) in zip(opens.items(), closes.items()):
                self.data[symbol].update(close, open_price)
        
        # market prices has to be ready
        if self.market not in self.data or not self.data[self.market].is_ready(): 
            return self.last_selection
        prev_business_day:datetime.date = (self.Time - BDay(1)).date()
        if prev_business_day not in self.earnings_data:
            return self.last_selection
        # filter stocks, which had earnings on prev business day
        prev_bussiness_day_earnings:List[str] = self.earnings_data[prev_business_day]
        selected_fundamental:List[Symbol] = [x for x in self.last_selection if x.Value in prev_bussiness_day_earnings]
        
        market_intraday_returns:List[float] = [x if x != 0.0 else 1.0 for x in self.data[self.market].get_intraday_returns()]
        
        for symbol in selected_fundamental:
            # symbol:Symbol = stock.Symbol
            if not self.data[symbol].is_ready():
                continue
            stock_intraday_returns:List[float] = self.data[symbol].get_intraday_returns()
            daily_moves:List[float] = [(stock_intrady_ret / market_intraday_ret) for stock_intrady_ret, market_intraday_ret \
                in zip(stock_intraday_returns, market_intraday_returns)]
                
            std:float = np.std(daily_moves)
            mean:float = np.mean(daily_moves)
        
            if daily_moves[0] > mean + 5 * std:
                self.long.append(symbol)
            elif daily_moves[0] < mean - 5 * std:
                self.short.append(symbol)
        
        return self.last_selection
    def OnData(self, data:Slice) -> None:
        # update stock data for current universe
        for symbol in self.last_selection + [self.market]:
            if symbol in self.data and symbol in data and data[symbol]:
                close:float = data[symbol].Close
                open:float = data[symbol].Open
                self.data[symbol].update(close, open)
        
        self.trade_manager.TryLiquidate()
        # open new trades
        for symbol in self.long:
            if symbol in data and data[symbol]:
                self.trade_manager.Add(symbol, True)
        for symbol in self.short:
            if symbol in data and data[symbol]:
                self.trade_manager.Add(symbol, False)
        self.long.clear()
        self.short.clear()

发表评论

了解 Quant Buffet 的更多信息

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

继续阅读