Quant BuffetRelax, Not Over Thinking

Value-Growth Timing

Log in to collect

Academic paper

Value Timing: Risk and Return Across Asset Classes

AuthorsFahiz Baba Yara; Martijn Boons; Andrea Tamoni

Institute
  • Indiana University
  • ?Indiana University - Kelley School of Business
  • NLTilburg University
  • Rutgers, The State University of New Jersey
  • ?Rutgers, The State University of New Jersey - Rutgers Business School at Newark & New Brunswick

Strategy in a nutshell

Focuses on large-cap stocks (top 75% market cap). Uses industry-adjusted book-to-market (BM) ratios to sort stocks into monthly decile portfolios. Long value stocks, short growth stocks, timed using standardized historical value spreads.

Economic rationale

Value spread measures relative cheapness of value vs. growth stocks. Positive spread = value stocks cheap, negative = expensive. Strong predictor of returns, linked to risk compensation, outperforming dividend yield signals.

Backtest performance

Annualised return6.42%
Volatility20.62%
Beta-0.051
Sharpe ratio0.31
Sortino ratio0.333
Win rate47%

Full Python code

from AlgorithmImports import *
from numpy import average, std, isnan
from typing import List, Dict, Tuple
#endregion    
class ValueGrowthTiming(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2000, 1, 1)
self.SetCash(100000)
self.symbol = self.AddEquity('SPY', Resolution.Daily).Symbol

self.fundamental_count:int = 3000
self.fundamental_sorting_key = lambda x: x.DollarVolume

# Monthly spread data.
self.period:int = 12
self.spread:RollinWindow = RollingWindow[float](self.period)
self.standardized_spread:RollingWindow = RollingWindow[float](self.period)
self.quantile:int = 10
self.leverage:int = 10

self.long:List[Symbol] = []
self.short:List[Symbol] = []
self.weight:Union[None, int] = None
self.selection_flag:bool = False
self.UniverseSettings.Resolution = Resolution.Daily
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelectionFunction)
self.Schedule.On(self.DateRules.MonthEnd(self.symbol), self.TimeRules.BeforeMarketClose(self.symbol), 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]:
if not self.selection_flag:
    return Universe.Unchanged

selected = [x for x in fundamental if x.HasFundamentalData and x.Market == 'usa' \
        and not isnan(x.AssetClassification.MorningstarIndustryGroupCode) and x.AssetClassification.MorningstarIndustryGroupCode != 0 \
        and not isnan(x.ValuationRatios.PBRatio) and x.ValuationRatios.PBRatio != 0 and x.MarketCap != 0]
if len(selected) > self.fundamental_count:
    selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]
group:Dict[str, Tuple[Symbol, float, float]] = {}

# Stocks returns calc.
for stock in selected:
    symbol = stock.Symbol
    
    industry_group_code:float = stock.AssetClassification.MorningstarIndustryGroupCode
    book_market:float = 1 / stock.ValuationRatios.PBRatio
    
    # Adding stock in group.
    if not industry_group_code in group:
        group[industry_group_code] = []
    group[industry_group_code].append((symbol, book_market, stock.MarketCap))

# Group's value weighted average calc.
group_bm_average:Dict[str, float] = {}
for industry_group_code, symbols in group.items():
    total_market_cap:float = sum([x[2] for x in symbols])
    
    weighted_items:Dict[Symbol, float] = {x[0] : (x[1] * (x[2] / total_market_cap)) for x in symbols}
    group_bm_average[industry_group_code] = average([x[1] for x in weighted_items.items()])

# Symbol's adjusted bm calc.
adjusted_bm:Dict[Symbol, float] = {}
for industry_group_code, symbols in group.items():
    for symbol in symbols:
        symbol_bm:float = symbol[1]
        adjusted_bm[symbol] = symbol_bm - group_bm_average[industry_group_code]
if len(adjusted_bm) < self.quantile:
    return Universe.Unchanged
sorted_by_adjusted_bm:List[Tuple[Symbol, float]] = sorted(adjusted_bm.items(), key = lambda x: x[1], reverse = True)
quantile:int = int(len(sorted_by_adjusted_bm) / self.quantile)
value_portfolio:List[Symbol] = sorted_by_adjusted_bm[:quantile]
growth_portfolio:List[Symbol] = sorted_by_adjusted_bm[-quantile:]
spread:float = average([x[1] for x in value_portfolio]) - average([x[1] for x in growth_portfolio])
self.spread.Add(spread)
if self.spread.IsReady:
    spread_values:List[float] = [x for x in self.spread]
    standardized_spread:float = average(spread_values) / std(spread_values)
    self.standardized_spread.Add(standardized_spread)
    if self.standardized_spread.IsReady:
        standardized_spread_values:List[float] = [x for x in self.standardized_spread]
        
        self.weight = abs(standardized_spread_values[0] / max(standardized_spread_values[1:]))
        if self.weight > 1:
            self.weight = 1
        self.long = [x[0][0] for x in value_portfolio]
        self.short = [x[0][0] for x in growth_portfolio]
    
return self.long + self.short
def OnData(self, data: Slice) -> None:
if not self.selection_flag:
    return
self.selection_flag = False

# Trade execution.
targets:List[PortfolioTarget] = []
if self.weight:
    for i, portfolio in enumerate([self.long, self.short]):
        for symbol in portfolio:
            if symbol in data and data[symbol]:
                targets.append(PortfolioTarget(symbol, ((-1) ** i) * (self.weight / len(portfolio))))

self.SetHoldings(targets, True)
self.long.clear()
self.short.clear()
self.weight = None
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"))