
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.
ASSET CLASS: stocks | REGION: United States | FREQUENCY:
Quarterly | MARKET: equities | KEYWORD: Conservative
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 Return | 15.1% |
| Volatility | 16.5% |
| Beta | 0.675 |
| Sharpe Ratio | 0.67 |
| Sortino Ratio | 0.459 |
| Maximum Drawdown | N/A |
| Win Rate | 78% |
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