“通过DPP交易美国股票,在坏消息日后做多最高DPP五分位,做空最低DPP五分位,持有头寸60个交易日。”

I. 策略概要

投资范围包括CRSP数据库中的美国股票(纽约证券交易所、美国证券交易所、纳斯达克)。关键变量是购买价格差异(DPP),每月使用An(2016)的方法计算。DPP由两部分组成:(1)基于股票以特定价格购买但尚未交易的概率(使用换手率)的权重,以及(2)当前价格与n个周期前购买价格之间的绝对差异,除以当前价格(详细信息见第15页的公式3)。

新闻日通过极端回报来识别,定义为异常回报的绝对值超过2(使用Fama-French三因子模型)。负极端回报表示坏消息日。

每月,股票被分为DPP五分位。在坏消息日之后,做多来自最高五分位(最高DPP)的股票,做空来自最低五分位(最低DPP)的股票,持有60个交易日。

II. 策略合理性

该策略的功能源于参考价格和投资者偏见。作者衡量股票交易价格与购买价格之间的差异,以评估对新闻的过度反应或反应不足。与接近购买价格的股票相比,偏离购买价格最远的股票在好消息日(坏消息日)表现出显著更高(更低)的异常回报。这些股票在新闻发布后也经历更大的回报反转。在一个季度(61天)内,偏离购买价格最远和最近的股票之间的回报差为4.13%。该策略利用这种横截面反应,每月获得0.93%的阿尔法,证明了其在捕捉投资者行为方面的有效性。

III. 来源论文

Do Reference Prices Impact How Investors Respond to News? [点击查看论文]

<摘要>

我们提供的证据表明,购买价格会影响投资者对极端回报的行为。通过个体投资者交易和极端回报日期的样本,我们发现当股票交易价格偏离投资者购买价格越远时,投资者越有可能按照股票回报的方向进行交易。与相对过度反应一致,交易价格偏离其平均购买价格最远的股票经历最极端的收益,随后是更大的后续反转。受这些发现启发而采用的横截面策略每月获得1.02%的阿尔法。

IV. 回测表现

年化回报15.32%
波动率9.49%
β值0.499
夏普比率0.161
索提诺比率0.222
最大回撤N/A
胜率55%

V. 完整的 Python 代码

