“该策略投资于纽约证券交易所、美国证券交易所和纳斯达克的大盘股,结合了52周高点接近度和Piotroski FSCORE,做多排名靠前的股票,做空排名靠后的股票,每月重新排序并持有六个月。”

I. 策略概要

投资范围包括在纽约证券交易所、美国证券交易所和纳斯达克上市的普通股,不包括金融公司和股价低于5美元的股票。只考虑最大的公司。股票根据其与52周高点的接近程度(收盘价相对于过去12个月最高价)和Piotroski的FSCORE(衡量基本面实力的季度指标)进行评估。股票通过双向独立排序分为15个投资组合:52周接近度分为五分位,FSCORE分为三个组(低:0-3,中:4-6,高:7-9)。对两项指标均排名靠前的股票建立多头头寸,对两项指标均排名靠后的股票建立空头头寸。头寸持有六个月,每月重新排序,创建重叠的等权重投资组合。该策略整合了动量和基本面实力,以识别潜在的跑赢者和跑输者。

II. 策略合理性

52周高点异常是由受锚定偏差影响的非成熟投资者驱动的。将52周高点接近度与Piotroski的FSCORE相结合有助于识别对基本面消息反应不足的公司,为投资者创造机会。尽管q因子模型提供了一个有前景的基于风险的解释,但其有效性对动量崩溃期间的异常值敏感。接近52周高点的股票可能反映非基本面因素,这使得FSCORE成为一个重要的补充衡量指标,它来源于最近的财务报表。成熟投资者,如机构和卖空者,对FSCORE信号迅速做出反应,避免在接近52周高点时反应不足。研究结果与表明反应不足集中在非成熟投资者中的研究一致。

III. 来源论文

Fundamental Strength and the 52-Week High Anomaly [点击查看论文]

<摘要>

当股票在其52周高点附近交易时,投资者往往对其未来回报抱有较低的预期。我们将这种预期与公司的基本面实力进行对比。对于基本面强劲的公司,我们证实投资者的预期过低,这与52周高点作为心理锚点的假设一致。我们报告称,基本面实力增强的52周高点交易策略的平均回报几乎是无条件策略的两倍,显著优于后者。此外,我们提供了有趣的证据,表明这种异常效应在投资者情绪高涨时最为明显,但在更成熟的机构和卖空者中则不存在。

IV. 回测表现

年化回报12.68%
波动率21.06%
β值-0.159
夏普比率0.6
索提诺比率0.179
最大回撤N/A
胜率48%

V. 完整的 Python 代码

