该策略涵盖AMEX、NYSE和NASDAQ的股票,排除价格低于1美元和金融公司。数据来自CRSP。首先,根据t月回报率将股票分为五分位,”赢家”和”输家”组。其次,根据股价与52周高点比率(PTH)排序。然后,按t月换手率将股票分为五组。构建两个价值加权投资组合:一个做多高PTH的赢家、做空低PTH的赢家,选择最低换手率组;另一个选择最高换手率组。最终组合为这两种策略的等权重组合。

策略概述

投资范围包括所有AMEX、NYSE和NASDAQ的股票,股票代码为10和11。价格低于1美元以及金融公司股票被排除。数据来自CRSP。首先,在t月末,根据t月的回报率将股票按纽约证券交易所分位排序为五分位组,第一分位代表“赢家”,最后一分位代表“输家”。其次,根据t-1月末的股价与52周高点比率(PTH)对股票进行排序。第三步,基于t月的换手率将股票分为五分位组。最后,在t+1月,利用上述三个因素的顺序排序构建两个价值加权的投资组合。对于第一个投资组合,做多高PTH的赢家,做空低PTH的赢家,选择换手率最低的分位组。对于第二个投资组合,做多高PTH的赢家,做空低PTH的赢家,选择换手率最高的分位组。最终构建一个由这两种策略组成的等权重组合。

策略合理性

动量策略在学术界广泛接受,主要原因在于投资者的非理性行为和行为偏差,如从众行为或确认偏见。然而,“52周高点效应”是由锚定偏差引发的,导致投资者产生错误判断。根据Chen、Stivers和Sun的研究,接近52周高点交易的股票,投资者对新闻的反应可能偏向悲观,而远低于52周高点交易的股票,投资者可能会产生乐观的反应。因此,高PTH比率在t月表明对t+1月回报的积极影响,而低PTH则表明t+1月的反转效应。综上,作者提出将动量策略与52周高点策略结合,形成更有效的策略。

论文来源

Short-term Relative-Strength Strategies, Turnover, and the Connection between Winner Returns and the 52-week High [点击浏览原文]

<摘要>

我们的研究提出了两个主要发现,表明52周高点价格锚定在理解股票一个月回报的短期行为中起着重要作用。首先,我们发现高换手率股票的短期动量效应仅在这些股票的价格相对接近其52周高点时显现。相反,对于价格远离52周高点的高换手率股票,强烈的反转效应更加明显。其次,我们发现明显的价格与52周高点(PTH)锚定偏差具有不对称性,集中在过去的赢家股票中。高PTH的赢家在我们研究的所有不同换手率和规模细分中,表现明显优于低PTH的赢家。相反,在过去的输家股票中,并未出现类似的基于PTH的表现差异。这种不对称性解释了我们的第一个主要发现。我们进行了三项补充研究,支持了PTH锚定偏差的解释,尤其是在赢家与PTH的关系上,同时还评估了市场情绪、分析师预测盈利的分歧和公司规模等因素。

回测表现

年化收益率31.3%
波动率31.08%
Beta1.051
夏普比率1.01
索提诺比率0.235
最大回撤N/A
胜率56%

完整python代码

