Quant BuffetRelax, Not Over Thinking

Combining Fundamental and Transitory Component of Value Strategy

Log in to collect

Academic paper

Kellogg School of Management - Department of Finance

AuthorsZhengyang Jiang

Institute
  • National Bureau of Economic Research
  • CAKellogg's (Canada)
  • ?Kellogg School of Management - Department of Finance
  • ?National Bureau of Economic Research (NBER)

Strategy in a nutshell

This strategy separates stocks’ P/B ratios into fundamental and transitory components. Investors go long on transitory (mispriced) stocks and short on fundamental (quality) stocks, with annual rebalancing and value-weighted positions.

Economic rationale

The fundamental component’s returns reflect sluggish price and quantity adjustments, as investors slowly incorporate quality signals. The transitory component’s predictability arises from return reversals, capturing temporary mispricing.

Backtest performance

Annualised return8.98%
Beta-0.051
Sortino ratio-0.675
Win rate49%

Full Python code

from AlgorithmImports import *
import numpy as np
import statsmodels.api as sm
from typing import List, Dict
from numpy import isnan
#endregion
 
class CombiningFundamentalAndTransitoryComponentOfValueStrategy(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2000, 1, 1)
self.SetCash(100000)

self.exchange_codes: List[str] = ['NYS', 'NAS', 'ASE']
self.tickers_to_ignore: List[str] = ['SGA']
self.period: int = 2 # need n values for regression

self.data: Dict[Symbol, SymbolData] = {}
self.quantities: Dict[Symbol, int] = {}
self.last_selection: List[Symbol] = []

self.min_share_price: int = 5
self.leverage: int = 5
self.quantile: int = 5
self.month_counter: int = 0
self.fundamental_count: int = 3000
self.fundamental_sorting_key = lambda x: x.MarketCap
self.selection_flag: bool = False
self.symbol: Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol

self.settings.daily_precise_end_time = False
self.Settings.MinimumOrderMarginPortfolioPercentage = 0
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelectionFunction)

self.Schedule.On(self.DateRules.MonthStart(self.symbol), self.TimeRules.BeforeMarketClose(self.symbol, 0), self.Selection)
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:
# selection yearly
if not self.selection_flag:
    return Universe.Unchanged

# filter top n stocks by dollar volume
selected: List[Fundamental] = [
    x for x in fundamental \
    if x.HasFundamentalData and \
    x.Market == 'usa' and \
    x.Price > self.min_share_price and not \
    isnan(x.ValuationRatios.PBRatio) and x.ValuationRatios.PBRatio != 0 and not\
    isnan(x.FinancialStatements.IncomeStatement.GrossProfit.TwelveMonths) and x.FinancialStatements.IncomeStatement.GrossProfit.TwelveMonths != 0 and not\
    isnan(x.FinancialStatements.BalanceSheet.TotalAssets.TwelveMonths) and x.FinancialStatements.BalanceSheet.TotalAssets.TwelveMonths != 0 and \
    x.MarketCap != 0 and \
    x.SecurityReference.ExchangeId in self.exchange_codes and \
    x.Symbol.Value not in self.tickers_to_ignore
]
if len(selected) > self.fundamental_count:
            selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]
                
HMLFundamental: Dict[Fundamental, float] = {} 
HMLTransitory: Dict[Fundamental, float] = {}

# store current stocks prices for trenching
for stock in selected:
    symbol: Symbol = stock.Symbol
    
    if symbol not in self.data:
        self.data[symbol] = SymbolData(self.period)
    
    # update price
    self.data[symbol].update_price(stock.AdjustedPrice)
   
    # symbol = stock.Symbol
    pb_ratio: float = stock.ValuationRatios.PBRatio
    total_assets: float = stock.FinancialStatements.BalanceSheet.TotalAssets.TwelveMonths
    gross_profit: float = stock.FinancialStatements.IncomeStatement.GrossProfit.TwelveMonths
    
    # make sure data are consecutive
    if symbol not in self.last_selection:
        self.data[symbol] = SymbolData(self.period)
        
    self.data[symbol].update_regression_data(pb_ratio, total_assets, gross_profit)
        
    # make sure data for regression are ready
    if not self.data[symbol].is_data_ready():
        continue
    
    # calculate stock's Y and Xs for regression
    regression_y: List[float] = self.data[symbol].get_regression_y()
    regression_x: List[List[float]] = self.data[symbol].get_regression_x()
    
    regression_model: RegressionResultWrapper = self.MultipleLinearRegression(regression_x, regression_y)
    
    # calculate fit value
    fit_value: float = regression_model.params[0]
    # iterate through betas - handles missing beta value if profit growth value is 0
    for i, beta in enumerate(regression_model.params[1:]):
        corresponding_x: float = regression_x[i][1]
        fit_value += corresponding_x * beta
    
    # store fit value keyed by stock
    HMLFundamental[stock] = fit_value
    
    # last residual from regression is needed for HMLTransitory strategy    
    last_residual: float = regression_model.resid[-1]
    # store last residual keyed by stock
    HMLTransitory[stock] = last_residual
    
