该策略投资于市值高于纽约证券交易所第20百分位的股票,排除微型股。根据公司现金运营盈利能力(COP)进行分位数排名,在高COP公司买入,多头操作;在低COP公司卖出,空头操作。围绕财报公告的5天窗口期,每日按市值加权并重新平衡投资组合。

策略概述

投资组合由市值高于纽约证券交易所(NYSE)市值第20百分位的股票组成。(微型股是指市值位于基于纽约证券交易所市值分位的最低20%的股票;它们应被排除在使用的样本之外。) (股票回报数据来自证券价格研究中心(CRSP),财务报表数据来自Compustat,分析师预测数据来自机构经纪人估算系统(I/B/E/S),股票期权数据来自OptionMetrics,机构投资者持股数据来自汤森路透。)

<交易执行>

在高盈利能力公司进行多头(买入),在低盈利能力公司进行空头(卖出),围绕财报发布时的回报(参见第3.1节)。每个日历年,收集现金运营盈利能力(COP)数据,并根据此指标对所有公司进行分位数排名。

然后,在高COP分位数的投资组合中进行多头(买入),同时在低COP分位数的投资组合中进行空头(卖出),在接下来的12个月期间内围绕四个季度财报公告的5天窗口期进行操作。所有投资组合每日重新平衡,并按市值加权。

策略合理性

作者通过对风险和定价错误的竞争性解释,丰富了已经讨论的话题。他们基于越来越多的文献,证明了盈利能力可以预测未来的回报(Fama和French 2006;Novy-Marx 2013;Ball等人 2015, 2016),而Ball等人(2015, 2016)最近的研究显示,运营盈利能力和基于现金的运营盈利能力是未来回报的最强预测指标。他们的第一个测试研究了盈利能力与极端回报结果之间的关系。盈利能力与股票崩盘风险呈负相关。盈利能力还与未来显著负回报的可能性呈负相关,但与显著正回报的可能性呈正相关。他们的第二组测试研究了分析师和机构投资者对盈利信息的潜在反应不足,作为盈利错误定价的机制。他们发现分析师和机构投资者对盈利信息反应不足,这表明盈利溢价更符合定价错误,而非风险的解释。

论文来源

Why Does Operating Profitability Predict Returns? New Evidence on Risk versus Mispricing Explanations [点击浏览原文]

<摘要>

本研究就著名的盈利溢价的风险与定价错误解释提供了新证据。首先,我们研究了预期下行风险暴露是否是合理的解释。我们发现,高盈利公司与较低的预期及实际未来价格崩盘的概率相关。因此,低盈利公司表现出更大的下行风险,使得下行风险解释不太可能成立。虽然这一事实通常被市场忽视,但期权交易者已经预见到这一点;我们发现低盈利公司卖出期权的价格相对更高。同时,这些公司并未表现出更高的跳跃概率,表明基于波动性(风险)的盈利溢价解释不太可能具有描述性。其次,我们发现Bouchard等人(2019)的“粘性预期”模型只能部分解释盈利溢价。虽然分析师的预测修正平均上与近期盈利方向一致,但盈利溢价仍然与分析师预测修正中非粘性成分存在强相关。第三,机构投资者根据盈利信号进行交易,但存在延迟,可能促成了溢价。总体而言,我们的证据支持盈利溢价与投资者对潜在下行风险的定价错误相关,并进一步澄清了文献中的新发现。

回测表现

年化收益率68.81%
波动率50.57%
Beta-0.006
夏普比率1.36
索提诺比率0.046
最大回撤N/A
胜率52%

完整python代码

from AlgorithmImports import *
import numpy as np
from collections import deque
from typing import Dict, List
import data_tools
from pandas.tseries.offsets import BDay
from dateutil.relativedelta import relativedelta
from numpy import isnan
from functools import reduce
# endregion