from numpy import floor, isnan
from AlgorithmImports import *
from pandas.core.frame import dataframe
import data_tools
from functools import reduce
class FundamentalStrength52WeekHigh(QCAlgorithm):
    def Initialize(self):
        self.SetStartDate(2000, 1, 1)
        self.SetCash(100000)
    
        self.period:int = 52 * 5
        self.holding_period:int = 6
        self.quantile:int = 5
        self.leverage:int = 5
        self.min_share_price:float = 5.
        self.exchange_codes:List[str] = ['NYS', 'NAS', 'ASE']
        
        self.data:Dict[Symbol, data_tools.SymbolData] = {}
        self.last_fine:List[Symbol] = []
        self.managed_queue:List[data_tools.RebalanceQueueItem] = []
        self.financial_statement_names:List[str] = [
            'EarningReports.BasicAverageShares.ThreeMonths',
            'EarningReports.BasicEPS.TwelveMonths',
            'OperationRatios.ROA.ThreeMonths',
            'OperationRatios.GrossMargin.ThreeMonths',
            'FinancialStatements.CashFlowStatement.CashFlowFromContinuingOperatingActivities.ThreeMonths',
            'FinancialStatements.IncomeStatement.NormalizedIncome.ThreeMonths',
            'FinancialStatements.BalanceSheet.LongTermDebt.ThreeMonths',
            'FinancialStatements.BalanceSheet.TotalAssets.ThreeMonths',
            'FinancialStatements.BalanceSheet.OrdinarySharesNumber.ThreeMonths',
            'FinancialStatements.IncomeStatement.TotalRevenueAsReported.ThreeMonths',
            'ValuationRatios.PERatio',
            'OperationRatios.CurrentRatio.ThreeMonths',
        ]
        market:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
        
        self.fundamental_count:int = 3000
        self.fundamental_sorting_key = lambda x: x.MarketCap
        self.selection_flag = False
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.FundamentalSelectionFunction)
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
        self.Schedule.On(self.DateRules.MonthStart(market), self.TimeRules.AfterMarketOpen(market), self.Selection)
        self.settings.daily_precise_end_time = False
    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 the rolling window every day
        for stock in fundamental:
            symbol:Symbol = stock.Symbol
            # Store monthly price.
            if symbol in self.data:
                self.data[symbol].update_price(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.Price > self.min_share_price and \
            all((not isnan(self.rgetattr(x, statement_name)) and self.rgetattr(x, statement_name) != 0) for statement_name in self.financial_statement_names)
        ]
        if len(selected) > self.fundamental_count:
            selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]
        score = {}
        nearness = {}
        # warmup price rolling windows
        for stock in selected:
            symbol:Symbol = stock.Symbol
            if symbol not in self.data:
                self.data[symbol] = data_tools.SymbolData(symbol, self.period)
                history = self.History(symbol, self.period, Resolution.Daily)
                if history.empty:
                    self.Log(f"Not enough data for {symbol} yet.")
                    continue
                closes = history.loc[symbol].close
                for time, close in closes.items():
                    self.data[symbol].update_price(close)
            
            if self.data[symbol].is_ready():
                nearness[symbol] = self.data[symbol].nearness()
                
                # FSCORE calc            
                roa:float = stock.OperationRatios.ROA.ThreeMonths
                cfo:float = stock.FinancialStatements.CashFlowStatement.CashFlowFromContinuingOperatingActivities.ThreeMonths
                leverage:float = stock.FinancialStatements.BalanceSheet.LongTermDebt.ThreeMonths / stock.FinancialStatements.BalanceSheet.TotalAssets.ThreeMonths
                liquidity:float = stock.OperationRatios.CurrentRatio.ThreeMonths
                equity_offering:float = stock.FinancialStatements.BalanceSheet.OrdinarySharesNumber.ThreeMonths
                gross_margin:float = stock.OperationRatios.GrossMargin.ThreeMonths
                turnover:float = stock.FinancialStatements.IncomeStatement.TotalRevenueAsReported.ThreeMonths / stock.FinancialStatements.BalanceSheet.TotalAssets.ThreeMonths
                
                symbol_data = self.data[symbol]
                # check if data has previous year's data ready and their values are consecutive
                if (not symbol_data.data_is_set()) or (symbol not in self.last_fine):
                    symbol_data.update_data(roa, leverage, liquidity, equity_offering, gross_margin, turnover)
                    continue
                score[symbol] = 0
                if roa > 0:
                    score[symbol] += 1
                if cfo > 0:
                    score[symbol] += 1
                if roa > symbol_data.ROA:   # ROA change is positive
                    score[symbol] += 1
                if cfo > roa:
                    score[symbol] += 1
                if leverage < symbol_data.Leverage:
                    score[symbol] += 1
                if liquidity > symbol_data.Liquidity:
                    score[symbol] += 1
                if equity_offering < symbol_data.Equity_offering:
                    score[symbol] += 1
                if gross_margin > symbol_data.Gross_margin:
                    score[symbol] += 1
                if turnover > symbol_data.Turnover:
                    score[symbol] += 1
                
                # assing new (this year's) data
                symbol_data.update_data(roa, leverage, liquidity, equity_offering, gross_margin, turnover)
        
        long:List[Symbol] = []
        short:List[Symbol] = []
            
        if len(score) != 0 and len(nearness) >= self.quantile:
            # nearness sorting and F score sorting
            sorted_by_nearness:List[Symbol] = sorted(nearness, key = nearness.get, reverse = True)
            quantile:int = int(len(sorted_by_nearness) / self.quantile)
            high_by_nearness = sorted_by_nearness[:quantile]
            low_by_nearness = sorted_by_nearness[-quantile:]
            long = [x[0] for x in score.items() if x[1] >= 7 and x[0] in high_by_nearness]
            short = [x[0] for x in score.items() if x[1] <= 3 and x[0] in low_by_nearness]
            
            if len(long) != 0 and len(short) != 0:
                long_w:float = self.Portfolio.TotalPortfolioValue / self.holding_period / len(long)
                short_w:float = self.Portfolio.TotalPortfolioValue / self.holding_period / len(short)
                # symbol/quantity collection
                long_symbol_q:List[Tuple] = [(x, floor(long_w / self.data[x].LastPrice)) for x in long]
                short_symbol_q:List[Tuple] = [(x, -floor(short_w / self.data[x].LastPrice)) for x in short]
                
                self.managed_queue.append(data_tools.RebalanceQueueItem(long_symbol_q + short_symbol_q))
        
        self.last_fine = [x.Symbol for x in selected]
        
        return long + short
    def OnData(self, data: Slice) -> None:
        if not self.selection_flag:
            return
        self.selection_flag = False
        remove_item = None
        # rebalancing portfolio
        for item in self.managed_queue:
            if item.holding_period == self.holding_period:
                # liquidate
                for symbol, quantity in item.symbol_q:
                    self.MarketOrder(symbol, -quantity)
                            
                remove_item = item
            
            # trade execution
            if item.holding_period == 0:
                open_symbol_q = []
                
                for symbol, quantity in item.symbol_q:
                    if data.ContainsKey(symbol):
                        self.MarketOrder(symbol, quantity)
                        open_symbol_q.append((symbol, quantity))
                            
                # only opened orders will be closed        
                item.symbol_q = open_symbol_q
                
            item.holding_period += 1
            
        # we need to remove closed part of portfolio after loop. Otherwise it will miss one item in self.managed_queue
        if remove_item:
            self.managed_queue.remove(remove_item)
                
    def Selection(self) -> None:
        self.selection_flag = True
    # 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('.'))

发表评论

了解 Quant Buffet 的更多信息

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

继续阅读