Quant BuffetRelax, Not Over Thinking

Combined Momentum and Nearness to 52-week High

Log in to collect

Academic paper

Short-term Relative-Strength Strategies, Turnover, and the Connection between Winner Returns and the 52-week High

AuthorsChen Chen; Chris T. Stivers; Licheng Sun

Institute
  • GHDominion University College
  • Old Dominion University
  • ?Old Dominion University - Strome College of Business
  • University of Louisville Hospital
  • ?University of Louisville

Strategy in a nutshell

Invest in NYSE, AMEX, and NASDAQ stocks using a sequential sort on past-month returns, 52-week high ratio, and turnover. Construct two value-weighted portfolios and combine equally.

Economic rationale

Momentum and 52-week high effects exploit investor behavioral biases like anchoring and herding. Combining both factors enhances predictability, capturing stronger short-term return patterns while reducing strategy risk.

Backtest performance

Annualised return31.3%
Volatility31.08%
Beta1.051
Sharpe ratio1.01
Sortino ratio0.235
Win rate56%

Full Python code

from AlgorithmImports import *
import data_tools
# endregion

class CombinedMomentumAndNearnessTo52WeekHigh(QCAlgorithm):

def Initialize(self):
self.SetStartDate(2000, 1, 1)
self.SetCash(100000)

self.leverage:int = 5
self.quantile:int = 5
self.total_portfolios:int = 2
self.portfolio_percentage:float = 1

self.min_price_period:int = 15
self.min_volume_period:int = 15
self.min_share_price:float = 1.
self.high_period:int = 52 * 5

self.exchanges:List[str] = ['NYS', 'NAS', 'ASE']

self.data:Dict[Symbol, data_tools.SymbolData] = {}
self.weight:Dict[Symbol, float] = {}
self.selected_symbols:List[Symbol] = []

self.fundamental_count:int = 500
self.fundamental_sorting_key = lambda x: x.DollarVolume

self.market_symbol:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol

self.selection_flag:bool = False
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelectionFunction)
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.Schedule.On(self.DateRules.MonthStart(self.market_symbol), self.TimeRules.BeforeMarketClose(self.market_symbol, 0), self.Selection)

def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
for security in changes.AddedSecurities:
    security.SetFeeModel(data_tools.CustomFeeModel())
    security.SetLeverage(self.leverage)

def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
if not self.selection_flag:
    return Universe.Unchanged

selected:List[Fundamental] = [x for x in fundamental if x.HasFundamentalData and x.Market == 'usa' and x.AdjustedPrice >= self.min_share_price and x.MarketCap != 0 and \
    not np.isnan(x.EarningReports.BasicAverageShares.ThreeMonths) and x.EarningReports.BasicAverageShares.ThreeMonths != 0 and x.SecurityReference.ExchangeId in self.exchanges]

if len(selected) > self.fundamental_count:
    selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]

self.selected_symbols.clear()

for stock in selected:
    symbol:Symbol = stock.Symbol
    self.selected_symbols.append(symbol)

    if symbol not in self.data:
        self.data[symbol] = data_tools.SymbolData(self.high_period)

    self.data[symbol].update_shares_outstanding(stock.EarningReports.BasicAverageShares.ThreeMonths)
    self.data[symbol].update_market_cap(stock.MarketCap)

symbols_to_delete:List[Symbol] = list(filter(lambda symbol: symbol not in self.selected_symbols, self.data))
for symbol in symbols_to_delete:
    del self.data[symbol]

return self.selected_symbols

def OnData(self, data: Slice) -> None:
for symbol in self.selected_symbols:
    if symbol in data and data[symbol] and data[symbol].High != 0 and \
     data[symbol].Price != 0 and data[symbol].Volume != 0:
        high:float = data[symbol].High
        volume:float = data[symbol].Volume
        price:float = data[symbol].Price

        self.data[symbol].update(price, volume, high)

if not self.selection_flag:
    return
self.selection_flag = False

performance:Dict[Symbol, float] = {}
turnover:Dict[Symbol, float] = {}
PTH:Dict[Symbol, float] = {}

for symbol in self.selected_symbols:
    symbol_obj:data_tools.SymbolData = self.data[symbol]

    if symbol_obj.PTH_data_ready():
        symbol_obj.update_PTH_values()
    else:
        symbol_obj.reset_PTH_values()

    if symbol_obj.is_ready(self.min_price_period, self.min_volume_period):
        performance[symbol] = symbol_obj.get_performance()
        turnover[symbol] = symbol_obj.get_turnover()
        PTH[symbol] = symbol_obj.get_prev_PTH_value()

    symbol_obj.reset_prices()
    symbol_obj.reset_volumes()

if len(performance) < self.quantile:
    self.Liquidate()
    return

quantile:int = int(len(performance) / self.quantile)
sorted_by_perf:List[Symbol] = [x[0] for x in sorted(performance.items(), key=lambda item: item[1])]
sorted_by_turnover:List[Symbol] = [x[0] for x in sorted(turnover.items(), key=lambda item: item[1])]
sorted_by_PTH:List[Symbol] = [x[0] for x in sorted(PTH.items(), key=lambda item: item[1])]

perf_winners:List[Symbol] = sorted_by_perf[-quantile:]
perf_losers:List[Symbol] = sorted_by_perf[:quantile]

lowest_turnover:List[Symbol] = sorted_by_turnover[:quantile]
highest_turnover:List[Symbol] = sorted_by_turnover[-quantile:]

lowest_PTH:List[Symbol] = sorted_by_PTH[:quantile]
highest_PTH:List[Symbol] = sorted_by_PTH[-quantile:]

# For the first portfolio, go long Hight-PTH Winners and go short Low-PTH Winners in the lowest turnover quintile.
long_leg = [x for x in lowest_turnover if x in highest_PTH and x in perf_winners and x in data and data[x]]
short_leg = [x for x in lowest_turnover if x in lowest_PTH and x in perf_winners and x in data and data[x]]
if len(long_leg) != 0 and len(short_leg) != 0:
    self.CalculateWeights(long_leg, short_leg)

# For the second portfolio, go long High PTH-Winners and go short Low-PTH Winners in the highest turnover quintile.
long_leg = [x for x in highest_turnover if x in highest_PTH and x in perf_winners and x in data and data[x]]
short_leg = [x for x in highest_turnover if x in lowest_PTH and x in perf_winners and x in data and data[x]]

if len(long_leg) != 0 and len(short_leg) != 0:
    self.CalculateWeights(long_leg, short_leg)

# 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 CalculateWeights(self, long_leg:List, short_leg:List) -> None:
for i, portfolio in enumerate([long_leg, short_leg]):
    mc_sum:float = sum(list(map(lambda symbol: self.data[symbol].get_market_cap(), portfolio)))
    for symbol in portfolio:
        self.weight[symbol] = ((self.data[symbol].get_market_cap() / mc_sum) / self.total_portfolios) * self.portfolio_percentage

def Selection(self) -> None:
self.selection_flag = True