The strategy selects the top 100 low-volatility US stocks based on 12-month momentum and net payout yield, rebalanced quarterly and equally weighted, aiming to exploit strong shareholder yield and momentum.

I. STRATEGY IN A NUTSHELL

The strategy targets the 1,000 largest US stocks, selecting the 500 lowest-volatility names. These are ranked by 12-1 month momentum and net payout yield (dividends plus share repurchases), averaged to pick the top 100 stocks for long positions. Portfolios are equally weighted and rebalanced quarterly to capture low-volatility stocks with strong shareholder yield and momentum.

II. ECONOMIC RATIONALE

By combining momentum, low volatility, and net payout yield, the Conservative Formula efficiently captures factor premiums with minimal data input. Its simplicity ensures robustness, low turnover, and reduced trading costs. Tested since 1929 and across markets, it delivers consistent returns and strong economic relevance without overfitting or complex adjustments.

III. SOURCE PAPER

The Conservative Formula: Quantitative Investing Made Easy [Click to Open PDF]

Pim van Vliet — Robeco Quantitative Investments; David Blitz — Robeco Quantitative Investments.

<Abstract>

We propose a conservative investment formula which selects 100 stocks based on three criteria: low return volatility, high net payout yield, and strong price momentum. We show that this simple formula gives investors full and efficient exposure to the most important factor premiums, and thus effectively summarizes half a century of empirical asset pricing research into one easy to implement investment strategy. With a compounded annual return of 15.1 percent since 1929, the conservative formula outperforms the market by a wide margin. It reduces downside risk and shows a positive return over every decade. The formula is also strong in European, Japanese and Emerging stock markets, and beats a wide range of other strategies based on size, value, quality, and momentum combinations. The formula is designed to be a practically useful tool for a broad range of investors and addresses academic concerns about ‘p-hacking’ by using three simple criteria, which do not even require accounting data.

IV. BACKTEST PERFORMANCE

Annualised Return15.1%
Volatility16.5%
Beta0.675
Sharpe Ratio0.67
Sortino Ratio0.459
Maximum DrawdownN/A
Win Rate78%

V. FULL PYTHON CODE

import numpy as np
from AlgorithmImports import *
from typing import List, Dict
from numpy import isnan
class TheConservativeFormula(QCAlgorithm):
    def Initialize(self):
        self.SetStartDate(2000, 1, 1)
        self.SetCash(100000)
        self.period:int = 36 * 21
        self.leverage:int = 10
        self.quantile:int = 5
        self.long:List[Symbol] = []
        self.data:Dict[Symbol, SymbolData] = {}
        
        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.Settings.MinimumOrderMarginPortfolioPercentage = 0.
        self.UniverseSettings.Resolution = Resolution.Daily
        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' and \
                                  not isnan(x.ValuationRatios.TotalYield) and (x.ValuationRatios.TotalYield > 0)]
        
        # Warmup price rolling windows.
        for stock in selected:
            symbol:Symbol = stock.Symbol
            if symbol in self.data:
                continue
            
            self.data[symbol] = SymbolData(self.period)
            history = self.History(symbol, self.period, Resolution.Daily)
            if history.empty:
                self.Log(f"Not enough data for {symbol} yet.")
                continue
            closes = history.loc[symbol].close
            for time, close in closes.items():
                self.data[symbol].update(close)
            
        yield_performance_volatility:Dict[Symbol, (Tuple[float])] = { x.Symbol : (x.ValuationRatios.TotalYield , self.data[x.Symbol].performance(), self.data[x.Symbol].volatility()) for x in selected if self.data[x.Symbol].is_ready()}
        
        # Volatility sorting.
        if len(yield_performance_volatility) < 2:
            return Universe.Unchanged
        sorted_by_ret_vol:List[Tuple[Symbol, float]] = sorted(yield_performance_volatility.items(), key = lambda x: x[1][2], reverse = True)
        half:int = int(len(sorted_by_ret_vol) / 2)
        low_by_ret_vol:List[Tuple[Symbol, float]] = [x for x in sorted_by_ret_vol[-half:]]
        
        # Scoring
        rank:Dict[Symbol, int] = {}
        for symbol, _ in low_by_ret_vol:
            rank[symbol] = 0
        
        sorted_by_mom:List[Tuple[Symbol, float]] = sorted(low_by_ret_vol, key = lambda x: x[1][1], reverse = True)
        score:int = len(sorted_by_mom)
        for symbol, _ in sorted_by_mom:
            rank[symbol] += score
            score -= 1
            
        sorted_by_yield:List[Tuple[Symbol, float]] = sorted(low_by_ret_vol, key = lambda x: x[1][0], reverse = True)
        score:int = len(sorted_by_yield)
        for symbol, _ in sorted_by_yield:
            rank[symbol] += score
            score -= 1
        if len(rank) >= self.quantile:
            sorted_by_rank:List[Tuple[Symbol, float]] = sorted(rank.items(), key = lambda x: x[1], reverse = True)
            quintile:int = int(len(sorted_by_rank) / self.quantile)
        
            self.long:List[Symbol] = [x[0] for x in sorted_by_rank[:quintile]]
        
        return self.long
    def OnData(self, data: Slice) -> None:
        if not self.selection_flag: 
            return
        self.selection_flag = False
        
        # Trade 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:
        if self.Time.month % 3 == 0:
            self.selection_flag = True
class SymbolData():
    def __init__(self, period:int):
        self._price:RollingWindow = RollingWindow[float](period)
    
    def update(self, price:float) -> None:
        self._price.Add(price)
    
    def is_ready(self) -> bool:
        return self._price.IsReady
        
    def performance(self, values_to_skip = 0) -> float:
        closes:List[float] = [x for x in self._price][:12*21][values_to_skip:]
        return (closes[0] / closes[-1] - 1)                
    
    def volatility(self) -> float:
        closes:np.ndarray = np.array([x for x in self._price])
        returns:np.ndarray = (closes[:-1] / closes[1:]) - 1
        return np.std(returns)
        
# 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