结合时间序列动量和横截面动量策略
登录后收藏学术论文
The Enduring Effect of Time-Series Momentum on Stock Returns Over Nearly 100-Years
Ian D'Souza; Voraphat Srichanachaichok; George Jiaguo Wang; Chelsea Yaqiong Yao
- New York University
- ?New York University - Leonard N. Stern School of Business
- ?Bangkok Bank
- Lancaster University
- ?Lancaster University Management School
- ?New York University - Stern School of Business
- ?New York University (NYU) - Leonard N. Stern School of Business
- ?Lancaster University - Lancaster University Management School
策略概要
投资范围包括来自纽约证券交易所、美国证券交易所和纳斯达克且至少有11个月过往回报的股票。投资者按累计回报的符号和回报的五分位数对股票进行双重排序。股票被分为两组:赢家(正回报)和输家(负回报)。在这些组内,股票根据表现进一步分为五分位数。投资者做多表现最佳的赢家五分位数,做空表现最差的输家五分位数。投资组合每月重新平衡,并按价值加权。该策略是一种零投资的多空方法。
II. 策略合理性
The momentum effect in stocks arises from investors’ irrationality and behavioral biases, causing them to underreact to news. This leads to a delay in the full incorporation of news into stock prices. For time-series momentum, the authors identify two potential explanations for investors’ underreaction: the gradual information diffusion hypothesis, proposed by Hong and Stein (1999), and the frog-in-the-pan hypothesis, discussed by Da, Gurun, and Warachka (2014). These hypotheses suggest that information is slowly absorbed or ignored, contributing to delayed price adjustments and the persistence of momentum effects.
回测表现
完整 Python 代码
from AlgorithmImports import *
from pandas.core.frame import DataFrame
class TimeSeriesCrossSectionalMomentum(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2000, 1, 1)
self.SetCash(100000)
market:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
self.fundamental_count:int = 1000
self.fundamental_sorting_key = lambda x: x.DollarVolume
self.period:int = 13
self.quantile:int = 5
self.leverage:int = 5
self.exchange_codes:List[str] = ['NYS', 'NAS', 'ASE']
# Monthly close data.
self.data:Dict[Symbol, SymbolData] = {}
self.weight:Dict[Symbol, float] = {}
self.selection_flag:bool = 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(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(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 x.MarketCap != 0]
if len(selected) > self.fundamental_count:
selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]
performance:Dict[Fundamental, float] = {}
# Warmup price rolling windows.
for stock in selected:
symbol:Symbol = stock.Symbol
if symbol not in self.data:
self.data[symbol] = SymbolData(self.period)
history:DataFrame = self.History(symbol, self.period*30, Resolution.Daily)
if history.empty:
self.Log(f"Not enough data for {symbol} yet.")
continue
closes:pd.Series = history.loc[symbol].close
closes_len:int = 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:int = time_close[0].date().month
next_date_month:int = closes.keys()[index + 1].month
# Found last day of month.
if date_month != next_date_month:
self.data[symbol].update(time_close[1])
if self.data[symbol].is_ready():
performance[stock] = self.data[symbol].performance()
if len(performance) >= self.quantile:
winner_group:List[Fundamental] = [x[0] for x in performance.items() if x[1] > 0]
loser_group:List[Fundamental] = [x[0] for x in performance.items() if x[1] < 0]
sorted_by_perf:List = sorted(performance.items(), key = lambda x: x[1], reverse = True)
quantile:int = int(len(sorted_by_perf) / self.quantile)
top_perf:List[Fundamental] = [x[0] for x in sorted_by_perf[:quantile]]
low_perf:List[Fundamental] = [x[0] for x in sorted_by_perf[-quantile:]]
long:List[Fundamental] = [x for x in winner_group if x in top_perf]
short:List[Fundamental] = [x for x in loser_group if x in low_perf]
# Market cap weighting.
for i, portfolio in enumerate([long, short]):
mc_sum:float = sum(map(lambda x: x.MarketCap, portfolio))
for stock in portfolio:
self.weight[stock.Symbol] = ((-1) ** i) * stock.MarketCap / mc_sum
return list(self.weight.keys())
def OnData(self, data: Slice) -> None:
if not self.selection_flag:
return
self.selection_flag = False
# Trade execution.
portfolio:List[PortfolioTarget] = [PortfolioTarget(symbol, w) for symbol, w in self.weight.items() if symbol in data and data[symbol]]
self.SetHoldings(portfolio, True)
self.weight.clear()
def Selection(self) -> None:
self.selection_flag = True
class SymbolData():
def __init__(self, period: int):
self._price:RollingWindow = RollingWindow[float](period)
def update(self, close: float) -> None:
self._price.Add(close)
def is_ready(self) -> bool:
return self._price.IsReady
# Yearly performance, 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"))