The strategy involves sorting US stocks based on the previous year’s returns. Long positions are taken in “Winners” and short in “Losers.” After market crashes, positions switch to LMW for three months.

I. STRATEGY IN A NUTSHELL

The strategy targets US stocks sorted into deciles based on the previous year’s returns, excluding the most recent month. Under normal conditions, the top decile (“Winners”) is bought and the bottom decile (“Losers”) is sold short, forming a WML (Winner minus Loser) momentum portfolio held for one month. If a market crash occurs—defined as a return more than two standard deviations below the mean—the strategy switches to a contrarian LMW (Losers minus Winners) position for three months before reverting to WML, with monthly value-weighted rebalancing throughout.

II. ECONOMIC RATIONALE

Momentum crashes are partially predictable and tend to follow periods of strong momentum returns, low interest rates, or rebounds after prior losses. These crashes result from the strategy’s reliance on recent performance, which can amplify market downturns. Incorporating a temporary contrarian LMW approach after a significant loss helps mitigate crash risk, potentially turning downturns into profitable opportunities and aligning the portfolio more effectively with prevailing market conditions.

III. SOURCE PAPER

Dynamic Momentum and Contrarian Trading [Click to Open PDF]

Dobrynskaya, School of Finance, HSE University

<Abstract>

High momentum returns cannot be explained by risk factors, but they are negatively skewed and subject to occasional severe crashes. I explore the timing of momentum crashes and show that momentum strategies tend to crash in 1-3 months after the local stock market plunge. Next, I propose a simple dynamic trading strategy which coincides with the standard momentum strategy in calm times, but switches to the opposite contrarian strategy in one month after a market crash and keeps the contrarian position for three months, after which it reverts back to the momentum position. The dynamic momentum strategy turns all major momentum crashes into gains and yields average return, which is about 1.5 times as high as the standard momentum return. The dynamic momentum returns are positively skewed and not exposed to risk factors, have high Sharpe ratio and alpha, persist in different time periods and geographical markets around the Globe.

IV. BACKTEST PERFORMANCE

Annualised Return21.74%
Volatility26.74%
Beta 0.06
Sharpe Ratio0.81
Sortino Ratio 0.191
Maximum Drawdown-39.39%
Win Rate53%

V. FULL PYTHON CODE

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

VI. Backtest Performance

Leave a Reply

Discover more from Quant Buffet

Subscribe now to keep reading and get access to the full archive.

Continue reading