The strategy trades NYSE, AMEX, and NASDAQ conglomerates, using pseudo-conglomerates based on standalone firms’ performance, going long on top-performing deciles, short on worst, and rebalancing monthly for equal-weighted portfolios.

I. STRATEGY IN A NUTSHELL: Monthly U.S. Conglomerate Return Prediction via Pseudo-Standalone Benchmarking

This monthly strategy targets NYSE, AMEX, and NASDAQ conglomerates, excluding low-priced and small-cap stocks. Each year, a “pseudo-conglomerate” benchmark is built using standalone firms that match the conglomerate’s segment composition. Conglomerates are ranked monthly by prior-month pseudo-conglomerate returns. The strategy goes long on top deciles and shorts the bottom, with equally weighted positions rebalanced monthly.

II. ECONOMIC RATIONALE

Investors process simple, single-industry firm information faster than complex conglomerates due to cognitive limits and capital constraints. As a result, standalone firm performance predicts conglomerate returns, exploiting delayed price adjustments in multi-industry companies.

III. SOURCE PAPER

Complicated Firms [Click to Open PDF]

Lauren Cohen, Harvard University – Business School (HBS), National Bureau of Economic Research (NBER); Dong Lou, London School of Economics

<Abstract>

We exploit a novel setting in which the same piece of information affects two sets of firms: one set of firms requires straightforward processing to update prices, while the other set requires more complicated analyses to incorporate the same piece of information into prices. We document substantial return predictability from the set of easy-to-analyze firms to their more complicated peers. Specifically, a simple portfolio strategy that takes advantage of this straightforward vs. complicated information processing classification yields returns of 118 basis points per month. Consistent with processing complexity driving the return relation, we further show that the more complicated the firm, the more pronounced the return predictability. In addition, we find that sell-side analysts are subject to these same information processing constraints, as their forecast revisions of easy-to-analyze firms predict their future revisions of more complicated firms.

IV. BACKTEST PERFORMANCE

Annualised Return15.12%
Volatility14.54%
Beta-0.085
Sharpe Ratio1.04
Sortino Ratio0.287
Maximum DrawdownN/A
Win Rate51%

V. FULL PYTHON CODE

