
“该策略将快速和慢速动量信号与状态依赖的阿尔法值相结合,针对市场状况(修正或反弹)进行优化,使用历史数据,并每月重新平衡,持续15年。”
资产类别: 股票 | 地区: 美国 | 周期: 每月 | 市场: 股票 | 关键词: 动量
I. 策略概要
该策略侧重于CRSP公司在纽约证券交易所、美国证券交易所或纳斯达克上市的美国超额价值加权因子(Mkt-RF)。它使用快速的1个月动量信号和慢速的12个月动量信号,并将它们与状态依赖变量alpha相结合。在牛市或熊市中,当信号一致时,alpha设置为0.5。然而,在修正或反弹状态下,alpha根据市场情况而变化,通过最大化下个月的夏普比率来计算。该策略使用历史估计进行实际应用,并每月重新平衡。基于77.5年的数据,在15年期间的交易策略中使用了状态依赖的alpha来优化性能。
II. 策略合理性
该论文根据慢速和快速动量指标的一致性,识别出四种市场周期:牛市、修正、熊市和反弹。在修正状态下,慢速和快速动量指标不一致,慢速动量指示做多,快速动量指示做空。在反弹状态下,指标显示相反。混合慢速和快速动量信号可以提高投资组合利润,但混合因子应在不同周期中变化以最大化夏普比率,从而优于静态方法。混合策略产生更高的夏普比率、更小的回撤、更正的偏度以及更强的可预测性。在牛市或熊市状态下,动量信号一致,而在修正和反弹状态下,经济意外不那么显著,这表明宏观环境可能发生变化。研究得出结论,根据市场周期量身定制的动态动量策略比传统的静态策略更有效。
III. 来源论文
Momentum Turning Points [点击查看论文]
- 阿希什·加尔格(Ashish Garg)、克里斯蒂安·L·古尔丁(Christian L. Goulding)、坎贝尔·R·哈维(Campbell R. Harvey)和米凯莱·马佐莱尼(Michele Mazzoleni),奥本大学哈伯特商学院,杜克大学福库商学院;美国国家经济研究局(NBER),凯投集团(The Capital Group Companies)
<摘要>
我们使用慢速和快速时间序列动量来表征四个股票市场周期——牛市、修正、熊市和反弹。熊市的急剧市场下跌集中在高风险状态,但预测负预期回报,这在大多数时变风险溢价模型中难以合理化。使用模型分析慢速和快速动量策略,我们估计美国股票市场回报中存在相对较高的均值持续性和实现噪声。通过混合慢速和快速动量策略形成的中速动量投资组合,将市场周期中的预测信息转化为正的无条件阿尔法,为此我们提出了一种新颖的分解方法。


IV. 回测表现
| 年化回报 | 6.11% |
| 波动率 | 10% |
| β值 | 0.55 |
| 夏普比率 | 0.61 |
| 索提诺比率 | 0.168 |
| 最大回撤 | N/A |
| 胜率 | 68% |
V. 完整的 Python 代码
from AlgorithmImports import *
class DynamicMomentumStrategy(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2010, 1, 1)
self.SetCash(100000)
self.slow_period = 12*21
self.fast_period = 21
# subscribe
data = self.AddData(QuantpediaFutures, 'CME_ES1', Resolution.Daily) # E-mini S&P 500 Futures, Continuous Contract #1
data.SetFeeModel(CustomFeeModel())
data.SetLeverage(5)
self.market = data.Symbol
# daily price data
self.price_data = RollingWindow[float](self.slow_period)
self.recent_month:int = -1
def OnData(self, data):
# check if data is still coming.
if self.securities[self.market].get_last_data() and self.time.date() > QuantpediaFutures.get_last_update_date()[self.market]:
self.liquidate()
return
# store daily market price
if self.market in data and data[self.market]:
self.price_data.Add(data[self.market].Value)
if self.recent_month != self.Time.month:
self.recent_month = self.Time.month
if self.price_data.IsReady:
slow_momentum = self.price_data[0] / self.price_data[self.price_data.Count-1] - 1
fast_momentum = self.price_data[0] / self.price_data[21] - 1
slow_signal = 1 if slow_momentum >= 0 else -1
fast_signal = 1 if fast_momentum >= 0 else -1
# market cycles
# A month ending at date t is classified as Bull if both the trailing 12-month return (arithmetic average monthly return), rt−12,t, is nonnegative and
# the trailing 1-month return, rt−1,t, is nonnegative. A month is classified as Correction if rt−12,t ≥ 0 but rt−1,t < 0; as Bear if rt−12,t < 0 and rt−1,t < 0;
# and as Rebound if rt−12,t < 0 but rt−1,t ≥ 0.
bull = slow_signal == 1 and fast_signal == 1
bear = slow_signal == -1 and fast_signal == -1
correction = slow_signal == 1 and fast_signal == -1
rebound = slow_signal == -1 and fast_signal == 1
alpha = 0
# if the market`s state is bear or bull – the alpha is not important since the signals agree and it could be set at one half
if bull or bear:
alpha = 0.5
# source: https://papers.ssrn.com/sol3/papers.cfm?abstract_id=3489539
# Table 6
elif correction:
alpha = 0.16
elif rebound:
alpha = 0.69
# weight calculation
w = ((1-alpha) * slow_signal) + (alpha*fast_signal)
self.SetHoldings(self.market, w)
# Custom fee model.
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters):
fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))
# Quantpedia data.
# NOTE: IMPORTANT: Data order must be ascending (datewise)
class QuantpediaFutures(PythonData):
_last_update_date:Dict[Symbol, datetime.date] = {}
@staticmethod
def get_last_update_date() -> Dict[Symbol, datetime.date]:
return QuantpediaFutures._last_update_date
def GetSource(self, config, date, isLiveMode):
return SubscriptionDataSource("data.quantpedia.com/backtesting_data/futures/{0}.csv".format(config.Symbol.Value), SubscriptionTransportMedium.RemoteFile, FileFormat.Csv)
def Reader(self, config, line, date, isLiveMode):
data = QuantpediaFutures()
data.Symbol = config.Symbol
if not line[0].isdigit(): return None
split = line.split(';')
data.Time = datetime.strptime(split[0], "%d.%m.%Y") + timedelta(days=1)
data['back_adjusted'] = float(split[1])
data['spliced'] = float(split[2])
data.Value = float(split[1])
if config.Symbol not in QuantpediaFutures._last_update_date:
QuantpediaFutures._last_update_date[config.Symbol] = datetime(1,1,1).date()
if data.Time.date() > QuantpediaFutures._last_update_date[config.Symbol]:
QuantpediaFutures._last_update_date[config.Symbol] = data.Time.date()
return data