The strategy invests in value-weighted decile portfolios sorted by B/M equity ratios. If the 24-month return on the CRSP value-weighted index is negative, it goes long the highest and short the lowest decile.

I. STRATEGY IN A NUTSHELL

“Cheap” stocks (high book-to-market ratio) and “expensive” stocks (low B/M)-ai identify panni, market periya maari down irundha, cheap stocks-ai vaangi, expensive stocks-ai sell pannu. Positions 6 months hold pannuvom.

II. ECONOMIC RATIONALE

Oru vela investors overreact panni expensive stocks buy pannuvanga; cheap stocks ignore pannuvanga. Market calm-a irundha, cheap stocks-u better returns koduthu, overpriced stocks-u avoid pannumbothu safe-a irukkum.

III. SOURCE PAPER

Value Bubbles [Click to Open PDF]

Chibane, Messaoud; Ouzan, Samuel — Neoma Business School; School of Finance, HSE University; Neoma Business School.

<Abstract>

The study reveals that the historical performance of value strategies is primarily driven by occasional bubbles. Behavioral theories suggest the value premium should vary with extended lagged market return or other aggregate proxies of investor sentiment—a hypothesis supported by our cross-sectional tests. From 1926 to 2022, following two years of negative market returns, we find that the U.S. value premium is about three times its unconditional counterpart, whereas it appears to vanish following two years of positive market returns. Additionally, periods of substantial losses in momentum strategies (momentum crashes) very often coincide with substantial profits in value strategies (value bubbles). Leveraging these insights, we develop a positively skewed, implementable dynamic investment strategy, enhancing the Sharpe ratio of standard value and momentum strategy by over threefold and 60%, respectively. Our findings are internationally robust.

IV. BACKTEST PERFORMANCE

Annualised Return3.68%
Volatility3.45%
Beta0.104
Sharpe Ratio1.07
Sortino Ratio-0.113
Maximum DrawdownN/A
Win Rate52%

V. FULL PYTHON CODE

from AlgorithmImports import *
from numpy import floor
#endregion
class ValueFactorAfterNegativeMarketReturn(QCAlgorithm):
    def Initialize(self):
        self.SetStartDate(2000, 1, 1)
        self.SetCash(100000)
        self.coarse_count = 500
        
        self.period = 24 * 21
        self.SetWarmUp(self.period, Resolution.Daily)
        self.quantile = 10
        # Trenching
        self.holding_period = 6
        self.managed_queue = []
        
        self.symbol = self.AddEquity('VTI', Resolution.Daily).Symbol
        self.data = RollingWindow[float](self.period)
        self.selection_flag = False
        
        self.recent_price = {}  # recent stock prices
        
        self.UniverseSettings.Resolution = Resolution.Daily
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
        self.settings.daily_precise_end_time = False
        self.AddUniverse(self.CoarseSelectionFunction, self.FineSelectionFunction)
        self.Schedule.On(self.DateRules.MonthEnd(self.symbol), self.TimeRules.AfterMarketOpen(self.symbol), self.Selection)
        
    def OnSecuritiesChanged(self, changes):
        for security in changes.AddedSecurities:
            security.SetFeeModel(CustomFeeModel())
            security.SetLeverage(10)
    def CoarseSelectionFunction(self, coarse):
        for stock in coarse:
            symbol = stock.Symbol
            # append recent price to market symbol
            if symbol == self.symbol:
                self.data.Add(stock.AdjustedPrice)
            # store monthly stock prices
            if self.selection_flag:
                self.recent_price[symbol] = stock.AdjustedPrice
            
        if not self.selection_flag:
            return Universe.Unchanged
        selected = sorted([x for x in coarse if x.HasFundamentalData and x.Price > 5],
            key=lambda x: x.DollarVolume, reverse=True)[:self.coarse_count]
        return [x.Symbol for x in selected]
    def FineSelectionFunction(self, fine):
        fine = [x for x in fine if x.MarketCap != 0 and x.ValuationRatios.PBRatio != 0]
        
        # market data is ready
        if self.data.IsReady:
            market_ret:float = self.data[0] / self.data[self.period-1] - 1
            if market_ret >= 0:
                return Universe.Unchanged
        else:
            return Universe.Unchanged
        bm_ratio = {}
        market_cap = {}
        for stock in fine:
            symbol = stock.Symbol
            
            market_cap[symbol] = stock.MarketCap
            bm_ratio[symbol] = 1 / stock.ValuationRatios.PBRatio
        
        long:list = []
        short:list = []
        
        if len(bm_ratio) >= self.quantile:
            # BM ratio sorting 
            sorted_by_bm = sorted(bm_ratio.items(), key=lambda x: x[1], reverse=True)
            decile = int(len(sorted_by_bm) / self.quantile)
            
            # Long the highest decile portfolio 
            # Short the lowest decile portolio
            long = [x[0] for x in sorted_by_bm[:decile]]
            short = [x[0] for x in sorted_by_bm[-decile:]]
        
            # Market cap weighting
            equity = self.Portfolio.TotalPortfolioValue / self.holding_period
            
            weights = {}
            
            total_market_cap_long = sum([market_cap[sym] for sym in long if sym in market_cap])
            for symbol in long:
                if symbol in market_cap:
                    weights[symbol] = market_cap[symbol] / total_market_cap_long
            
            total_market_cap_short = sum([market_cap[sym] for sym in short if sym in market_cap])
            for symbol in short:
                if symbol in market_cap:
                    weights[symbol] = -market_cap[symbol] / total_market_cap_short
            
            symbol_q = [(symbol, floor((equity*symbol_w) / self.recent_price[symbol])) for symbol,symbol_w in weights.items()]
            self.managed_queue.append(RebalanceQueueItem(symbol_q))
                
        return long + short
    def OnData(self, data):
        if not self.selection_flag:
            return
        self.selection_flag = False
        
        # Trade execution
        remove_item = None
        
        # Rebalance portfolio
        for item in self.managed_queue:
            if item.holding_period == self.holding_period:
                for symbol, quantity in item.symbol_q:
                    self.MarketOrder(symbol, -quantity)
                            
                remove_item = item
                
            elif item.holding_period == 0:
                open_symbol_q = []
                
                for symbol, quantity in item.symbol_q:
                    if symbol in data and data[symbol]:
                        self.MarketOrder(symbol, quantity)
                        open_symbol_q.append((symbol, quantity))
                
                # Only opened orders will be closed        
                item.symbol_q = open_symbol_q
                
            item.holding_period += 1
            
        # We need to remove closed part of portfolio after loop. Otherwise it will miss one item in self.managed_queue.
        if remove_item:
            self.managed_queue.remove(remove_item)
    def Selection(self):
        self.selection_flag = True
class RebalanceQueueItem():
    def __init__(self, symbol_q):
        # symbol/quantity collections
        self.symbol_q = symbol_q  
        self.holding_period = 0
# 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

Leave a Reply

Discover more from Quant Buffet

Subscribe now to keep reading and get access to the full archive.

Continue reading