Quant Buffet放轻松,别过度思虑

动态动量与逆势交易

登录后收藏

学术论文

Dynamic Momentum and Contrarian Trading

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

机构
  • RUNational Research University Higher School of Economics
  • ?School of Finance, HSE University

策略概要

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

II. 策略合理性

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

回测表现

波动率26.74%
夏普比率0.81
索提诺比率0.191
最大回撤-39.39%
胜率53%

完整 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"))