Quant Buffet放轻松,别过度思虑

长期市净率 (PB Ratio) 效应与动量策略结合在股票中的应用

登录后收藏

学术论文

On the Performance of Cyclically Adjusted Valuation Measures

作者关于经周期调整估值指标的表现 [点击查看论文]

机构
  • ?Alpha Architect
  • Villanova University

策略概要

该策略的目标是纽约证券交易所/美国证券交易所/纳斯达克市场中市值高于纽约证券交易所第40百分位的公司,排除房地产投资信托基金(REITs)、美国存托凭证(ADRs)、封闭式基金和金融公司,并要求有10年的基本面数据。每月,使用当前价格和经通货膨胀调整的过去10年的账面价值计算周期性调整的账面市值比。股票被分为十分位数,并选择最高的十分位数(最高的账面市值比)。这些股票再按过去12-2个月的动量进行划分,动量较高的那一半股票被纳入等权重的投资组合。投资组合每月进行再平衡,结合价值和动量因素以获得系统性的投资机会。

II. 策略合理性

通过在商业周期内平滑价格和账面价值的波动,增加市净率计算的年限可以增强其预测能力。以低市净率(price-to-book ratios)为特征的价值型公司,持续跑赢市场回报,这主要归因于投资者对成长型股票的过度反应,导致价值型股票被低估。在价值型股票中添加动量过滤器有助于识别那些基本面改善和价格上涨的股票,将其与较弱的股票区分开来。这种价值和动量的结合通过捕捉具有积极增长趋势的低估机会,提高了基本价值策略的绩效,为投资决策提供了更有效的方法。

回测表现

波动率20.47%
夏普比率0.86
索提诺比率0.362
最大回撤-57.2%
胜率73%

完整 Python 代码

from numpy import average
from AlgorithmImports import *
class LongTermPBRatio(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2000, 1, 1)
self.SetCash(100000)
market:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
self.bm_period:int = 10 * 12
self.momentum_period:int = 13
self.quantile:int = 5
self.leverage:int = 5
self.exchange_codes:List[str] = ['NYS', 'NAS', 'ASE']
# Daily close data.
self.data:Dict[Symbol, SymbolData] = {}
self.long:List[Symbol] = []
self.fundamental_count:int = 3000
self.fundamental_sorting_key = lambda x: x.MarketCap
self.selection_flag:bool = False
self.UniverseSettings.Resolution = Resolution.Daily
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.AddUniverse(self.FundamentalSelectionFunction)
self.Schedule.On(self.DateRules.MonthEnd(market), self.TimeRules.BeforeMarketClose(market), self.Selection)

self.settings.daily_precise_end_time = False

def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
for security in changes.AddedSecurities:
    security.SetFeeModel(CustomFeeModel())
    security.SetLeverage(self.leverage)
				
def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
if not self.selection_flag:
    return Universe.Unchanged
# Update the rolling window every month.
for stock in fundamental:
    symbol:Symbol = stock.Symbol
    # Store monthly price.
    if symbol in self.data:
        self.data[symbol].update_price(stock.AdjustedPrice)
selected:List[Fundamental] = [x for x in fundamental if x.HasFundamentalData and x.Market == 'usa' and x.SecurityReference.ExchangeId in self.exchange_codes and \
    not np.isnan(x.ValuationRatios.BookValuePerShare) and x.ValuationRatios.BookValuePerShare > 0 and x.CompanyReference.IsREIT == 0]
if len(selected) > self.fundamental_count:
	selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]
bm_return_data:Dict[Symbol, Tuple[float, float]] = {}
# Warmup price rolling windows.
for stock in selected:
    symbol:Symbol = stock.Symbol
    
    if symbol not in self.data:
        self.data[symbol] = SymbolData(symbol, self, self.momentum_period, self.bm_period)
        history = self.History(symbol, self.momentum_period*30, Resolution.Daily)
        if history.empty:
            self.Log(f"Not enough data for {symbol} yet.")
            continue
        closes = history.loc[symbol].close
        
        closes_len = len(closes.keys())
        # Find monthly closes.
        for index, time_close in enumerate(closes.items()):
            # index out of bounds check.
            if index + 1 < closes_len:
                date_month = time_close[0].date().month
                next_date_month = closes.keys()[index + 1].month
            
                # Found last day of month.
                if date_month != next_date_month:
                    self.data[symbol].update_price(time_close[1])
    
    if self.data[symbol].performance_is_ready():
        self.data[symbol].update_book_value(stock.ValuationRatios.BookValuePerShare)
        # BM ratio calc.
        if self.data[symbol].book_values_are_ready():
            book_values:List[float] = list(self.data[symbol]._book_value)
            book_values_avg:float = average(book_values)
            
            last_price:float = self.data[symbol].get_last_price()
            bm_ratio:float = book_values_avg / last_price
    
            # Daily close data is ready.
            perf:float = self.data[symbol].performance()
            
            # Store bm ratio and performance tuple.
            bm_return_data[symbol] = (bm_ratio, perf)
if len(bm_return_data) >= self.quantile * 2:
    sorted_by_bm:List = sorted(bm_return_data.items(), key=lambda x: x[1][0], reverse=True)
    quantile:int = int(len(sorted_by_bm) / self.quantile)
    top_by_bm = [x for x in sorted_by_bm[:quantile]]
    
    sorted_by_ret:List = sorted(top_by_bm, key=lambda x: bm_return_data[x[0]][1], reverse=True)
    half = int(len(sorted_by_ret) / 2)
    self.long = [x[0] for x in sorted_by_ret[:half]]
return self.long
def OnData(self, data: Slice) -> None:
# Rebalance once a month.
if not self.selection_flag:
    return
self.selection_flag = False
# Trade execution.
portfolio:List[PortfolioTarget] = [PortfolioTarget(symbol, 1/len(self.long)) for symbol in self.long if symbol in data and data[symbol]]
self.SetHoldings(portfolio, True)
self.long.clear()

def Selection(self) -> None:
self.selection_flag = True

class SymbolData():
def __init__(self, symbol, algorithm, momentum_period, book_period):
self._price:RollingWindow = RollingWindow[float](momentum_period)
self._book_value:RollingWindow = RollingWindow[float](book_period)

def update_book_value(self, value: float) -> None:
self._book_value.Add(value)

def book_values_are_ready(self) -> bool:
return self._book_value.IsReady

def update_price(self, price: float) -> None:
self._price.Add(price)

def performance_is_ready(self) -> bool:
return self._price.IsReady

def get_last_price(self) -> float:
return self._price[0]
# 12 month momentum, one month skipped.
def performance(self) -> float:
return self._price[1] / self._price[self._price.Count - 1] - 1
# Custom fee model.
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters):
fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))