import numpy as np
from AlgorithmImports import *
import statsmodels.api as sm
import data_tools
from pandas.core.frame import dataframe
class ReferencePricesAndBadNews(QCAlgorithm):
    def Initialize(self):
        self.SetStartDate(2000, 1, 1)
        self.SetCash(100000)
        
        self.symbol:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
        
        self.data:Dict[Symbol, data_tools.SymbolData] = {} # storing SymbolData objects under stocks symbols
        self.top_quintile:List[Symbol] = [] # storing stocks from top quintile by DPP
        self.bottom_quintile:List[Symbol] = [] # storing stocks from bottom quintile by DPP
        self.currently_not_traded:List[Symbol] = [] # storing symbols of currently not traded stocks
        self.currently_traded:List[Symbol] = [] # storing symbols of currently traded stocks
        self.managed_symbols:List[ManagedSymbol] = []
        
        self.short_period:int = 5 # need n of daily prices and volumes
        self.turnover_period:int = 250 # need n of weekly turnovers for DPP calculation
        self.long_period:int = 21 * 12 # need n of daily prices for market and stocks
        self.holding_period:int = 60 # stocks are held for n days
        self.traded_symbols:int = 50 # max value of currently traded stocks
        self.bad_news_ret_threshold:float = -0.02
        self.exchange_codes:List[str] = ['NYS', 'NAS', 'ASE']
        self.quantile:int = 5
        self.leverage:int = 5
        
        self.market_prices:RollingWindow = RollingWindow[float](self.long_period) # storing daily market prices for regression
        
        self.fundamental_count:int = 500
        self.fundamental_sorting_key = lambda x: x.DollarVolume
        self.selection_flag:bool = False
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.FundamentalSelectionFunction)
        self.settings.daily_precise_end_time = False
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
        self.Schedule.On(self.DateRules.MonthStart(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 rolling windows each day
        for stock in fundamental:
            symbol:Symbol = stock.Symbol
            
            if symbol in self.data:
                # update stock price and volume
                self.data[symbol].update(stock.AdjustedPrice, stock.Volume)
                
                # get stock's EarningReports.BasicAverageShares.OneMonth in fine and update turnover
                if self.data[symbol].short_period_ready():
                    self.data[symbol].update_turnover(stock.EarningReports.BasicAverageShares.ThreeMonths)
                    self.data[symbol].update_week_performance()
            
            if symbol == self.symbol:
                # update market prices
                self.market_prices.Add(stock.AdjustedPrice)
        
        if not self.selection_flag:
            return Universe.Unchanged
        selected:List[Fundamental] = [x for x in fundamental if x.HasFundamentalData and x.Market == 'usa' and x.Symbol != self.symbol and \
            x.SecurityReference.ExchangeId in self.exchange_codes and not np.isnan(x.EarningReports.BasicAverageShares.ThreeMonths) and x.EarningReports.BasicAverageShares.ThreeMonths != 0]
        if len(selected) > self.fundamental_count:
            selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]
        DPP:Dict[Symbol, float] = {}
        # warmup price rolling windows.
        for stock in selected:
            symbol:Symbol = stock.Symbol
            if symbol not in self.data:
                self.data[symbol] = data_tools.SymbolData(self.short_period, self.long_period, self.turnover_period)
                # creating history for self.short_period,
                # because it can't perform turnover calculation without EarningReports.BasicAverageShares.OneMonth
                history:dataframe = self.History(symbol, self.short_period, Resolution.Daily)
                if history.empty:
                    self.Log(f"Not enough data for {symbol} yet")
                    continue
                
                closes:pd.Series = history.loc[symbol].close
                volumes:pd.Series = history.loc[symbol].volume
                
                for (_, close), (_, volume) in zip(closes.items(), volumes.items()):
                    if close != 0:
                        self.data[symbol].update(close, volume)
            
            # get stock's EarningReports.BasicAverageShares.OneMonth in fine and update turnover
            if self.data[symbol].short_period_ready():
                # update stock's turnover and calculate week performance, if stock's data are ready
                self.data[symbol].update_turnover(stock.EarningReports.BasicAverageShares.ThreeMonths)
                self.data[symbol].update_week_performance()
            
            # check if stock's turnovers are ready
            if self.data[symbol].turnovers_ready() and self.selection_flag:
                DPP[symbol] = self.data[symbol].DPP_calculation()
        
        # change universe on monthly selection        
        if self.selection_flag:
            # keep monthly selection
            self.selection_flag = False
            
            if len(DPP) < self.quantile:
                return Universe.Unchanged
            
            quantile:int = int(len(DPP) / self.quantile)
            sorted_by_DPP:List[Symbol] = [x[0] for x in sorted(DPP.items(), key=lambda item: item[1])]
            
            self.currently_traded = [x.symbol for x in self.managed_symbols]
            
            # first will be stocks symbols, which aren't currently traded
            self.top_quintile = sorted_by_DPP[-quantile:]
            self.bottom_quintile = sorted_by_DPP[:quantile]
            
            # get symbols of stocks, which aren't currently traded 
            self.currently_not_traded = [x for x in self.top_quintile + self.bottom_quintile if x not in self.currently_traded]
            
            return self.top_quintile + self.bottom_quintile
        else:
            # keep old universe
            return Universe.Unchanged
    def OnData(self, data: Slice) -> None:
        # storing stock, which were hold for too long
        remove_managed_symbols:List[ManagedSymbol] = []
        
        # update holding period for each held stock and check if any of them is held for too long
        for managed_symbol in self.managed_symbols:
            # increase holding period
            managed_symbol.holding_period += 1
            
            # check if stock is held for too long
            if managed_symbol.holding_period == self.holding_period:
                remove_managed_symbols.append(managed_symbol)
                
        # liquidate stocks, which were held too long and remove them from self.managed_symbols dictionary
        for managed_symbol in remove_managed_symbols:
            if self.Portfolio[managed_symbol.symbol].Invested:
                self.MarketOrder(managed_symbol.symbol, -managed_symbol.quantity)
            self.managed_symbols.remove(managed_symbol)
        
        # market prices for regression aren't ready
        if not self.market_prices.IsReady:
            return
        
        market_prices:List[float] = list(self.market_prices)
        remove_from_curr_not_traded:List[Symbol] = []
        
        # check bad news days
        # firstly try to trade stocks, which aren't currently traded,
        # then try to trade stocks, which are currently traded, but they had bad news days 
        for symbol in self.currently_not_traded + self.currently_traded:
            # stock doesn't have data for regression or stock wasn't selected in monthly selection
            if not self.data[symbol].long_period_ready() and not self.data[symbol].short_period_ready() and (symbol in self.top_quintile or symbol in self.bottom_quintile):
                continue
            
            stock_prices:List[float] = [x for x in self.data[symbol].long_closes]
            
            # make regression
            regression_model = self.MultipleLinearRegression(market_prices, stock_prices)
            # get beta
            beta:float = regression_model.params[-1]
            
            # get daily performance
            stock_recent_perf:float = self.data[symbol].performance(2)
            market_recent_perf:float = market_prices[0] / market_prices[1] - 1 if market_prices[1] != 0 else 0
            implied_capm_return:float = market_recent_perf*beta
            # stock has bad news day and there is a space in portfolio for trading this stock
            if stock_recent_perf - implied_capm_return <= self.bad_news_ret_threshold and len(self.managed_symbols) < self.traded_symbols:
                # calculate traded quantity for stock
                weight:float = self.Portfolio.TotalPortfolioValue / self.traded_symbols
                quantity:float = np.floor(weight / self.data[symbol].last_price())
                
                # change quantity to negative, because stock is in short quintile
                if symbol in self.bottom_quintile:
                    quantity = quantity * -1
                
                # trade execution
                if self.Securities[symbol].Price != 0 and self.Securities[symbol].IsTradable:
                    self.MarketOrder(symbol, quantity)
                    # add stock's symbol to self.managed_symbols dictionary
                    self.managed_symbols.append(data_tools.ManagedSymbol(symbol, quantity))
                    
                    # stock is already traded, so it needs to be removed from self.currently_not_traded list
                    if symbol in self.currently_not_traded:
                        remove_from_curr_not_traded.append(symbol)
                        
        for symbol in remove_from_curr_not_traded:
            # remove from currently not traded list
            self.currently_not_traded.remove(symbol)
            # add to currently traded list
            self.currently_traded.append(symbol)
            
    def MultipleLinearRegression(self, x, y):
        x = np.array(x).T
        x = sm.add_constant(x)
        result = sm.OLS(endog=y, exog=x).fit()
        return result
        
    def Selection(self) -> None:
        self.selection_flag = True

发表评论

了解 Quant Buffet 的更多信息

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

继续阅读