The Conservative Formula
Log in to collectAcademic paper
The Conservative Formula: Quantitative Investing Made Easy
Pim van Vliet; David Blitz
- ?Robeco Quantitative Investments
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.
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.
Backtest performance
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"))