
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.
ASSET CLASS: stocks | REGION: United States | FREQUENCY:
Monthly | MARKET: equities | KEYWORD: Turnover, Momentum, Reversal
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 Return | 9.51% |
| Volatility | N/A |
| Beta | -0.017 |
| Sharpe Ratio | N/A |
| Sortino Ratio | -0.248 |
| Maximum Drawdown | N/A |
| Win Rate | 50% |
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