“该研究构建了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 [点击查看论文]

<摘要>

在这篇文章中,作者记录了全球范围内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"))

发表评论

了解 Quant Buffet 的更多信息

立即订阅以继续阅读并访问完整档案。

继续阅读