“该策略涉及根据前一年的回报对美国股票进行排序。多头头寸建立在“赢家”上,空头头寸建立在“输家”上。市场崩盘后,头寸在三个月内切换为LMW。”

I. 策略概要

投资范围包括美国股票。在月末,股票根据前一年的回报(不包括最近一个月)被分为十分位数。最高十分位数(“赢家”)做多,最低十分位数(“输家”)做空,形成WML(赢家减去输家)策略。头寸持有一个月。如果市场经历崩盘(低于平均回报两个标准差以上),该策略在三个月内切换为LMW(输家减去赢家)。之后,如果没有进一步的市场暴跌,投资组合将恢复为WML。投资组合每月重新平衡,并按价值加权。

II. 策略合理性

动量崩盘部分是可预测的,通常发生在动量回报高、利率低或崩盘后市场反弹之后。这些崩盘往往发生在重大市场损失后的一到三个月内。崩盘与动量策略的投资组合形成过程有关,其中最近的市场表现不佳加剧了崩盘。通过在重大市场损失后纳入逆向策略,该策略旨在减少动量崩盘并将其转化为收益。这种调整通过避免潜在的崩盘并更好地适应市场条件,从而增强了动量策略。

III. 来源论文

Dynamic Momentum and Contrarian Trading [点击查看论文]

<摘要>

高动量回报无法用风险因素解释,但它们呈负偏态,并且偶尔会遭受严重的崩盘。我探讨了动量崩盘的时机,并表明动量策略往往在局部股市暴跌后1-3个月内崩盘。接下来,我提出了一个简单的动态交易策略,该策略在平静时期与标准动量策略一致,但在市场崩盘后一个月切换到相反的逆向策略,并保持逆向头寸三个月,之后恢复到动量头寸。动态动量策略将所有重大动量崩盘转化为收益,并产生平均回报,约为标准动量回报的1.5倍。动态动量回报呈正偏态,不受风险因素影响,具有高夏普比率和阿尔法,并在全球不同的时期和地域市场中持续存在。

IV. 回测表现

年化回报21.74%
波动率26.74%
β值0.06
夏普比率0.81
索提诺比率0.191
最大回撤-39.39%
胜率53%

V. 完整的 Python 代码

import numpy as np
from AlgorithmImports import *
from pandas.core.frame import dataframe
class DynamicMomentumContrarianTrading(QCAlgorithm):
    def Initialize(self):
        self.SetStartDate(2000, 1, 1)
        self.SetCash(100000)
        self.weight:Dict[Symbol, float] = {}
        
        # Monthly price data.
        self.data:Dict[Symbol, SymbolData] = {}
        self.period:int = 13
        self.quantile:int = 10
        self.leverage:int = 5
        
        self.market:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
        # Market daily data.
        daily_period:int = 21
        self.data[self.market] = SymbolData(daily_period)
        
        self.market_return_data:List[float] = []
        self.min_monthly_perf_period:int = 12
        self.contrarian_flag:bool = False
        self.contrarian_months:int = 0
        self.fundamental_count:int = 500
        self.fundamental_sorting_key = lambda x: x.DollarVolume
        self.selection_flag:int = False
        self.UniverseSettings.Resolution = Resolution.Daily
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
        self.AddUniverse(self.FundamentalSelectionFunction)
        self.Schedule.On(self.DateRules.MonthEnd(self.market), self.TimeRules.BeforeMarketClose(self.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 or self.contrarian_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)
            
            # Market return calc.
            if self.data[self.market].is_ready():
                self.market_return_data.append(self.data[self.market].performance())
    
        selected:List[Fundamental] = [x for x in fundamental if x.HasFundamentalData and x.Market == 'usa' 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]]
            
        # 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])
            
        performance:Dict[Fundamental, float] = {x : self.data[x.Symbol].performance(1) for x in selected if x.Symbol in self.data and self.data[x.Symbol].is_ready()}
        
        # At least one year of monthly market return is ready.
        if len(self.market_return_data) >= self.min_monthly_perf_period and len(performance) >= self.quantile:
            mean_ret:float = np.mean(self.market_return_data)
            std_ret:float = np.std(self.market_return_data)
            recent_market_ret:float = self.market_return_data[-1]
        
            # There was a crash last month.
            if recent_market_ret < mean_ret - 2*std_ret:
                self.contrarian_flag = True
            
            sorted_by_performance:List[Fundamental] = sorted(performance, key = performance.get, reverse = True)
            quantile:int = int(len(sorted_by_performance) / self.quantile)
            
            long:List[Fundamental] = []
            short:List[Fundamental] = []
            if self.contrarian_flag:
                short = sorted_by_performance[:quantile]
                long = sorted_by_performance[-quantile:]
            else:
                long = sorted_by_performance[:quantile]
                short = sorted_by_performance[-quantile:]
    
            # 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
        
        portfolio:List[PortfolioTarget] = [PortfolioTarget(symbol, w) for symbol, w in self.weight.items() if symbol in data and data[symbol]]
        self.SetHoldings(portfolio, True)
        # Trade execution.
        if self.contrarian_flag:
            self.contrarian_months += 1
            if self.contrarian_months == 3:
                self.contrarian_flag = False
                self.contrarian_months = 0
                self.weight.clear()
        else:
            self.weight.clear()
    def Selection(self) -> None:
        self.selection_flag = True
        
class SymbolData():
    def __init__(self, period: int) -> None:
        self._price:RollingWindow = RollingWindow[float](period)
    
    def update(self, price: float) -> None:
        self._price.Add(price)
    
    def is_ready(self) -> bool:
        return self._price.IsReady
        
    # Performance, one month skipped.
    def performance(self, values_to_skip = 0) -> float:
        return self._price[values_to_skip] / 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"))

发表评论

了解 Quant Buffet 的更多信息

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

继续阅读