Quant BuffetRelax, Not Over Thinking

High-Volatility Momentum-Reversal Strategy in Large-Cap Stocks

Log in to collect

Academic paper

Do Momentum and Reversals Coexist?

AuthorsJason Zhanshun Wei

Institute
  • CAUniversity of Toronto
  • ?University of Toronto - Rotman School of Management

Strategy in a nutshell

The investment universe consists of NYSE, AMEX, and NASDAQ stocks with prices higher than $5 per share. At the beginning of each month, the sample is divided into equal halves, at the size median, and only larger stocks are used. Then each month, realized returns and realized (annualized) volatilities are calculated for each stock for the past six months. One week (seven calendar days) prior to the beginning of each month is skipped to avoid biases due to microstructures. Stocks are then sorted into quintiles based on their realized past returns and past volatility. The investor goes long on stocks from the highest performing quintile from the highest volatility group and short on stocks from the lowest-performing quintile from the highest volatility group. Stocks are equally weighted and held for six months (therefore, 1/6 of the portfolio is rebalanced every month).

Economic rationale

Academic research postulates that the medium-term momentum is rationalized largely along the behavioral avenue. Gradual information diffusion and/or investor under-reaction leads to momentum (Chan, Jegadeesh and Lakonishok, 1996; and Hong, Lim and Stein, 2000). Some researchers show that information uncertainty can intensify return continuations under the postulation that investors under-react more (due to overconfidence) when presented with vague information. Following this line of thinking, investors should see stronger momentum in securities with greater information uncertainty, such as in smaller stocks and stocks with higher volatility.

Backtest performance

Annualised return16.46%
Volatility19.22
Beta0.046
Sharpe ratio0.354
Sortino ratio0.374
Maximum drawdown41.2%
Win rate50%

Full Python code

import numpy as np
from AlgoLib import *

class MomentumReversalCombinedWithVolatilityEffectinStocks(XXX):

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

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

# EW Tranching.
self.holding_period:int = 6
self.managed_queue:List[RebalanceQueueItem] = []

# Daily price data.
self.data:Dict[Symbol, SymbolData] = {}
self.period:int = 6 * 21
self.leverage:int = 5
self.min_share_price:float = 5.
self.exchange_codes:List[str] = ['NYS', 'NAS', 'ASE']
self.quantile:int = 5

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

self.selection_flag = False
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelectionFunction)
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.Schedule.On(self.DateRules.MonthStart(market), self.TimeRules.AfterMarketOpen(market), self.Selection)

def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
for security in changes.AddedSecurities:
    security.SetFeeModel(CustomFeeModel())
    security.SetLeverage(self.leverage)
    
def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> None:
# Update the rolling window every day.
for stock in fundamental:
    symbol = stock.Symbol

    # Store monthly price.
    if symbol in self.data:
        self.data[symbol].update(stock.AdjustedPrice)

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.SecurityReference.ExchangeId in self.exchange_codes 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]]

sorted_by_market_cap:List[Fundamental] = sorted(selected, key = lambda x: x.MarketCap, reverse=True)
half:int = int(len(sorted_by_market_cap) / 2)
top_by_market_cap:List[Symbol] = [x.Symbol for x in sorted_by_market_cap][:half]

perf_volatility:Dict[Symbol, Tuple[float, float]] = {}

# Warmup price rolling windows.
for stock in selected:
    symbol = stock.Symbol
    
    if symbol not in self.data:
        self.data[symbol] = SymbolData(symbol, self.period)
        history = self.History(symbol, self.period, Resolution.Daily)
        if history.empty:
            self.Log(f"Not enough data for {symbol} yet.")
            continue
        closes = history.loc[symbol].close
        for time, close in closes.items():
            self.data[symbol].update(close)
    
    # Performance and volatility tuple.
    if self.data[symbol].is_ready():
        performance = self.data[symbol].performance()
        annualized_volatility = self.data[symbol].volatility()
        perf_volatility[symbol] = (performance, annualized_volatility)                

long:List[Symbol] = []
short:List[Symbol] = []
if len(perf_volatility) >= self.quantile:
    sorted_by_perf:List[Tuple] = sorted(perf_volatility.items(), key = lambda x: x[1][0], reverse = True)
    quantile:int = int(len(sorted_by_perf) / self.quantile)
    top_by_perf:List[Symbol] = [x[0] for x in sorted_by_perf[:quantile]]
    low_by_perf:List[Symbol] = [x[0] for x in sorted_by_perf[-quantile:]]
    
    sorted_by_vol:List[Tuple] = sorted(perf_volatility.items(), key = lambda x: x[1][1], reverse = True)
    quantile = int(len(sorted_by_vol) / self.quantile)
    top_by_vol:List[Symbol] = [x[0] for x in sorted_by_vol[:quantile]]
    low_by_vol:List[Symbol] = [x[0] for x in sorted_by_vol[-quantile:]]
    
    long = [x for x in top_by_perf if x in top_by_vol]
    short = [x for x in low_by_perf if x in top_by_vol]

if len(long) != 0:
    long_w:float = self.Portfolio.TotalPortfolioValue / self.holding_period / len(long)
    # symbol/quantity collection
    long_symbol_q:List = [(x, np.ceil(long_w / self.data[x].get_last_price())) for x in long]
else:
    long_symbol_q:List = []

if len(short) != 0:
    short_w:float = self.Portfolio.TotalPortfolioValue / self.holding_period / len(short)
    # symbol/quantity collection
    short_symbol_q:List = [(x, -np.ceil(short_w / self.data[x].get_last_price())) for x in short]
else:
    short_symbol_q:List = []
        
self.managed_queue.append(RebalanceQueueItem(long_symbol_q + short_symbol_q))

return long + short

def OnData(self, data: Slice) -> None:
if not self.selection_flag:
    return
self.selection_flag = False
   
remove_item = None

# Rebalance portfolio
for item in self.managed_queue:
    if item.holding_period == self.holding_period:
        for symbol, quantity in item.symbol_q:
                self.MarketOrder(symbol, -quantity)
        
        remove_item = item
    
    # Trade execution    
    if item.holding_period == 0:
        open_symbol_q = []
        
        for symbol, quantity in item.symbol_q:
            if symbol in data and data[symbol] and self.Securities[symbol].IsTradable:
                self.MarketOrder(symbol, quantity)
                open_symbol_q.append((symbol, quantity))
                    
        # Only opened orders will be closed        
        item.symbol_q = open_symbol_q
        
    item.holding_period += 1
    
# We need to remove closed part of portfolio after loop. Otherwise it will miss one item in self.managed_queue.
if remove_item:
    self.managed_queue.remove(remove_item)

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

class RebalanceQueueItem():
def __init__(self, symbol_q:List):
# symbol/quantity collections
self.symbol_q:List = symbol_q  
self.holding_period:int = 0

class SymbolData():
def __init__(self, symbol: Symbol, period: int):
self._symbol:Symbol = symbol
self._price:RollingWindow = RollingWindow[float](period)
self._last_price:float = 0

def update(self, price: float) -> None:
self._price.Add(price)
self._last_price:float = price

def get_last_price(self) -> float:
return self._last_price

def is_ready(self) -> bool:
return self._price.IsReady

def volatility(self) -> float:
closes:np.ndarray = np.array(list(self._price)[5:]) # Skip last week.
daily_returns:np.ndarray = closes[:-1] / closes[1:] - 1
return np.std(daily_returns) * np.sqrt(252 / (len(closes)))

def performance(self) -> float:
closes:List[float] = list(self._price)[5:] # Skip last week.
return (closes[0] / closes[-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"))