“该投资范围包括MSCI发达市场标准指数中的大中盘股票,不包括金融和房地产公司。”

I. 策略概要

该投资范围包括MSCI发达市场标准指数中的大中盘股票,不包括金融和房地产公司。对于其余公司,首先按以下方式计算质量分数:

对于四个维度中的每一个,都有两个指标:

首先,计算每个指标的值(参见研究论文第3节中的公式),并将其转换为百分位数。当然,最高质量的公司应该拥有最高的百分位数。通过将两个指标的百分位数平均为该维度的新单一百分位数,将两个指标组合成该维度的值。使用逆正态分布函数将其转换为z分数,并将所有4个z分数平均以获得一个质量分数。其次,根据质量分数的值创建按五分位数排序的价值加权投资组合。做多最高五分位数,做空最低五分位数。该策略每季度重新平衡。

II. 策略合理性

作者认为,质量溢价的来源是风险因子和行为偏差的结合。由于其性质,质量是多维的,难以准确捕捉。作者提供了一个复杂的模型,由4个有充分记录的质量衡量指标组成,每个指标都由2个精心挑选的度量标准描述。所有这些指标之前都经过了学术研究,证实了它们预测股票表现的能力。然而,与此同时,该策略产生的质量溢价并非由这些组成部分中的任何一个单独驱动,而是由它们的组合驱动。同时,通过为每个支柱选择两个而不是一个度量标准,显著降低了错误分类股票的可能性。选择大中盘股并仅使用近年来的样本确保了结果的时效性,对愿意在实践中实施此策略的投资者有用。

III. 来源论文

Revisiting Quality Investing [Click to Open PDF]

<摘要>

在因子投资领域,质量无疑是共识最弱的股票因子。本研究探讨了定义它的最佳方式。为了捕捉学术界所描绘的因子多方面现实,我们通过定义四个独立的支柱:盈利能力、盈利质量、安全性和投资,来处理质量因子。为了更好地满足机构投资者的需求,我们分析了由此产生的因子,重点关注过去十八年以及全球发达市场流动性股票(大盘股和中盘股)的投资范围。

在多空框架下,我们的质量因子提供了统计上显著的阿尔法,这无法通过对传统股票因子(市场、价值、规模和动量)的载荷来解释。大多数地区和维度都对这个阿尔法做出了积极贡献,但欧元区和安全维度是明显的例外。在仅做多框架下,我们的质量因子在整个分析期间每年跑赢其基准2.8%,信息比率为0.81。此外,自2008年全球金融危机(GFC)以来,超额表现一直非常稳定。这四个维度之间相关性较弱,因此是互补的。我们表明,在市场动荡时期(全球金融危机、新冠疫情),安全性尤为重要,因此该维度本身就是质量因子的一部分。在欧元区方面,行业中性投资组合构建似乎更合适。

我们还引入了一种新的投资组合构建方法,通过实施基于K-means算法的聚类方法,根据与基本面和市场特征相关的特征对公司进行分组。这种方法可以捕捉基本面和其他股票特征之间的动态变化。这个完全可实施的过程带来了更好的质量因子表现,而不会影响相关的风险衡量或投资组合的质量敞口(根据无约束质量因子衡量)。

IV. 回测表现

年化回报5%
波动率6.4%
β值0.035
夏普比率0.78
索提诺比率-0.022
最大回撤N/A
胜率50%

V. 完整的 Python 代码

