The strategy invests in least volatile MSCI World index stocks, equally weighting across sectors and stocks within each sector, rebalancing monthly to prioritize low-risk, diversified returns.

I. STRATEGY IN A NUTSHELL

Go long on the least volatile decile of stocks within each MSCI World sector, equally weighting both stocks and sectors. Rebalance monthly to capture consistent, risk-adjusted returns with sector diversification.

II. ECONOMIC RATIONALE

Investor constraints, non-return-focused goals, unrealistic market assumptions, differing horizons, and cognitive biases drive the low volatility anomaly, allowing low-volatility stocks to outperform without traditional risk-based justification.

III. SOURCE PAPER

The Low Volatility Anomaly in Equity Sectors – 10 Years Later! [Click to Open PDF]

Benoit Bellone and Raul Leote de Carvalho. Quantcube Technology. BNP Paribas Asset Management.

<Abstract>

Ten years after showing that the low volatility anomaly in the performance of stocks is a phenomenon that should be considered in each sector as opposed to on an absolute basis ignoring sectors, we present evidence that this observation has held up well, and that if anything, has become even more valid.

IV. BACKTEST PERFORMANCE

Annualised Return6.96%
Volatility12.55%
Beta0.657
Sharpe Ratio0.55
Sortino Ratio0.38
Maximum DrawdownN/A
Win Rate59%

V. FULL PYTHON CODE

from AlgorithmImports import *
from pandas.core.frame import dataframe
from itertools import chain
class TheLowVolatilityAnomalyInEquitySectors(QCAlgorithm):
    def Initialize(self):
        self.SetStartDate(2000, 1, 1)
        self.SetCash(100000)
        
        self.data:Dict[Symbol, SymbolData] = {}
        self.longs:List[List[Symbol]] = []
        self.period:int = 3 * 12 * 21 # Three years of daily returns
        self.quantile:int = 10
        self.leverage:int = 5
        
        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.Settings.MinimumOrderMarginPortfolioPercentage = 0.
        self.AddUniverse(self.FundamentalSelectionFunction)
        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())
            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']
        if len(selected) > self.fundamental_count:
            selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]
        sectors = {}
        # 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)
            
            if self.data[symbol].is_ready():
                sector = stock.AssetClassification.MorningstarSectorCode
                if sector not in sectors:
                    sectors[sector] = []
                sectors[sector].append(symbol)
        
        for sector, symbols in sectors.items():
            symbols_volatility:Dict[Symbol, float] = { symbol : self.data[symbol].volatility() for symbol in symbols}
            quantile:int = int(len(symbols_volatility) / self.quantile)
            sorted_by_volatility:List[Symbol] = [x[0] for x in sorted(symbols_volatility.items(), key=lambda item: item[1])]
            
            self.longs.append(sorted_by_volatility[:quantile]) # Least volatile decile of sector 
            
        return list(set(chain.from_iterable(self.longs)))
    def OnData(self, data: Slice) -> None:
        if not self.selection_flag:
            return
        self.selection_flag = False
        
        # Trade execution
        self.Liquidate()
        
        total_sectors:int = len(self.longs)
        
        for long in self.longs:
            long_length:int = len(long)
            
            for symbol in long:
                if symbol in data and data[symbol]:
                    self.SetHoldings(symbol, 1. / long_length / total_sectors)
                
        self.longs.clear()
        
    def Selection(self) -> None:
        self.selection_flag = True
        
class SymbolData():
    def __init__(self, period):
        self._closes:RollingWindow = RollingWindow[float](period)
        
    def update(self, close: float) -> None:
        self._closes.Add(close)
        
    def is_ready(self) -> bool:
        return self._closes.IsReady
        
    def volatility(self) -> float:
        closes:np.ndarray = np.array(list(self._closes))
        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"))

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