The strategy trades large-cap stocks by sorting on prior returns and turnover, combining low-turnover loser-winner and high-turnover winner-loser portfolios, with monthly value-weighted rebalancing for return and liquidity insights.

I. STRATEGY IN A NUTSHELL

Targets large-cap U.S. stocks, sorting them by prior-month returns and turnover. Constructs two portfolios: long losers/short winners in low-turnover stocks and long winners/short losers in high-turnover stocks, rebalanced monthly.

II. ECONOMIC RATIONALE

Reversal dominates low-turnover stocks due to noise trading, while momentum prevails in high-turnover stocks from gradual incorporation of private information. Turnover proxies investor disagreement, explaining the coexistence of short-term reversal and momentum patterns.

III. SOURCE PAPER

Short-Term Momentum [Click to Open PDF]

Mamdouh Medhat — Dimensional Fund Advisors; Maik Schmeling — Goethe University Frankfurt – Department of Finance; Centre for Economic Policy Research (CEPR).

<Abstract>

We document a striking pattern in the cross-section of U.S. and international stock returns: Double-sorting on the previous month’s return and share turnover results in strong and significant short-term reversal for low-turnover stocks whereas high-turnover stocks exhibit short-term momentum. Short-term momentum is as profitable and persistent as conventional momentum, but is not spanned by standard factors, and is significant among the largest, most liquid stocks. Consistent with our model, in which heterogeneous investors disagree about the informativeness of their signals, we find that reversal among low-turnover stocks is driven by noise-trading whereas momentum among high-turnover stocks reflects the gradual diffusion of private information. As a result, purging noise-trades from the previous month’s return and turnover results in even stronger short-term momentum.

IV. BACKTEST PERFORMANCE

Annualised Return9.51%
VolatilityN/A
Beta-0.017
Sharpe RatioN/A
Sortino Ratio-0.248
Maximum DrawdownN/A
Win Rate50%

V. FULL PYTHON CODE

