Quant BuffetRelax, Not Over Thinking

Timing High and Low Volatility Equity Factor Strategy

Log in to collect

Academic paper

Low-Volatility Strategy: Can We Time the Factor?

AuthorsPoh Ling Neo; Chyng Wen Tee

Institute
  • SGSingapore University of Social Sciences
  • SGSingapore Management University
  • ?Singapore Management University - Lee Kong Chian School of Business

Strategy in a nutshell

The strategy invests in the 1,000 largest CRSP common stocks, sorted into decile portfolios by past-month realized volatility. The slope—return difference between high- and low-volatility deciles—guides allocation: positive slope favors high-volatility, negative favors low-volatility, and neutral maintains mid-volatility. Portfolios are rebalanced monthly, dynamically adjusting to market conditions.

Economic rationale

High-volatility stocks typically underperform long-term, while low-volatility stocks compound efficiently, offering strong risk-adjusted returns. Returns vary with market conditions: high-volatility excels in up markets, low-volatility in down markets. The slope of volatility decile returns signals these conditions, with mid-volatility portfolios serving as a neutral default, balancing risk and return when trends are unclear.

Backtest performance

Annualised return17.2%
Volatility17.7%
Beta0.977
Sharpe ratio0.72
Sortino ratio0.333
Win rate68%

Full Python code

import numpy as np
from AlgorithmImports import *
from pandas.core.frame import DataFrame
class TimingHighLowVolatility(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2000, 1, 1)
self.SetCash(100000)
self.period:int = 21
self.quantile:int = 10
self.slope_std_threshold:float = 4.
self.long:List[Symbol] = []
self.slopes:RollingWindow = RollingWindow[float](12)
self.data:Dict[Symbol, RollingWindow] = {}

market:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
self.fundamental_count:int = 500
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.MonthStart(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())
        
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].Add(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']
if len(selected) > self.fundamental_count:
    selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]

volatility:Dict[Symbol, float] = {}
momentum:Dict[Symbol, float] = {}
# Warmup price rolling windows.
for stock in selected:
    symbol:Symbol = stock.Symbol
    
    if symbol not in self.data:
        self.data[symbol] = RollingWindow[float](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].Add(close)
    
    if self.data[symbol].IsReady:
        closes:np.ndarray = np.array(list(self.data[symbol]))
        momentum[symbol] = closes[0] / closes[-1] - 1
        
        returns:np.ndarray = closes[:-1] / closes[1:] - 1
        volatility[symbol] = np.std(returns)
if len(momentum) >= self.quantile:
    # Volaility sorting
    sorted_by_vol:List[Symbol] = sorted(volatility, key = volatility.get, reverse = True)
    quantile:int = int(len(sorted_by_vol) / self.quantile)
    high_by_vol:List[Symbol] = sorted_by_vol[:quantile]
    low_by_vol:List[Symbol] = sorted_by_vol[-quantile:]
    mid_by_vol:List[Symbol] = [x for x in sorted_by_vol if x not in high_by_vol and x not in low_by_vol]

    # Slope calc
    high_vol_return:float = sum([momentum[x] for x in high_by_vol if x in momentum])
    low_vol_return:float = sum([momentum[x] for x in low_by_vol if x in momentum])
    slope:float = high_vol_return - low_vol_return

    if self.slopes.IsReady: 
        slopes:List[float] = list(self.slopes)
        slopes_mean:float = np.mean(slopes)
        slopes_std:float = np.std(slopes)
        if slope > slopes_mean + self.slope_std_threshold * slopes_std:
            self.long = high_by_vol
        elif slope < slopes_mean - self.slope_std_threshold * slopes_std:
            self.long = low_by_vol
        else:
            self.long = mid_by_vol
            
    self.slopes.Add(slope)

return self.long

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

# order execution
portfolio:List[PortfolioTarget] = [PortfolioTarget(symbol, 1. / len(self.long)) for symbol in self.long if symbol in data and data[symbol]]
self.SetHoldings(portfolio, True)
self.long.clear()

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

# Custom fee model
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters):
fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))