
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.
ASSET CLASS: stocks | REGION: United States | FREQUENCY:
Monthly | MARKET: equities | KEYWORD: Complexity Effect, Stocks
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 Return | 15.12% |
| Volatility | 14.54% |
| Beta | -0.085 |
| Sharpe Ratio | 1.04 |
| Sortino Ratio | 0.287 |
| Maximum Drawdown | N/A |
| Win Rate | 51% |
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"))