from AlgorithmImports import *
from scipy import stats
from typing import List, Dict
from numpy import isnan
#endregion
class RobustQualityInStocks(QCAlgorithm):
    def Initialize(self) -> None:
        self.SetStartDate(2000, 1, 1)
        self.SetCash(100_000)
        self.period: int = 4 # need n of quarterly data
        self.leverage: int = 10
        self.quantile: int = 5
        self.min_share_price: int = 5
        self.data: Dict[Symbol, SymbolData] = {}
        self.weights: Dict[Symbol, float] = {}
        self.last_selection: List[Symbol] = []
        market: Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
        self.months_counter: int = 0
        self.selection_flag: bool = False
        self.UniverseSettings.Leverage = self.leverage
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.FundamentalSelectionFunction)
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
        self.Schedule.On(self.DateRules.MonthStart(market), self.TimeRules.BeforeMarketClose(market, 0), self.Selection)
        self.settings.daily_precise_end_time = False
    def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
        for security in changes.AddedSecurities:
            security.SetFeeModel(CustomFeeModel())
    def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
        # quarterly rebalance
        if not self.selection_flag:
            return Universe.Unchanged
        selected = [
            x for x in fundamental 
            if x.HasFundamentalData 
            and x.Market == 'usa' 
            and x.Price > self.min_share_price
            and x.MarketCap != 0
            and not isnan(x.FinancialStatements.IncomeStatement.GrossProfit.ThreeMonths) and x.FinancialStatements.IncomeStatement.GrossProfit.ThreeMonths != 0 
            and not isnan(x.FinancialStatements.BalanceSheet.TotalAssets.ThreeMonths) and x.FinancialStatements.BalanceSheet.TotalAssets.ThreeMonths != 0 
            and not isnan(x.FinancialStatements.BalanceSheet.WorkingCapital.ThreeMonths) and x.FinancialStatements.BalanceSheet.WorkingCapital.ThreeMonths != 0 
            and not isnan(x.FinancialStatements.CashFlowStatement.CapExReported.ThreeMonths) and x.FinancialStatements.CashFlowStatement.CapExReported.ThreeMonths != 0 
            and not isnan(x.FinancialStatements.IncomeStatement.TotalRevenue.ThreeMonths) and x.FinancialStatements.IncomeStatement.TotalRevenue.ThreeMonths != 0
            and not isnan(x.OperationRatios.ROIC.OneYear) and x.OperationRatios.ROIC.OneYear != 0 
            and not isnan(x.OperationRatios.LongTermDebtEquityRatio.OneYear) and x.OperationRatios.LongTermDebtEquityRatio.OneYear != 0 
            and not isnan(x.OperationRatios.TotalAssetsGrowth.OneYear) and x.OperationRatios.TotalAssetsGrowth.OneYear != 0 
            # and not isnan(x.FinancialStatements.CashFlowStatement.ChangeInOtherCurrentAssets.ThreeMonths) and x.FinancialStatements.CashFlowStatement.ChangeInOtherCurrentAssets.ThreeMonths != 0 
            # and not isnan(x.FinancialStatements.CashFlowStatement.ChangeInInventory.ThreeMonths) and x.FinancialStatements.CashFlowStatement.ChangeInInventory.ThreeMonths != 0 
            # and not isnan(x.FinancialStatements.CashFlowStatement.ChangeInReceivables.ThreeMonths) and x.FinancialStatements.CashFlowStatement.ChangeInReceivables.ThreeMonths != 0 
            # and not isnan(x.OperationRatios.TotalLiabilitiesGrowth.OneYear) and x.OperationRatios.TotalLiabilitiesGrowth.OneYear != 0 
            # and not isnan(x.FinancialStatements.IncomeStatement.Depreciation.TwelveMonths) and x.FinancialStatements.IncomeStatement.Depreciation.TwelveMonths != 0
            and not isnan(x.FinancialStatements.IncomeStatement.NetIncome.TwelveMonths) and x.FinancialStatements.IncomeStatement.NetIncome.TwelveMonths != 0 
            and not isnan(x.FinancialStatements.CashFlowStatement.OperatingCashFlow.TwelveMonths) and x.FinancialStatements.CashFlowStatement.OperatingCashFlow.TwelveMonths != 0 
            and not isnan(x.FinancialStatements.CashFlowStatement.InvestingCashFlow.TwelveMonths) and x.FinancialStatements.CashFlowStatement.InvestingCashFlow.TwelveMonths != 0 
            and not isnan(x.FinancialStatements.BalanceSheet.Cash.ThreeMonths) and x.FinancialStatements.BalanceSheet.Cash.ThreeMonths != 0 
            and not isnan(x.FinancialStatements.BalanceSheet.TotalLiabilitiesAsReported.ThreeMonths) and x.FinancialStatements.BalanceSheet.TotalLiabilitiesAsReported.ThreeMonths != 0 
            and not isnan(x.FinancialStatements.BalanceSheet.CurrentDebt.ThreeMonths) and x.FinancialStatements.BalanceSheet.CurrentDebt.ThreeMonths != 0 
            and not isnan(x.FinancialStatements.BalanceSheet.LongTermDebt.ThreeMonths) and x.FinancialStatements.BalanceSheet.LongTermDebt.ThreeMonths != 0
        ]
        market_cap: Dict[Symbol, float] = {} # storing stocks market capitalization keyed by stocks symbols
        # storing metrics values keyed by stock symbols
        GPA: Dict[Symbol, float] = {} # GrossProfit / TotalAssets
        CFROIC: Dict[Symbol, float] = {} # ROIC.TwelveMonths
        LTDE: Dict[Symbol, float] = {} # LongTermDebtEquityRatio.TwelveMonths
        AG1Y: Dict[Symbol, float] = {} # TotalAssetsGrowth.TwelveMonths
        WCA: Dict[Symbol, float] = {} # WorkingCapital / TotalAssets
        Capex: Dict[Symbol, float] = {} # CapExReported / TotalRevenue
        # WCAcc: Dict[Symbol, float] = {} # (ChangeInOtherCurrentAssets + ChangeInInventor + ChangeInReceivables) / mean TA - TotalLiabilitiesGrowth / mean TA - Depreciation / mean TA
        AccCF: Dict[Symbol, float] = {} # 
        for stock in selected:
            symbol: Symbol = stock.Symbol
            total_assets: float = stock.FinancialStatements.BalanceSheet.TotalAssets.ThreeMonths # TA
            gross_profit: float = stock.FinancialStatements.IncomeStatement.GrossProfit.ThreeMonths
            working_capital: float = stock.FinancialStatements.BalanceSheet.WorkingCapital.ThreeMonths
            cap_ex_reported: float = stock.FinancialStatements.CashFlowStatement.CapExReported.ThreeMonths
            total_revenue: float = stock.FinancialStatements.IncomeStatement.TotalRevenue.ThreeMonths
            # get all needed values for NOA calculation
            cash: float = stock.FinancialStatements.BalanceSheet.Cash.ThreeMonths # CHEi in formula for NOA
            total_liabilities_as_reported: float = stock.FinancialStatements.BalanceSheet.TotalLiabilitiesAsReported.ThreeMonths  # LT in formula for NOA
            current_debt: float = stock.FinancialStatements.BalanceSheet.CurrentDebt.ThreeMonths # DLC in formula for NOA
            long_term_debt: float = stock.FinancialStatements.BalanceSheet.LongTermDebt.ThreeMonths # DLTT in formula for NOA
            # make sure data are consecutive
            if symbol not in self.last_selection:
                self.data[symbol] = SymbolData(self.period)
            # calculate NOA for this quarter
            # NOA = mean(TA - CHEi) - mean(LT-DLC-DLTT)
            mean_TA_CHEi: float = np.mean([total_assets, cash])
            mean_LT_DLC_DLTT: float = np.mean([total_liabilities_as_reported, current_debt, long_term_debt])
            noa_value: float = mean_TA_CHEi - mean_LT_DLC_DLTT
            # update NOA values
            self.data[symbol].update_NOA(noa_value)
            # update quarterly values
            self.data[symbol].update_total_assets(total_assets)
            gpa_value: float = gross_profit / total_assets
            self.data[symbol].update_GPA(gpa_value)
            wca_value: float = working_capital / total_assets
            self.data[symbol].update_WCA(wca_value)
            capex_value: float = cap_ex_reported / total_revenue
            self.data[symbol].update_Capex(capex_value)
            # check if all quarterly data are ready for metrics calculations
            if not self.data[symbol].are_quarterly_data_ready():
                continue
            # data for each metric are ready
            # calculate and store metrics
            GPA[symbol] = self.data[symbol].calculate_GPA()
            CFROIC[symbol] = stock.OperationRatios.ROIC.OneYear
            LTDE[symbol] = stock.OperationRatios.LongTermDebtEquityRatio.OneYear
            AG1Y[symbol] = stock.OperationRatios.TotalAssetsGrowth.OneYear
            WCA[symbol] = self.data[symbol].calculate_WCA()
            Capex[symbol] = self.data[symbol].calculate_Capex()
            # # get all needed values for WCAcc metric
            # change_in_other_current_assets = stock.FinancialStatements.CashFlowStatement.ChangeInOtherCurrentAssets.ThreeMonths
            # change_in_inventory = stock.FinancialStatements.CashFlowStatement.ChangeInInventory.ThreeMonths
            # change_in_receivables = stock.FinancialStatements.CashFlowStatement.ChangeInReceivables.ThreeMonths
            # total_liabilities = stock.OperationRatios.TotalLiabilitiesGrowth.OneYear / 4
            # depreciation = stock.FinancialStatements.IncomeStatement.Depreciation.TwelveMonths
            # mean_total_assets = self.data[symbol].mean_total_assets()
            # # calculate parts of WCAcc formula
            # first_formula_part = (change_in_other_current_assets + change_in_inventory + change_in_receivables) / mean_total_assets
            # second_formula_part = total_liabilities / mean_total_assets
            # third_formula_part = depreciation / mean_total_assets
            # # calculate WCAcc value
            # wcacc_value = first_formula_part - second_formula_part - third_formula_part
            # # store WCAcc metric value keyed by stock's symbol
            # WCAcc[symbol] = wcacc_value
            # get all needed values for AccCF
            net_income: float = stock.FinancialStatements.IncomeStatement.NetIncome.TwelveMonths
            operating_cashflow: float = stock.FinancialStatements.CashFlowStatement.OperatingCashFlow.TwelveMonths
            investing_cashflow: float = stock.FinancialStatements.CashFlowStatement.InvestingCashFlow.TwelveMonths
            # get NOA values for specific quarters
            current_quarter_NOA, fourth_quarter_NOA = self.data[symbol].get_NOA_values()
            # calculate NOA formula part for AccCF formula
            NOA_formula_part: float = (current_quarter_NOA + fourth_quarter_NOA) / 2
            # calculate AccCF value
            acccf_value: float = (net_income - (operating_cashflow + investing_cashflow)) / NOA_formula_part
            # store AccCF value keyed by stock's symbol
            AccCF[symbol] = acccf_value
            # store stock's market capitalization
            market_cap[symbol] = stock.MarketCap
        # change last_selection for consecutive data
        self.last_selection = [x.Symbol for x in selected]
        # make sure, there are enough data for quintile selection
        if len(GPA) < self.quantile:
            return Universe.Unchanged
        # calculate percentil value for each metric and create dictinories keyed by stock symbols
        GPA_percentiles: Dict[Symbol, float] = self.CalculatePercentiles(GPA)
        CFROIC_percentiles: Dict[Symbol, float] = self.CalculatePercentiles(CFROIC)
        LTDE_percentiles: Dict[Symbol, float] = self.CalculatePercentiles(LTDE)
        AG1Y_percentiles: Dict[Symbol, float] = self.CalculatePercentiles(AG1Y)
        WCA_percentiles: Dict[Symbol, float] = self.CalculatePercentiles(WCA)
        Capex_percentiles: Dict[Symbol, float] = self.CalculatePercentiles(Capex)
        # WCAcc_percentiles: Dict[Symbol, float] = self.CalculatePercentiles(WCAcc)
        AccCF_percentiles: Dict[Symbol, float] = self.CalculatePercentiles(AccCF)
        # calcualte dimension values 
        # dimension_value = np.mean(metric1_percentile + metric2_percentile)
        # dimension value = mean of stock's percentile from first and second metric in current division
        # for each dimension create dictionary with stocks dimension values keyed by stocks symbols
        profitability: Dict[Symbol, float] = self.CalculateDimensionValues(GPA_percentiles, CFROIC_percentiles)
        earnings_quality: Dict[Symbol, float] = self.CalculateDimensionValues(AccCF_percentiles, {})
        safety: Dict[Symbol, float] = self.CalculateDimensionValues(LTDE_percentiles, WCA_percentiles)
        investment: Dict[Symbol, float] = self.CalculateDimensionValues(AG1Y_percentiles, Capex_percentiles)
        # calculate z-scores of stocks for each dimension
        # for each dimension create dictionary with stocks z-scores keyed by stocks symbols
        profitability_z_scores: Dict[Symbol, float] = self.CalculateDimensionZScore(profitability)
        earnings_quality_z_scores: Dict[Symbol, float] = self.CalculateDimensionZScore(earnings_quality)
        safety_z_scores: Dict[Symbol, float] = self.CalculateDimensionZScore(safety)
        investment_z_scores: Dict[Symbol, float] = self.CalculateDimensionZScore(investment)
        # create dictionary of quality factor values keyed by stocks symbols based on dimensions z-scores
        quality_factor: Dict[Symbol, float] = self.CalculateQualityFactor(
            profitability_z_scores,
            earnings_quality_z_scores,
            safety_z_scores,
            investment_z_scores
        )
        # sort stocks by their quality factor
        quantile: int = int(len(quality_factor) / self.quantile)
        sorted_by_quality_factor: List[Symbol] = [x[0] for x in sorted(quality_factor.items(), key=lambda item: item[1])]
        # long highest quintile according to quality factor
        long: List[Symbol] = sorted_by_quality_factor[-quantile:]
        # short lowest quintile according to quality factor
        short: List[Symbol] = sorted_by_quality_factor[:quantile]
        # calculate weights
        for i, portfolio in enumerate([long, short]):
            mc_sum: float = sum(list(map(lambda symbol: market_cap[symbol], portfolio)))
            for symbol in portfolio:
                self.weights[symbol] = ((-1)**i) * market_cap[symbol] / mc_sum
        return list(self.weights.keys())
    def OnData(self, slice: Slice) -> None:
        if not self.selection_flag:
            return
        self.selection_flag = False
        # trade exectuion
        portfolio: List[PortfolioTarget] = [PortfolioTarget(symbol, w) for symbol, w in self.weights.items() if slice.contains_key(symbol) and slice[symbol]]
        self.SetHoldings(portfolio, True)
        self.weights.clear()
    def CalculatePercentiles(self, metric_dict: Dict[Symbol, float]) -> Dict[Symbol, float]:
        ''' create and return dictionary with percentile values of metric keyed by stock symbol '''
        # return dict of stocks percentiles values keyed by their symbols    
        metric_percentiles: Dict[Symbol, float] = { x[0] : stats.percentileofscore(list(metric_dict.values()), x[1]) for x in metric_dict.items() }
        return metric_percentiles
    def CalculateDimensionValues(self, 
                                metric1_percentiles: Dict[Symbol, float], 
                                metric2_percentiles: Dict[Symbol, float]) -> Dict[Symbol, float]:
        ''' create and return dictionary of dimension values keyed by stocks symbols '''
        dimension_values: Dict[Symbol, float] = {} # storing dimension values keyed by stocks symbols
        # calculate and store stock dimension values
        for stock_symbol, stock_percentile_1 in metric1_percentiles.items():
            if len(metric2_percentiles) > 0:
                # get stock's percentile from second metric in current dimension
                stock_percentile_2: float = metric2_percentiles[stock_symbol]
                # calculate dimension value
                # dimension value = mean of stock's percentile from first and second metric in current division
                dimension_value: float = np.mean([stock_percentile_1, stock_percentile_2])
                # store stock's dimension value keyed by stock's symbol
                dimension_values[stock_symbol] = dimension_value
            else:
                # store stock's dimension value keyed by stock's symbol
                dimension_values[stock_symbol] = stock_percentile_1
        # return dictionary of dimension values keyed by stocks symbols
        return dimension_values
    def CalculateDimensionZScore(self, dimension_dict: Dict[Symbol, float]) -> Dict[Symbol, float]:
        ''' create and return dictionary of z-score values for each stock in dimension keyed by their symbols '''
        dimension_values: List[float] = [value for _, value in dimension_dict.items()]
        dimension_mean: float = np.mean(dimension_values)
        dimension_std: float = np.std(dimension_values)
        dimension_z_score: Dict[Symbol, float] = {} # storing stocks z-score values keyed by their symbols
        # calculate and store z-score for each stock in dimension
        for stock_symbol, value in dimension_dict.items():
            z_score: float = (value - dimension_mean) / dimension_std
            # store stock's z-score keyed by stock's symbol
            dimension_z_score[stock_symbol] = z_score
        # return dictionary with stocks z-scores keyed by their symbols
        return dimension_z_score
    def CalculateQualityFactor(self, 
                            dimension_dict1: Dict[Symbol, float], 
                            dimension_dict2: Dict[Symbol, float], 
                            dimension_dict3: Dict[Symbol, float], 
                            dimension_dict4: Dict[Symbol, float]) -> Dict[Symbol, float]:
        ''' create and return dictionary of quality factor values keyed by stocks symbols '''
        quality_factor: Dict[Symbol, float] = {} # storing quality factor values keyed by stocks symbols
        for stock_symbol, z_score1 in dimension_dict1.items():
            z_score2: float = dimension_dict2[stock_symbol]
            z_score3: float = dimension_dict3[stock_symbol]
            z_score4: float = dimension_dict4[stock_symbol]
            # calculate stock's quality factor based on dimensions z-scores
            quality_factor_value: float = np.mean([z_score1, z_score2, z_score3, z_score4])
            # store stock's quality factore keyed by stock's symbols
            quality_factor[stock_symbol] = quality_factor_value
        # return dictionary of quality factor values keyed by stocks symbols
        return quality_factor
    def Selection(self) -> None:
        # quarterly rebalance
        if self.months_counter % 3 == 0:
            self.selection_flag = True
        self.months_counter += 1
