The strategy invests in London Stock Exchange stocks, calculating risk-adjusted momentum. Stocks are sorted into deciles, going long on the top and short on the bottom, with volatility scaling for comparison.

I. STRATEGY IN A NUTSHELL

The strategy invests in stocks listed on the London Stock Exchange. For each stock, realized volatility and momentum are computed, and generalized risk-adjusted momentum is derived by dividing momentum by realized volatility raised to the N-th power. The optimal N is selected monthly to maximize the strategy’s Sharpe ratio. Stocks are ranked into deciles—long on the top decile and short on the bottom. The portfolio is scaled to a target volatility using the past six months of realized volatility, enabling direct comparison with the time-varying volatility strategy of Barroso and Santa-Clara (2015).

II. ECONOMIC RATIONALE

Momentum performance is largely driven by the cross-sectional realized volatility of assets. High-volatility stocks are more frequently included in momentum portfolios but contribute to excessive portfolio risk, while low-volatility stocks tend to exhibit stronger and more stable momentum effects. Traditional momentum strategies, biased toward volatile stocks, underperform during uncertain market conditions. By optimizing the volatility exponent (N), this strategy adapts its aggressiveness dynamically—becoming more risk-taking in stable periods and defensive in volatile ones. Compared with Barroso and Santa-Clara’s constant volatility scaling, the generalized approach yields higher risk-adjusted returns and statistically significant alphas.

III. SOURCE PAPER

Momentum and the Cross-Section of Stock Volatility [Click to Open PDF]

Fan, Minyou; Kearney, Fearghal Joseph; Li, Youwei; Liu, Jiadong — Queen’s University Belfast; University of Hull.

<Abstract>

Recent literature shows that momentum strategies exhibit significant downside risks over
certain periods, called “momentum crashes”. We find that high uncertainty of momentum
strategy returns is sourced from the cross-sectional volatility of individual stocks. Stocks
with high realised volatility over the formation period tend to lose momentum effect. We
propose a new approach, generalised risk-adjusted momentum (GRJMOM), to mitigate
the negative impact of high momentum-specific risks. GRJMOM is proven to be more
profitable and less risky than existing momentum ranking approaches across multiple
asset classes, including the UK stock, commodity, global equity index, and fixed income
markets.

IV. BACKTEST PERFORMANCE

Annualised Return30.3%
Volatility23%
Beta-0.128
Sharpe Ratio1.32
Sortino Ratio0.12
Maximum Drawdown-50%
Win Rate54%

V. FULL PYTHON CODE

