Value-Growth Timing
Log in to collectAcademic paper
Value Timing: Risk and Return Across Asset Classes
Fahiz Baba Yara; Martijn Boons; Andrea Tamoni
- 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"))