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.

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 Return17.2%
Volatility17.7%
Beta0.977
Sharpe Ratio0.72
Sortino Ratio0.333
Maximum DrawdownN/A
Win Rate68%

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

Leave a Reply

Discover more from Quant Buffet

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

Continue reading