# change last selection, to make data consecutive
self.last_selection = [x.Symbol for x in selected]

# there has to be enough stocks for quintile selections    
if len(HMLFundamental) < self.quantile or len(HMLTransitory) < self.quantile:
    return Universe.Unchanged
    
quantile: int = int(len(HMLFundamental) / self.quantile)
sorted_by_fundamental: List[Fundamental] = [x[0] for x in sorted(HMLFundamental.items(), key=lambda item: item[1])]
sorted_by_transitory: List[Fundamental] = [x[0] for x in sorted(HMLTransitory.items(), key=lambda item: item[1])]

# select long and short
fundamental_long_stocks: List[Fundamental] = sorted_by_fundamental[:quantile]
fundamental_short_stocks: List[Fundamental] = sorted_by_fundamental[-quantile:]

transitory_long_stocks: List[Fundamental] = sorted_by_transitory[:quantile]
transitory_short_stocks: List[Fundamental] = sorted_by_transitory[-quantile:]

# perform trenching
# have to divide weight by 2, because there are 2 different strategies in portfolio
weight: float = self.Portfolio.TotalPortfolioValue / 2
# NOTE self.quantities is modified bellow
# calculate quantities for long parts
self.CalculateQuantities(fundamental_long_stocks, weight, True)
self.CalculateQuantities(transitory_long_stocks, weight, True)

# calculate quantities for short parts
self.CalculateQuantities(fundamental_short_stocks, weight, False)
self.CalculateQuantities(transitory_short_stocks, weight, False)

return list(self.quantities.keys())

def OnData(self, data: Slice) -> None:
# rebalance yearly
if not self.selection_flag:
    return
self.selection_flag = False

# trade execution
self.Liquidate()
        
for symbol, quantity in self.quantities.items():
    if symbol in data and data[symbol]:
        self.MarketOrder(symbol, quantity)
    
self.quantities.clear()

def MultipleLinearRegression(self, x: List[List[float]], y: List[float]):
x: np.ndarray = np.array(x).T
x = sm.add_constant(x)
result: RegressionResultWrapper = sm.OLS(endog=y, exog=x).fit()
return result

def CalculateQuantities(self, stock_list: List[Fundamental], weight: float, long_flag: bool) -> None:
total_cap: float = sum([stock.MarketCap for stock in stock_list])

for stock in stock_list:
    price: float = self.data[stock.Symbol].price
    market_cap: float = stock.MarketCap
    
    # calculate quantity
    quantity: int = np.floor((weight * (market_cap / total_cap)) / price) 
    
    # stock goes short
    if not long_flag:
        quantity = -1 * quantity
    
    self.quantities[stock.Symbol] = quantity

def Selection(self) -> None:
# rebalance yearly
if self.month_counter % 12 == 0:
    self.selection_flag = True
self.month_counter += 1
    
class SymbolData():
def __init__(self, period: int) -> None:
self.pb_ratio: RollingWindow = RollingWindow[float](period)
self.gross_profit: RollingWindow = RollingWindow[float](period + 1) 
self.total_assets: RollingWindow = RollingWindow[float](period + 1)
self.price: Union[None, float] = None

def update_price(self, price: float) -> None:
self.price = price

def update_regression_data(self, pb_ratio: float, total_assets: float, gross_profit: float) -> None:
self.pb_ratio.Add(pb_ratio)
self.total_assets.Add(total_assets)
self.gross_profit.Add(gross_profit)

def is_data_ready(self) -> bool:
# return self.pb_ratio.IsReady and self.gross_profit.IsReady and \
#         self.total_assets.IsReady and self.market_cap != None and self.price != None
return self.pb_ratio.IsReady and self.gross_profit.IsReady and self.total_assets.IsReady

def get_regression_y(self) -> List[float]:
return [x for x in self.pb_ratio][::-1]
        
def get_regression_x(self) -> List[List[float]]:
gross_profit_values: np.ndarray = np.array([x for x in self.gross_profit])
total_assets_values: np.ndarray = np.array([x for x in self.total_assets])

x1: List[float] = [gpv / tav for gpv, tav in zip(gross_profit_values[:-1], total_assets_values[:-1])]
x2: List[float] = (gross_profit_values[:-1] / gross_profit_values[1:] - 1) / total_assets_values[1:]     # profit growth / total assets from previous year

return [x1[::-1], x2[::-1]]

# Custom fee model
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters: OrderFeeParameters) -> OrderFee:
fee: float = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))