
“该策略针对纽约证券交易所/美国证券交易所/纳斯达克的大型公司,选择高账面市值比的股票,通过动量进行优化,并构建一个等权重、每月再平衡的投资组合,结合价值和动量因素以获得系统性收益。”
资产类别: 股票 | 地区: 美国 | 周期: 每月 | 市场: 股票 | 关键词: 市净率,动量
I. 策略概要
该策略的目标是纽约证券交易所/美国证券交易所/纳斯达克市场中市值高于纽约证券交易所第40百分位的公司,排除房地产投资信托基金(REITs)、美国存托凭证(ADRs)、封闭式基金和金融公司,并要求有10年的基本面数据。每月,使用当前价格和经通货膨胀调整的过去10年的账面价值计算周期性调整的账面市值比。股票被分为十分位数,并选择最高的十分位数(最高的账面市值比)。这些股票再按过去12-2个月的动量进行划分,动量较高的那一半股票被纳入等权重的投资组合。投资组合每月进行再平衡,结合价值和动量因素以获得系统性的投资机会。
II. 策略合理性
通过在商业周期内平滑价格和账面价值的波动,增加市净率计算的年限可以增强其预测能力。以低市净率(price-to-book ratios)为特征的价值型公司,持续跑赢市场回报,这主要归因于投资者对成长型股票的过度反应,导致价值型股票被低估。在价值型股票中添加动量过滤器有助于识别那些基本面改善和价格上涨的股票,将其与较弱的股票区分开来。这种价值和动量的结合通过捕捉具有积极增长趋势的低估机会,提高了基本价值策略的绩效,为投资决策提供了更有效的方法。
III. 来源论文
关于经周期调整估值指标的表现 [点击查看论文]
- 韦斯利·R.格雷和杰克·沃格尔。德雷塞尔大学。德雷塞尔大学。
<摘要>
我们确认了使用周期性调整的估值指标来识别表现优异的股票的有效性。席勒市盈率,或周期性调整的市盈率(CAPE)比率,不是实现周期性调整的价值衡量指标的最佳方式。在边际上,周期性调整的账面市值比(CA-BM)是预测回报的更好衡量指标。我们发现,更频繁的再平衡和动量可以增强基于周期性调整的估值指标的策略。


IV. 回测表现
| 年化回报 | 21.6% |
| 波动率 | 20.47% |
| β值 | 0.426 |
| 夏普比率 | 0.86 |
| 索提诺比率 | 0.362 |
| 最大回撤 | -57.2% |
| 胜率 | 73% |
V. 完整的 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"))