
“该研究构建了65个美国股票因子投资组合,应用动态时间序列动量缩放。过去一个月正回报触发买入;负回报触发卖出。一个单位杠杆投资组合整合所有因子,每月重新平衡。”
资产类别: 股票 | 地区: 美国 | 周期: 每月 | 市场: 股票 | 关键词: 时间序列、动量
I. 策略概要
该研究考察了使用美国股票构建的65个基于特征的因子投资组合。因子通过在1%水平上对原始特征值进行横截面温莎化,并根据纽约证券交易所市值中位数将股票分为大盘股和小盘股。这些股票进一步使用纽约证券交易所的断点(30/40/30百分位)分为低/中/高组。为每个组构建价值加权投资组合,并创建多空因子投资组合,公式为0.5×(“大盘高”+“小盘高”)– 0.5×(“大盘低”+“小盘低”)。投资组合每月更新。TSGM策略根据因子过去一个月的表现,使用z分数动态调整因子一个月的回报。正回报触发因子购买,而负回报则促使卖出。TSFM策略将单个因子动量策略组合成一个具有单位杠杆(1美元多头和1美元空头)的单一投资组合,每月重新平衡。这种方法整合了多个因子的时间序列动量,以实现动态投资决策。
II. 策略合理性
作者以动量理论为基础,强调单个因子表现出稳健的时间序列动量,即近期回报预测未来回报,并且因子可以根据过去表现进行择时。与等权重原始因子和传统2-12股票动量等基准相比,TSFM策略表现良好,其优势延伸到更长的形成期。TSFM具有鲁棒性,在不同的回溯窗口(一个月到五年)内保持正动量。与横截面因子动量(CSFM)相比,TSFM显示出高相关性(>0.90),但提供了卓越的性能,在控制CSFM时具有正alpha。两种策略都实现了相似的独立夏普比率,优于股票动量、行业动量、短期反转和Fama-French因子。TSFM的时间序列方法提供了预期因子回报的更纯粹衡量,而其在不同时期内的稳定性和有效性则突显了其作为动态投资策略的优势。
III. 来源论文
Factor Momentum Everywhere [点击查看论文]
- Tarun Gupta 和 Bryan Kelly。景顺全球资产管理公司,耶鲁大学管理学院;AQR资本管理有限责任公司;国家经济研究局(NBER)
<摘要>
在这篇文章中,作者记录了全球范围内65个广泛研究的基于特征的股票因子中强劲的动量行为。他们表明,一般来说,单个因子可以根据其自身的近期表现可靠地进行择时。一个结合所有因子择时策略的时间序列“因子动量”投资组合获得了0.84的年夏普比率。因子动量为采用传统动量、行业动量、价值和其他常用研究因子的投资策略增加了显著的增量表现。他们的结果表明,动量现象很大程度上是由共同回报因子的持续性驱动的,而不仅仅是由特质股票表现的持续性驱动的。


IV. 回测表现
| 年化回报 | 12% |
| 波动率 | 14.29% |
| β值 | 0.02 |
| 夏普比率 | 0.84 |
| 索提诺比率 | -0.447 |
| 最大回撤 | N/A |
| 胜率 | 72% |
V. 完整的 Python 代码
from AlgorithmImports import *
import numpy as np
from typing import List, Dict
#endregion
class TimeSeriesFactorMomentum(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2005, 1, 1)
self.SetCash(100000)
# daily price data
self.data:Dict[str, SymbolData] = {}
self.period:int = 12 * 21 * 3 # Three years daily closes
self.leverage:int = 10
self.SetWarmUp(self.period, Resolution.Daily)
csv_string_file:str = self.Download('data.quantpedia.com/backtesting_data/equity/quantpedia_strategies/backtest_end_year.csv')
lines:str = csv_string_file.split('\r\n')
last_id:None|str = None
for line in lines[1:]:
split:str = line.split(';')
id:str = str(split[0])
data:QuantpediaEquitz = self.AddData(QuantpediaEquity, id, Resolution.Daily)
data.SetLeverage(self.leverage)
data.SetFeeModel(CustomFeeModel())
self.data[id] = SymbolData(self.period)
if not last_id:
last_id = id
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.recent_month:int = -1
def OnData(self, data):
if self.IsWarmingUp:
return
# Update equity close each day.
for symbol in self.data:
if symbol in data and data[symbol]:
self.data[symbol].update(data[symbol].Value)
if self.Time.month == self.recent_month:
return
self.recent_month = self.Time.month
long:List[str] = []
short:List[str] = []
_last_update_date:Dict[str, datetime.date] = QuantpediaEquity.get_last_update_date()
for symbol in self.data:
if not self.data[symbol].is_ready():
continue
if symbol in data and data[symbol]:
if _last_update_date[symbol] > self.Time.date():
# Calculate factore score for each equity
equity_score:float = self.data[symbol].calculate_z_score()
# Go long if equity_score is positive
# and short if equity_score is negative
if equity_score >= 0:
long.append(symbol)
else:
short.append(symbol)
# Trade execution.
invested:List[str] = [x.Key.Value for x in self.Portfolio if x.Value.Invested]
for symbol in invested:
if symbol not in long + short:
self.Liquidate(symbol)
long_length:int = len(long)
short_length:int = len(short)
# Equally weighted long and short portfolio
for symbol in long:
self.SetHoldings(symbol, 1 / long_length)
for symbol in short:
self.SetHoldings(symbol, -1 / short_length)
class SymbolData():
def __init__(self, period:int):
self.closes:RollingWindow[float] = RollingWindow[float](period)
def update(self, close:float):
self.closes.Add(close)
def is_ready(self) -> bool:
return self.closes.IsReady
def calculate_z_score(self) -> float:
closes:List[float] = [x for x in self.closes]
values:np.ndarray = np.array(closes) # Full period daily closes
daily_returns:np.ndarray = (values[:-1] - values[1:]) / values[1:] # Daily returns for full period
full_period_volatility:float = np.std(daily_returns) # Full period volatility
monthly_returns:List[float] = [(values[i] - values[i + 20]) / values[i + 20] for i in range(0, len(values), 21)]
# This equation is first in paper on page 10.
return min(max( sum(monthly_returns) / full_period_volatility, -2), 2)
# Quantpedia strategy equity curve data.
# NOTE: IMPORTANT: Data order must be ascending (datewise)
class QuantpediaEquity(PythonData):
def GetSource(self, config, date, isLiveMode):
return SubscriptionDataSource("data.quantpedia.com/backtesting_data/equity/quantpedia_strategies/{0}.csv".format(config.Symbol.Value), SubscriptionTransportMedium.RemoteFile, FileFormat.Csv)
_last_update_date:Dict[str, datetime.date] = {}
@staticmethod
def get_last_update_date() -> Dict[str, datetime.date]:
return QuantpediaEquity._last_update_date
def Reader(self, config, line, date, isLiveMode):
data = QuantpediaEquity()
data.Symbol = config.Symbol
if not line[0].isdigit(): return None
split = line.split(';')
data.Time = datetime.strptime(split[0], "%Y-%m-%d") + timedelta(days=1)
data['close'] = float(split[1])
data.Value = float(split[1])
# store last update date
if config.Symbol.Value not in QuantpediaEquity._last_update_date:
QuantpediaEquity._last_update_date[config.Symbol.Value] = datetime(1,1,1).date()
if data.Time.date() > QuantpediaEquity._last_update_date[config.Symbol.Value]:
QuantpediaEquity._last_update_date[config.Symbol.Value] = data.Time.date()
return data
# Custom fee model.
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters):
fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))