class SymbolData():
    def __init__(self, period: int) -> None:
        self.GPA: RollingWindow = RollingWindow[float](period)
        self.WCA: RollingWindow = RollingWindow[float](period)
        self.Capex: RollingWindow = RollingWindow[float](period)
        self.total_assets: RollingWindow = RollingWindow[float](period)
        self.NOA: RollingWindow = RollingWindow[float](period)
    def update_NOA(self, noa_value: float) -> None:
        self.NOA.Add(noa_value)
    def update_total_assets(self, total_assets_value: float) -> None:
        self.total_assets.Add(total_assets_value)
    def update_GPA(self, gpa_value: float) -> None:
        self.GPA.Add(gpa_value)
    def update_WCA(self, wca_value: float) -> None:
        self.WCA.Add(wca_value)
    def update_Capex(self, capex_value: float) -> None:
        self.Capex.Add(capex_value)
    def are_quarterly_data_ready(self) -> bool:
        return self.GPA.IsReady and self.WCA.IsReady and self.Capex.IsReady and self.total_assets.IsReady and self.NOA.IsReady
    def mean_total_assets(self) -> float:
        total_assets_values = [x for x in self.total_assets]
        return np.mean(total_assets_values)
    def get_NOA_values(self) -> float:
        noa_values = [x for x in self.NOA]
        # return NOA values in quartet t and quarter t-4
        return noa_values[0], noa_values[-1]
    def calculate_GPA(self) -> float:
        gpa_values = [x for x in self.GPA]
        return sum(gpa_values)
    def calculate_WCA(self) -> float:
        wca_values = [x for x in self.WCA]
        return sum(wca_values)
    def calculate_Capex(self) -> float:
        capex_values = [x for x in self.Capex]
        return sum(capex_values)
# Custom fee model.
class CustomFeeModel(FeeModel):
    def GetOrderFee(self, parameters: OrderFeeParameters) -> OrderFee:
        fee: float = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
        return OrderFee(CashAmount(fee, "USD"))

发表评论

了解 Quant Buffet 的更多信息

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

继续阅读