
The strategy invests in CRSP stocks, using volatility-based decile portfolios and slope signals to dynamically shift between high-, low-, or mid-volatility portfolios, rebalancing monthly based on market conditions.
ASSET CLASS: stocks | REGION: United States | FREQUENCY:
Monthly | MARKET: equities | KEYWORD: Volatility
I. 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.
II. 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.
III. SOURCE PAPER
Low-Volatility Strategy: Can We Time the Factor? [Click to Open PDF]
Neo, Poh Ling and Tee, Chyng Wen, Singapore University of Social Sciences, Singapore Management University – Lee Kong Chian School of Business
<Abstract>
We show that the slope of the volatility decile portfolio’s return profile contains valuable information that can be used to time volatility under different market conditions. During good (bad) market condition, the high- (low-) volatility portfolio produces the highest return. We proceed to devise a volatility timing strategy based on statistical tests on the slope of the volatility decile portfolio’s return profile. Volatility timing is achieved by being aggressive during strong growth periods, while being conservative during market downturns. Superior performance is obtained, with an additional return of 4.1% observed in the volatility timing strategy, resulting in a five-fold improvement on accumulated wealth, along with statistically significant improvement in the Sortini ratio and the Information ratio. The authors also demonstrate that stocks in the high-volatility portfolio are more strongly correlated compared to stocks in the low-volatility portfolio. Hence the profitability of the volatility timing strategy can be attributed to successfully holding a diversified portfolio during bear markets, while holding a concentrated growth portfolio during bull markets.

IV. BACKTEST PERFORMANCE
| Annualised Return | 17.2% |
| Volatility | 17.7% |
| Beta | 0.977 |
| Sharpe Ratio | 0.72 |
| Sortino Ratio | 0.333 |
| Maximum Drawdown | N/A |
| Win Rate | 68% |
V. 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"))
VI. Backtest Performance