class CashOperatingProfitabilityPredictsEarningsAnnouncementReturns(QCAlgorithm):

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

        self.quantile:int = 10
        self.leverage:int = 5
        self.long_num:int = 5
        self.short_num:int = 5
        self.holding_period:int = 5
        self.DR_period:int = 2

        self.financial_statement_names:List[str] = [
            'FinancialStatements.IncomeStatement.TotalRevenue.TwelveMonths',
            'FinancialStatements.IncomeStatement.CostOfRevenue.TwelveMonths',
            'FinancialStatements.IncomeStatement.GeneralAndAdministrativeExpense.TwelveMonths',
            'FinancialStatements.IncomeStatement.ResearchAndDevelopment.TwelveMonths',
            'FinancialStatements.CashFlowStatement.ChangeInReceivables.TwelveMonths',
            'FinancialStatements.CashFlowStatement.ChangeInInventory.TwelveMonths',
            'FinancialStatements.CashFlowStatement.ChangeInPrepaidAssets.TwelveMonths',
            'FinancialStatements.BalanceSheet.CurrentDeferredRevenue.TwelveMonths',
            'FinancialStatements.CashFlowStatement.ChangeInAccountPayable.TwelveMonths',
            'FinancialStatements.CashFlowStatement.ChangeInAccruedExpense.TwelveMonths',
        ]

        self.long:List[Symbol] = []
        self.short:List[Symbol] = []
        self.DR_data:Dict[Symbol, float] = {}  

        self.earnings_data:Dict[datetime.date, List[str]] = {} 
        self.tickers:Set(str) = set()

        self.first_date:Union[datetime.date, None] = None
        
        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()

            self.earnings_data[date] = []

            if not self.first_date: self.first_date = date
            
            for stock_data in obj['stocks']:
                ticker:str = stock_data['ticker']

                self.tickers.add(ticker)
                self.earnings_data[date].append(ticker)

        # 5 equally weighted brackets for traded symbols. - 5 symbols long, 5 symbols short, 5 days of holding
        self.trade_manager:TradeManager = data_tools.TradeManager(self, self.long_num, self.short_num, self.holding_period)

        self.symbol:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
        
        self.selection_month:int = 4
        self.selection_flag:bool = False
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.FundamentalSelectionFunction)
        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]: 
        # yearly selection
        if self.selection_flag:
            self.selection_flag = False

            COP_stocks:Dict[Fundamental, float] = {}
            selected:List[Fundamental] = [x for x in fundamental if x.Symbol.Value in self.tickers and \
                        all((not isnan(self.rgetattr(x, statement_name)) and self.rgetattr(x, statement_name) != 0) for statement_name in self.financial_statement_names)]

            for stock in selected:
                symbol:Symbol = stock.Symbol

                if symbol not in self.DR_data:
                    self.DR_data[symbol] = data_tools.DRChangeManager(self.DR_period)
                self.DR_data[symbol].update_data(stock.FinancialStatements.BalanceSheet.CurrentDeferredRevenue.TwelveMonths)
                
                if not self.DR_data[symbol].is_ready():
                    continue
                
                DR_change = self.DR_data[symbol].get_DR_change()
                sales, COGS, SG_A, R_D, REC_change, INV_change, XPP_change, DR_change, AP_change, XACC_change = [self.rgetattr(stock, statement_name) for statement_name in self.financial_statement_names]

                # calculate COP value on stocks
                if stock not in COP_stocks:
                    COP_stocks[symbol] = sales - COGS - (SG_A - R_D) - REC_change - INV_change - XPP_change + DR_change + AP_change + XACC_change            

            # sort and divide to quantiles
            if len(COP_stocks) >= self.quantile:
                sorted_COP = sorted(COP_stocks, key=COP_stocks.get, reverse=True)
                quantile:int = int(len(sorted_COP) / self.quantile)
                self.long = sorted_COP[:quantile]
                self.short = sorted_COP[-quantile:]       
        
        return self.long + self.short

    def OnData(self, data: Slice) -> None:
        # liquidate opened symbols after five days
        self.trade_manager.TryLiquidate()

        date_to_lookup:datetime.date = (self.Time + BDay(2)).date()
        
        # if there is no earnings data yet
        if date_to_lookup < self.first_date:
            # clear long set
            self.long.clear()
            self.short.clear()

        # open new trades
        symbols_to_delete = []
        if date_to_lookup in self.earnings_data:
            for symbol in self.long:
                # Next day is earnings day for the symbol.
                if symbol.Value in self.earnings_data[date_to_lookup] and symbol in data and data[symbol]:
                    self.trade_manager.Add(symbol, True)

            for symbol in self.short:
                # Next day is earnings day for the symbol.
                if symbol.Value in self.earnings_data[date_to_lookup] and symbol in data and data[symbol]:
                    self.trade_manager.Add(symbol, False)
        
    def Selection(self) -> None:
        if self.Time.month == self.selection_month:
            self.selection_flag = True
            
            self.long.clear()
            self.short.clear()

    # https://gist.github.com/wonderbeyond/d293e7a2af1de4873f2d757edd580288
    def rgetattr(self, obj, attr, *args):
        def _getattr(obj, attr):
            return getattr(obj, attr, *args)
        return reduce(_getattr, [obj] + attr.split('.'))

Leave a Reply

Discover more from Quant Buffet

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

Continue reading