from AlgorithmImports import *
from pandas.core.frame import dataframe
from pandas.core.series import Series
from collections import deque
from typing import List, Dict, Tuple
import json
# endregion
class ComplexityEffectInStocks(QCAlgorithm):
    def Initialize(self) -> None:
        self.SetStartDate(2015, 1, 1)
        self.SetCash(100_000)  
        self.exchange_codes: List[str] = ['NYS', 'NAS', 'ASE']	
        universe: List[str] = [
            'HON', 'MMM', 'VMI', 'MDU', 'SEB', 'GFF', 'VRTV', 'CODI', 'BBU', 'MATW', 'SPLP', 'CRESY', 'TRC', 'FIP', 'TUSK', 'RCMT', 'ALPP', 'NNBR', 'EFSH'
        ]
        
        market: Symbol = self.AddEquity("SPY", Resolution.Daily).Symbol
        
        self.period: int = 21
        self.quantile: int = 10
        self.leverage: int = 10
        self.selection_month: int = 6
        self.current_year: int = -1
        self.data: Dict[Symbol, deque[Tuple[datetime.date, float]]] = {}
        self.weight: Dict[Symbol, float] = {}
        self.long: List[str] = []
        self.short: List[str] = []
        self.selection_flag: bool = False
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.FundamentalSelectionFunction)
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
        self.settings.daily_precise_end_time = False
        
        self.Schedule.On(self.DateRules.MonthStart(market), self.TimeRules.AfterMarketOpen(market), self.Selection)
        for ticker in universe:
            data: Equity = self.AddEquity(ticker, Resolution.Daily)
            data.SetLeverage(self.leverage)
        # load conglomerate segments percentages
        # DataSource: annual report from company's website or SEC website
        content: str = self.Download("data.quantpedia.com/backtesting_data/economic/conglomerate_revenue_segments.json")
        self.custom_data: Dict[str, dict] = json.loads(content)
    def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
        # update the price every day
        for stock in fundamental:
            symbol:Symbol = stock.Symbol
            if symbol in self.data:
                self.data[symbol].append((self.Time, stock.AdjustedPrice))
    
        if not self.selection_flag:
            return Universe.Unchanged
        selected:List[Symbol] = [
            x for x in fundamental
            if x.HasFundamentalData 
            and x.Market == 'usa'
            and x.SecurityReference.ExchangeId in self.exchange_codes
            and x.AssetClassification.MorningstarIndustryGroupCode != 0 
            and x.MarketCap != 0
        ]
        # warmup price rolling windows
        for stock in selected:
            symbol: Symbol = stock.Symbol
            if symbol in self.data:
                continue
            
            self.data[symbol] = deque(maxlen=self.period)
            history: dataframe = self.History(symbol, self.period, Resolution.Daily)
            if history.empty:
                self.Log(f"Not enough data for {symbol} yet")
                continue
            data: Series = history.loc[symbol]
            for time, row in data.iterrows():
                if 'close' in row:
                    self.data[symbol].append((time, row['close']))
        if self.current_year != self.Time.year and self.Time.month == self.selection_month:
            self.current_year = self.Time.year
        if len(selected) != 0:
            # create dataframe from saved prices
            industry_stocks: Dict[Symbol, List[float]] = {symbol: [i[1] for i in value] for symbol, value in self.data.items() if symbol in list(map(lambda x: x.Symbol, selected)) if len(self.data[symbol]) == self.data[symbol].maxlen}
            df_stocks: dataframe = pd.dataframe(industry_stocks, index=[i[0] for i in list(self.data.values())[0]])
            df_stocks = (df_stocks.iloc[-1] - df_stocks.iloc[0]) / df_stocks.iloc[0]
        # sort stocks on industry numbers
        symbols_by_industry: Dict[str, List[Symbol]] = {}
        for stock in selected:
            symbol: Symbol = stock.Symbol
            industry_group_code: MorningstarIndustryGroupCode = str(stock.AssetClassification.MorningstarIndustryGroupCode)
            if not industry_group_code in symbols_by_industry:
                symbols_by_industry[industry_group_code] = []
            symbols_by_industry[industry_group_code].append(symbol)
        # create pseudo conglomerates
        pseudo_conglomerates: Dict[str, float] = {}
        if not df_stocks.empty:
            for conglomerate, lst in self.custom_data.items():
                for year in lst:
                    if year['year'] == str(self.current_year - 1):
                        for segment_data in year['codes']:
                            industry_code:str = segment_data.get('code')
                            if industry_code and not isinstance(industry_code, list):
                                if industry_code in symbols_by_industry and segment_data.get('percentage') is not None:
                                    industry_stocks:List[Symbol] = symbols_by_industry[industry_code]
                                    industry_stocks_perf:float = df_stocks[list([x for x in industry_stocks if x in df_stocks])].mean() * (segment_data['percentage'] / 100)
                                    if not conglomerate in pseudo_conglomerates:
                                        pseudo_conglomerates[conglomerate] = 0
                                    pseudo_conglomerates[conglomerate] += industry_stocks_perf
                            elif industry_code and isinstance(industry_code, list):
                                for ind_code in industry_code:
                                    if ind_code in symbols_by_industry and segment_data.get('percentage') is not None:
                                        industry_stocks = symbols_by_industry[ind_code]
                                        industry_stocks_perf = df_stocks[list([x for x in industry_stocks if x in df_stocks])].mean() * ((segment_data['percentage'] / 2) / 100)
                                        if not conglomerate in pseudo_conglomerates:
                                            pseudo_conglomerates[conglomerate] = 0
                                        pseudo_conglomerates[conglomerate] += industry_stocks_perf
                    
        # sort by conglomerate and divide to upper decile and lower decile
        if len(pseudo_conglomerates) >= self.quantile:
            sorted_by_conglomerates: List[str] = sorted(pseudo_conglomerates, key=pseudo_conglomerates.get, reverse=True)
            quantile: int = int(len(sorted_by_conglomerates) / self.quantile)
            self.long = sorted_by_conglomerates[:quantile]
            self.short = sorted_by_conglomerates[-quantile:]
        return list(map(lambda x: self.Symbol(x), self.long + self.short))
    
    def OnData(self, data: Slice) -> None:
        # monthly rebalance
        if not self.selection_flag:
            return
        self.selection_flag = False
        targets: List[PortfolioTarget] = []
        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) / len(portfolio)))
        
        self.SetHoldings(targets, True)
        self.long.clear()
        self.short.clear()
    def Selection(self) -> None:
        self.selection_flag = True
# 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"))

Leave a Reply

Discover more from Quant Buffet

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

Continue reading