投资范围包括MSCI全球指数中的中型和大型股,样本期为1985年至2022年。回报按月以美元计算,数据来源于Datastream、Worldscope和S&P Compustat。首先计算每只股票的行业特定反转,即该股票回报减去其行业回报的差异。每月将股票按五分位排序,做多底部五分位,做空顶部五分位,按市值加权并每月重新平衡。

策略概述

投资范围包括MSCI全球指数中的股票(中型股和大型股),样本期为1985年至2022年。回报按月以美元计算。数据来自Datastream价格、Worldscope基础数据和S&P Compustat。第一步,计算每只股票的行业特定反转,即该股票的回报减去其所属行业的回报(基于GICS第三级分类)在前一个月的差异。其次,每月月底将股票按五分位排序。做多底部五分位,做空顶部五分位。该策略按市值加权并每月重新平衡。

策略合理性

通常,文献中考虑了三种关于该策略为何有效的解释。首先是买卖价差反弹效应,尽管作者认为这不能完全解释策略的溢价,因为即使使用中间报价,该策略仍表现出溢价。其次,行为金融学可能是另一种解释,但也不太可能,因为其他时间框架都表现出动量(过度和不足反应)。同时,短期一个月的因子和行业回报表现出动量效应。 最常见和公认的解释基于流动性,具体而言,溢价是对流动性提供的补偿,短期反转投资者充当流动性提供者。这也是为什么该策略在市场动荡期间表现更佳的原因(Nagel,2012)。 最后,有必要考虑行业回报或因子动量,因为未经调整的短期反转效应似乎已经失效。

论文来源

Reversing the Trend of Short-Term Reversal [点击浏览原文]

<摘要>

经典的短期反转效应随着时间的推移逐渐减弱,至今在大多数地区几乎消失。然而,通过抵消其与行业和因子回报短期动量的对抗倾向,策略可以被重新激活。增强的短期反转策略显示出更高的回报和更低的风险,并随着时间的推移保持了有效性,最终实现了两倍以上的风险调整后表现。最佳的实施方式是将短期反转与其他短期阿尔法信号结合。短期反转策略的各个特征表明溢价源于供需之间的暂时失衡。因此,参与该策略的投资者实际上充当了流动性提供者,促进了资本市场的更高效运作。

回测表现

年化收益率4.85%
波动率9.86%
Beta0.176
夏普比率0.49
索提诺比率0.338
最大回撤N/A
胜率49%

完整python代码

from AlgorithmImports import *
import data_tools
from typing import List, Dict, Set
from dateutil.relativedelta import relativedelta
from numpy import isnan
# endregion

class IndustryAdjustedReversal(QCAlgorithm):

    def Initialize(self):
        self.SetStartDate(2000, 1, 1)
        self.SetCash(100000)

        self.exchange_codes:List[str] = ['NYS', 'AMEX', 'NAS']    

        self.fundamental_count:int = 1000
        self.fundamental_sorting_key = lambda x: x.DollarVolume
        
        self.leverage:int = 5
        self.quantile:int = 10
        self.monthly_period:int = 2

        self.data:Dict[Symbol, SymbolData] = {}
        self.weight:Dict[Symbol, float] = {}

        self.current_month:int = -1
        self.rebalance_flag:bool = False
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.FundamentalSelectionFunction)

    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 self.Time.month == self.current_month:
            return Universe.Unchanged
        self.current_month = self.Time.month

         # store monthly prices
        for stock in fundamental:
            symbol:Symbol = stock.Symbol

            if symbol in self.data:
                self.data[symbol].update_monthly_price(stock.AdjustedPrice)

        selected:List[Fundamental] = [x for x in fundamental if x.HasFundamentalData and x.Market == 'usa' \
                        and x.MarketCap != 0 and not np.isnan(x.AssetClassification.MorningstarIndustryGroupCode) and x.AssetClassification.MorningstarIndustryGroupCode != 0 \
                        and x.SecurityReference.ExchangeId in self.exchange_codes]

        if len(selected) > self.fundamental_count:
            selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]
        
        # store stocks by industry code
        industries:Set[MorningstarIndustryGroupCode] = set([x.AssetClassification.MorningstarIndustryGroupCode for x in selected])
        grouped_industries:Dict[MorningstarIndustryGroupCode, List[Symbol]] = { industry : [stock.Symbol for stock in selected if stock.AssetClassification.MorningstarIndustryGroupCode == industry] for industry in industries }

        # sort stocks by industry numbers and price warmup
        ISR:Dict[Symbol, float] = {}
        for stock in selected:
            symbol:Symbol = stock.Symbol

            if symbol not in self.data:               
                self.data[symbol] = data_tools.SymbolData(self.monthly_period)

                history:DataFrame = self.History(symbol, start=self.Time.date() - relativedelta(months=1), end=self.Time.date()).unstack(level=0)
                if history.empty:
                    self.Log(f"Not enough data for {symbol} yet.")
                    continue
                history = history.close.groupby(pd.Grouper(freq='MS')).first()
                for time, close in history.iterrows():
                    self.data[symbol].update_monthly_price(float(close.values))

            if self.data[symbol].is_ready():
                industry_return:float = np.mean([self.data[x].get_monthly_return() for x in grouped_industries[stock.AssetClassification.MorningstarIndustryGroupCode] if x in self.data and self.data[x].is_ready()])
                stock_return:float = self.data[symbol].get_monthly_return()
                ISR[stock] = stock_return - industry_return

        if len(ISR) >= self.quantile:
            sorted_symbols:List[Symbol] = sorted(ISR, key=ISR.get)
            quantile:int = len(ISR) // self.quantile
            long:List[Symbol] = sorted_symbols[:quantile]
            short:List[Symbol] = sorted_symbols[-quantile:]

            for i, portfolio in enumerate([long, short]):
                mc_sum:float = sum(list(map(lambda stock: stock.MarketCap, portfolio)))
                for stock in portfolio:
                    self.weight[stock.Symbol] = ((-1)**i) / len(portfolio)
            
            self.rebalance_flag = True

        return list(self.weight.keys())

    def OnData(self, data: Slice) -> None:
        if not self.rebalance_flag:
            return
        self.rebalance_flag = False

        # 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()

Leave a Reply

Discover more from Quant Buffet

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

Continue reading