from AlgorithmImports import *
from typing import List, Dict, Tuple
from numpy import isnan
class TheImpactTurnoversShortTermMomentumReversal(QCAlgorithm):
    def Initialize(self):
        self.SetStartDate(2000, 1, 1)  
        self.SetCash(100000) 
        self.exchange_codes:List[str] = ['NYS', 'NAS', 'ASE']
        market:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
        
        self.leverage:int = 10
        self.quantile:int = 5
        self.share_min_price:int = 5
        self.period:int = 21
        self.fundamental_count:int = 500
        self.fundamental_sorting_key = lambda x: x.MarketCap
        
        # Price and volume daily data.
        self.data:Dict[Symbol, SymbolData] = {}
        self.weight:Dict[Symbol, float] = {}
        
        self.selection_flag:bool = False
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.FundamentalSelectionFunction)
        self.Schedule.On(self.DateRules.MonthEnd(market), self.TimeRules.AfterMarketOpen(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]:
        # Update the rolling window every day.
        for stock in fundamental:
            symbol:Symbol = stock.Symbol
            # Store monthly price.
            if symbol in self.data:
                self.data[symbol].update(stock.AdjustedPrice, stock.Volume)
        if not self.selection_flag:
            return Universe.Unchanged
        # selected = [x.Symbol for x in fundamental if x.HasFundamentalData and x.Market == 'usa']
        selected:List[Fundamental] = [
            x for x in fundamental if x.HasFundamentalData and x.Price > self.share_min_price and x.Market == 'usa' and x.MarketCap != 0 \
            and not isnan(x.EarningReports.BasicAverageShares.ThreeMonths) and x.EarningReports.BasicAverageShares.ThreeMonths > 0 \
            and x.SecurityReference.ExchangeId in self.exchange_codes
        ]
            
        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 in self.data:
                continue
            self.data[symbol] = SymbolData(self.period)
            history = self.History(symbol, self.period, Resolution.Daily)
            if history.empty:
                self.Log(f"Not enough data for {symbol} yet")
                continue
            if 'close' in history and 'volume' in history:
                closes:dataframe = history.loc[symbol].close
                volumes:Series = history.loc[symbol].volume
                for (_, close), (_, volume) in zip(closes.items(), volumes.items()):
                    self.data[symbol].update(close, volume)
        performance_turnover_market_cap:Dict[Symbol, Tuple[float]] = {}
        
        for stock in selected:
            symbol:Symbol = stock.Symbol
            if not self.data[symbol].is_ready():
                continue
            # Return calc.
            perf:float = self.data[symbol].performance()
            
            # Turnover calc.
            monthly_volume:float = self.data[symbol].monthly_volume()
            shares_outstanding:float = stock.EarningReports.BasicAverageShares.ThreeMonths
            turnover:float = monthly_volume / shares_outstanding
            # Market cap calc.
            market_cap:float = stock.MarketCap
                
            performance_turnover_market_cap[symbol] = (perf, turnover, market_cap)
                
        if len(performance_turnover_market_cap) != 0:
            # Return sorting.
            sorted_by_ret:List[Tuple[Symbol, Tuple[float]]] = sorted(performance_turnover_market_cap.items(), key = lambda x: x[1][0], reverse = True)
            quintile:int = int(len(sorted_by_ret) / self.quantile)
            
            high_ret:List[Tuple[Symbol, Tuple[float]]] = [x for x in sorted_by_ret[:quintile]]
            low_ret:List[Tuple[Symbol, Tuple[float]]] = [x for x in sorted_by_ret[-quintile:]]
            
            # Turnover sorting.
            sorted_by_turnover:List[Tuple[Symbol, Tuple[float]]] = sorted(performance_turnover_market_cap.items(), key = lambda x: x[1][1], reverse = True)
            high_turnover:List[Tuple[Symbol, Tuple[float]]] = [x for x in sorted_by_turnover[:quintile]]
            low_turnover:List[Tuple[Symbol, Tuple[float]]] = [x for x in sorted_by_turnover[-quintile:]]
            
            # Forming portfolios.
            long_first_portfolio:List[Tuple[Symbol, Tuple[float]]] = [x for x in low_ret if x in low_turnover]
            short_first_portfolio:List[Tuple[Symbol, Tuple[float]]] = [x for x in high_ret if x in low_turnover]
    
            long_second_portfolio:List[Tuple[Symbol, Tuple[float]]] = [x for x in high_ret if x in high_turnover]
            short_second_portfolio:List[Tuple[Symbol, Tuple[float]]] = [x for x in low_ret if x in high_turnover]
            
            # calculate weights
            for portfolio_lst in [[long_first_portfolio, short_first_portfolio], [long_second_portfolio, short_second_portfolio]]:
                for i, portfolio in enumerate(portfolio_lst):
                    mc_sum:float = sum(list(map(lambda x: x[1][2], portfolio)))
                    for symbol, perf_turnover_cap in portfolio:
                        self.weight[symbol] = (((-1)**i) * 0.5) * perf_turnover_cap[2] / mc_sum
        
        return [x[0] for x in self.weight.items()]
    
    def OnData(self, data: Slice):
        if not self.selection_flag:
            return
        self.selection_flag = False
        
        # Trade execution.
        portfolio:List[PortfolioTarget] = [PortfolioTarget(symbol, w) for symbol, w in self.weight.items() if symbol in data and data[symbol]]
        self.SetHoldings(portfolio, True)
        
        self.weight.clear()
        
    def Selection(self):
        self.selection_flag = True
        
class SymbolData():
    def __init__(self, period:int):
        self.Closes:RollingWindow = RollingWindow[float](period)
        self.Volumes:RollingWindow = RollingWindow[float](period)
            
    def update(self, close:float, volume:float):
        self.Closes.Add(close)
        self.Volumes.Add(volume)
            
    def is_ready(self) -> bool:
        return self.Closes.IsReady and self.Volumes.IsReady
        
    def performance(self) -> float:
        closes:List[float] = [x for x in self.Closes]
        return closes[0] / closes[-1] - 1 # Performance
        
    def monthly_volume(self) -> float:
        volumes:List[float] = [x for x in self.Volumes]
        return sum(volumes)
        
        
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