from AlgorithmImports import *
import data_tools
# endregion
class CombinedMomentumAndNearnessTo52WeekHigh(QCAlgorithm):
    def Initialize(self):
        self.SetStartDate(2000, 1, 1)
        self.SetCash(100000)
        self.leverage:int = 5
        self.quantile:int = 5
        self.total_portfolios:int = 2
        self.portfolio_percentage:float = 1
        self.min_price_period:int = 15
        self.min_volume_period:int = 15
        self.min_share_price:float = 1.
        self.high_period:int = 52 * 5
        self.exchanges:List[str] = ['NYS', 'NAS', 'ASE']
        self.data:Dict[Symbol, data_tools.SymbolData] = {}
        self.weight:Dict[Symbol, float] = {}
        self.selected_symbols:List[Symbol] = []
        self.fundamental_count:int = 500
        self.fundamental_sorting_key = lambda x: x.DollarVolume
        self.market_symbol:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
        self.selection_flag:bool = False
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.FundamentalSelectionFunction)
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
        self.Schedule.On(self.DateRules.MonthStart(self.market_symbol), self.TimeRules.BeforeMarketClose(self.market_symbol, 0), 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]:
        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.AdjustedPrice >= self.min_share_price and x.MarketCap != 0 and \
            not np.isnan(x.EarningReports.BasicAverageShares.ThreeMonths) and x.EarningReports.BasicAverageShares.ThreeMonths != 0 and x.SecurityReference.ExchangeId in self.exchanges]
        if len(selected) > self.fundamental_count:
            selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]
        
        self.selected_symbols.clear()
        
        for stock in selected:
            symbol:Symbol = stock.Symbol
            self.selected_symbols.append(symbol)
            if symbol not in self.data:
                self.data[symbol] = data_tools.SymbolData(self.high_period)
            self.data[symbol].update_shares_outstanding(stock.EarningReports.BasicAverageShares.ThreeMonths)
            self.data[symbol].update_market_cap(stock.MarketCap)
        symbols_to_delete:List[Symbol] = list(filter(lambda symbol: symbol not in self.selected_symbols, self.data))
        for symbol in symbols_to_delete:
            del self.data[symbol]
        
        return self.selected_symbols
    def OnData(self, data: Slice) -> None:
        for symbol in self.selected_symbols:
            if symbol in data and data[symbol] and data[symbol].High != 0 and \
             data[symbol].Price != 0 and data[symbol].Volume != 0:
                high:float = data[symbol].High
                volume:float = data[symbol].Volume
                price:float = data[symbol].Price
                self.data[symbol].update(price, volume, high)
        if not self.selection_flag:
            return
        self.selection_flag = False
        performance:Dict[Symbol, float] = {}
        turnover:Dict[Symbol, float] = {}
        PTH:Dict[Symbol, float] = {}
        for symbol in self.selected_symbols:
            symbol_obj:data_tools.SymbolData = self.data[symbol]
            if symbol_obj.PTH_data_ready():
                symbol_obj.update_PTH_values()
            else:
                symbol_obj.reset_PTH_values()
            if symbol_obj.is_ready(self.min_price_period, self.min_volume_period):
                performance[symbol] = symbol_obj.get_performance()
                turnover[symbol] = symbol_obj.get_turnover()
                PTH[symbol] = symbol_obj.get_prev_PTH_value()
            symbol_obj.reset_prices()
            symbol_obj.reset_volumes()
        
        if len(performance) < self.quantile:
            self.Liquidate()
            return
        quantile:int = int(len(performance) / self.quantile)
        sorted_by_perf:List[Symbol] = [x[0] for x in sorted(performance.items(), key=lambda item: item[1])]
        sorted_by_turnover:List[Symbol] = [x[0] for x in sorted(turnover.items(), key=lambda item: item[1])]
        sorted_by_PTH:List[Symbol] = [x[0] for x in sorted(PTH.items(), key=lambda item: item[1])]
        perf_winners:List[Symbol] = sorted_by_perf[-quantile:]
        perf_losers:List[Symbol] = sorted_by_perf[:quantile]
        lowest_turnover:List[Symbol] = sorted_by_turnover[:quantile]
        highest_turnover:List[Symbol] = sorted_by_turnover[-quantile:]
        lowest_PTH:List[Symbol] = sorted_by_PTH[:quantile]
        highest_PTH:List[Symbol] = sorted_by_PTH[-quantile:]
        # For the first portfolio, go long Hight-PTH Winners and go short Low-PTH Winners in the lowest turnover quintile.
        long_leg = [x for x in lowest_turnover if x in highest_PTH and x in perf_winners and x in data and data[x]]
        short_leg = [x for x in lowest_turnover if x in lowest_PTH and x in perf_winners and x in data and data[x]]
        if len(long_leg) != 0 and len(short_leg) != 0:
            self.CalculateWeights(long_leg, short_leg)
        # For the second portfolio, go long High PTH-Winners and go short Low-PTH Winners in the highest turnover quintile.
        long_leg = [x for x in highest_turnover if x in highest_PTH and x in perf_winners and x in data and data[x]]
        short_leg = [x for x in highest_turnover if x in lowest_PTH and x in perf_winners and x in data and data[x]]
        if len(long_leg) != 0 and len(short_leg) != 0:
            self.CalculateWeights(long_leg, short_leg)
        # trade execution
        portfolio:List[PortfolioTarget] = [PortfolioTarget(symbol, w) for symbol, w in self.weight.items() if symbol in data and data[symbol]]
        self.SetHoldings(portfolio, True)
        self.weight.clear()
    def CalculateWeights(self, long_leg:List, short_leg:List) -> None:
        for i, portfolio in enumerate([long_leg, short_leg]):
            mc_sum:float = sum(list(map(lambda symbol: self.data[symbol].get_market_cap(), portfolio)))
            for symbol in portfolio:
                self.weight[symbol] = ((self.data[symbol].get_market_cap() / mc_sum) / self.total_portfolios) * self.portfolio_percentage
        
    def Selection(self) -> None:
        self.selection_flag = True

Leave a Reply

Discover more from Quant Buffet

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

Continue reading