from collections import deque
from AlgorithmImports import *
from math import sqrt
import operator
import numpy as np
from pandas.core.frame import dataframe
class GeneralisedRiskAdjustedMomentuminStocks(QCAlgorithm):
    
    def Initialize(self):
        self.SetStartDate(2010, 1, 1)
        self.SetCash(100000)
        # Daily price data.
        self.data:Dict[Symbol, SymbolData] = {}
        # Volatility factor.
        self.volatility_factor_symbols:List[Symbol] = []                  # Symbols
        self.volatility_factor_vector:deque = deque(maxlen = 6)    # Monthly volatility.
        
        self.market:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
        self.data[self.market] = SymbolData(21) # One month RollingWindow
        
        # Minimal data count needed for optimalization.
        self.period:int = 12 * 21
        
        self.leverage_cap:int = 3
        self.leverage:int = 5
        self.quantile:int = 4
        
        self.long:List[Symbol] = []
        self.short:List[Symbol] = []
        
        self.fundamental_count:int = 200
        self.fundamental_sorting_key = lambda x: x.DollarVolume
        self.selection_flag:bool = False
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.FundamentalSelectionFunction)
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
        self.Schedule.On(self.DateRules.MonthEnd(self.market), self.TimeRules.AfterMarketOpen(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]:
        # 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)
        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.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, Resolution.Daily)
                if history.empty:
                    self.Log(f"Not enough data for {symbol} yet")
                    continue
                closes:pd.Series = history.loc[symbol].close
                for time, close in closes.items():
                    self.data[symbol].update(close)
            
        # Sharpe ratio indexed by n.
        sharpe_ratio_data:Dict = {}
        n = 0
        while n <= 4:
            performance_volatility:Dict[Symbol, Tuple[float, float]] = {}
            generalized_momentum:Dict[Symbol, float] = {}
            
            for stock in selected:
                symbol:Symbol = stock.Symbol
                if self.data[symbol].is_ready():
                    # Realized volatility calc.
                    perf:float = self.data[symbol].performance()
                    vol:float = self.data[symbol].volatility()
                    performance_volatility[symbol] = (perf, vol)
                        
                    daily_prices = np.array([x for x in self.data[symbol].twelve_months_data])
                    daily_returns = (daily_prices[:-1] - daily_prices[1:]) / daily_prices[1:] 
                    realized_volatility = sqrt(sum(daily_returns**2) / self.period)
                        
                    generalized_momentum[symbol] = perf / (realized_volatility ** n)
                    
            if len(generalized_momentum) >= self.quantile:
                sorted_by_momentum:List[Symbol] = sorted(generalized_momentum, key = generalized_momentum.get, reverse = True)
                quantile:int = int(len(sorted_by_momentum) / self.quantile)
                long:List[Symbol] = sorted_by_momentum[:quantile]
                short:List[Symbol] = sorted_by_momentum[-quantile:]
                
                # Sharpe ratio calc.
                symbol_count:int = len(long + short)
                total_performance:float = sum([performance_volatility[x][0] / symbol_count for x in long])
                total_performance += sum([((-1) * performance_volatility[x][0]) / symbol_count for x in short])
                
                total_volatility:float = sum([performance_volatility[x][1] / symbol_count for x in long + short])
                portfolio_sharpe_ratio:float = total_performance / total_volatility
                
                portfolio_symbols:List[List[Symbol]] = [long, short]
                sharpe_ratio_data[str(n)] = (portfolio_sharpe_ratio, portfolio_symbols)
                
            n += 0.1
        
        if len(sharpe_ratio_data) != 0:
            max_by_sharpe_ratio = max(sharpe_ratio_data.items(), key = operator.itemgetter(1))
            self.long = max_by_sharpe_ratio[1][1][0]
            self.short = max_by_sharpe_ratio[1][1][1]        
        
        return self.long + self.short
                        
    def OnData(self, data: Slice) -> None:
        if not self.selection_flag:
            return
        self.selection_flag = False
        # Calculate last month's volatility.
        if len(self.volatility_factor_symbols) != 0:
            monthly_volatility:float = self.CalculateFactorVolatility(self.data, self.volatility_factor_symbols)
            if monthly_volatility != 0:
                self.volatility_factor_vector.append(monthly_volatility)
        # Store new factor symbols.
        self.volatility_factor_symbols = self.long + self.short
        # Volatility factor is ready.
        if len(self.volatility_factor_vector) == self.volatility_factor_vector.maxlen:
            # Volatility targetting.
            if self.data[self.market].is_ready():
                annual_index_volatility:float = self.data[self.market].volatility() * sqrt(12*21)
                realized_strategy_volatility:float = sum(self.volatility_factor_vector) / len(self.volatility_factor_vector)
                
                # Cap leverage if needed.
                target_leverage:float = min(self.leverage_cap, (annual_index_volatility / realized_strategy_volatility))
                        
                # Trade execution.
                targets:List[PortfolioTarget] = []
                for i, portfolio in enumerate([self.long, self.short]):
                    for symbol in portfolio:
                        if symbol in data and data[symbol]:
                            targets.append(PortfolioTarget(symbol, ((-1) ** i) / len(portfolio)))
        
                self.SetHoldings(targets, True)
        self.long.clear()
        self.short.clear()
        
    def Selection(self) -> None:
        self.selection_flag = True
        
    def CalculateFactorVolatility(self, data, factor_symbols) -> float:
        monthly_volatility:float = 0
        if len(factor_symbols) != 0:
            for symbol in factor_symbols:
                if symbol in data and data[symbol].length() >= 21:
                    monthly_volatility += (data[symbol].last_month_volatility() / len(factor_symbols))
        return monthly_volatility
        
class SymbolData():
    def __init__(self, period: int):
        self.twelve_months_data:RollingWindow = RollingWindow[float](period)
        
    def update(self, close: float) -> None:
        self.twelve_months_data.Add(close)
        
    def is_ready(self) -> bool:
        return self.twelve_months_data.IsReady
        
    def length(self) -> int:
        return self.twelve_months_data.Count
        
    def performance(self) -> float:
        return self.twelve_months_data[0] / self.twelve_months_data[self.twelve_months_data.Count - 1] - 1
        
    def volatility(self) -> float:
        closes:np.ndarray = np.array(list(self.twelve_months_data))
        returns:np.ndarray = (closes[:-1] - closes[1:]) / closes[1:]
        return np.std(returns) 
        
    def last_month_volatility(self) -> float:
        closes:np.ndarray = np.array(list(self.twelve_months_data)[:21])
        returns:np.ndarray = (closes[:-1] - closes[1:]) / closes[1:]
        return np.std(returns) 
        
class CustomFeeModel(FeeModel):
    def GetOrderFee(self, parameters):
        fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
        return OrderFee(CashAmount(fee, "USD"))

Leave a Reply

Discover more from Quant Buffet

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

Continue reading