The dataset consists of all NYSE/AMEX/Nasdaq common stocks. The sample period lasts from May 1981 to May 2018. Following entities are excluded: stocks with share prices less than one dollar at the beginning of the portfolio formation date and firms in the financial sectors.

I. STRATEGY IN A NUTSHELL

The strategy targets all NYSE, AMEX, and NASDAQ common stocks, excluding firms with share prices below $1 and financial sector firms. Using data from CRSP, Compustat, and Thomson Reuters 13f filings, stocks are sorted into terciles based on quarterly changes in institutional ownership breadth. Within each tercile, stocks are further sorted into quintiles according to 11 anomaly variables: asset growth, failure probability, gross profitability, investment-asset ratio, long-term equity issuance, momentum, net operating assets, net payout, net stock issuance, operating accruals, o-score, and return on assets. The long leg consists of stocks with the highest anomaly scores in the top ownership breadth change tercile, while the short leg consists of stocks with the lowest anomaly scores in the bottom ownership breadth change tercile. Portfolios are value-weighted and rebalanced quarterly, with a two-month lag applied to ensure tradability.

II. ECONOMIC RATIONALE

The strategy exploits signals from informed institutional investors. Changes in ownership breadth indicate entries and exits of well-informed investors, predicting future stock returns. Evidence supports the informed trading hypothesis, as controlling for future earnings surprises removes the predictive power of breadth changes. Short-selling constraints explain only part of the negative alpha for short positions and are otherwise insignificant.

III. SOURCE PAPER

Changes in Ownership Breadth and Capital Market Anomalies. [Click to Open PDF]

Yangru Wu, Rutgers University, Newark – School of Business – Department of Finance & Economics; Weike Xu, Clemson University – Department of Finance

<Abstract>

We investigate how the interaction of entries and exits of informed institutional investors with market anomaly signals affects strategy performance. The long legs of anomalies earn more positive alphas following entries, while the short legs earn more negative alphas following exits. The enhanced anomaly-based strategies of buying stocks in the long legs of anomalies with entries and shorting stocks in the short legs with exits outperform the original anomalies with an increase of 19-54 bps per month in the Fama-French (2015) five-factor alpha. The entries and exits of institutional investors capture informed trading and earnings surprises thereby enhancing the anomalies.

IV. BACKTEST PERFORMANCE

Annualised Return8.34%
Volatility17.03%
Beta-0.075
Sharpe Ratio0.49
Sortino RatioN/A
Maximum DrawdownN/A
Win Rate50%

V. FULL PYTHON CODE

from AlgorithmImports import *
from typing import Dict, List, Set
from data_tools import QuantpediaHegdeFunds, CustomFeeModel, SymbolData
# endregion

class ChangesInOwnershipBreadthPredictPerformanceOfEquityFactors(QCAlgorithm):

    def Initialize(self):
        self.SetStartDate(2010, 1, 1)
        self.SetCash(100000)
        
        self.leverage:int = 5
        self.months_lag:int = 2

        self.breadth_selection_quantile:int = 3
        self.anomaly_selection_quantile:int = 5

        self.mom_period:int = 21
        self.total_assets_period:int = 2
        self.mean_weights_period:int = 2

        self.max_missing_days:int = 3 * 31
        self.last_hedge_funds_update:datetime.date|None = None
        
        self.quantities:Dict[Symbol, float] = {}
        self.tickers_with_data:Dict[str, List[float]] = []
        self.data:Dict[str, SymbolData] = {}

        self.exchanges:List[str] = ['NYS', 'NAS', 'ASE']
        self.anomalies_symbols:List[str] = ['AG', 'GP', 'MOM', 'ROA', 'NSI', 'NPAY', 'OA']

        self.market:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol

        self.hedge_funds_symbol:Symbol = self.AddData(QuantpediaHegdeFunds, 'hedge_funds_holdings.json', Resolution.Daily).Symbol

        self.selection_flag:bool = False
        self.rebalance_flag:bool = False
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.CoarseSelectionFunction, self.FineSelectionFunction)

    def OnSecuritiesChanged(self, changes:SecurityChanges) -> None:
        for security in changes.AddedSecurities:
            security.SetFeeModel(CustomFeeModel())
            security.SetLeverage(self.leverage)

    def CoarseSelectionFunction(self, coarse:List[CoarseFundamental]) -> List[Symbol]:
        for equity in coarse:
            ticker:Symbol = equity.Symbol.Value

            if ticker in self.data:
                self.data[ticker].update_prices(equity.AdjustedPrice)

        if not self.selection_flag:
            return Universe.Unchanged
        self.selection_flag = False
        self.rebalance_flag = True
        
        # select only stocks, which have current and previous mean weight
        selected_stocks:List[Symbol] = []
        for stock in coarse:
            symbol:Symbol = stock.Symbol
            ticker:str = symbol.Value

            if ticker not in self.tickers_with_data:
                continue
            
            # warm up prices if needed
            if not self.data[ticker].prices_ready():
                history = self.History(symbol, self.mom_period, Resolution.Daily)
                if not history.empty:
                    closes = history.loc[symbol].close
                    
                    for _, close in closes.iteritems():
                        self.data[ticker].update_prices(close)

            selected_stocks.append(stock.Symbol)

        self.tickers_with_data.clear()

        return selected_stocks

    def FineSelectionFunction(self, fine:List[FineFundamental]) -> List[Symbol]:
        curr_date:datetime.date = self.Time.date()
        stocks_with_data:List[FineFundamental] = []

        for stock in fine:
            if stock.MarketCap != 0 and stock.SecurityReference.ExchangeId in self.exchanges and stock.FinancialStatements.BalanceSheet.TotalAssets.ThreeMonths != 0 \
                and stock.FinancialStatements.IncomeStatement.GrossProfit.ThreeMonths != 0 and stock.OperationRatios.ROA.ThreeMonths != 0 \
                and stock.FinancialStatements.CashFlowStatement.IssuanceOfCapitalStock.ThreeMonths != 0 and stock.FinancialStatements.CashFlowStatement.RepurchaseOfCapitalStock.ThreeMonths != 0 \
                and stock.ValuationRatios.TotalYield != 0 and stock.FinancialStatements.CashFlowStatement.CommonStockIssuance.ThreeMonths != 0 \
                and stock.FinancialStatements.BalanceSheet.CurrentAssets.Value != 0 and stock.FinancialStatements.BalanceSheet.CurrentLiabilities.Value != 0:

                ticker:str = stock.Symbol.Value
                stock_data:SymbolData = self.data[ticker]

                # make sure data are consecutive
                if not stock_data.anomaly_data_still_coming(curr_date, self.max_missing_days):
                    # each anomaly's data will be reset except momentum
                    stock_data.reset_anomalies_data()

                total_assets:float = stock.FinancialStatements.BalanceSheet.TotalAssets.ThreeMonths
                gross_profit:float = stock.FinancialStatements.IncomeStatement.GrossProfit.ThreeMonths
                roa:float = stock.OperationRatios.ROA.ThreeMonths

                issuance_of_stock:float = stock.FinancialStatements.CashFlowStatement.IssuanceOfCapitalStock.ThreeMonths
                repurchase_of_stock:float = stock.FinancialStatements.CashFlowStatement.RepurchaseOfCapitalStock.ThreeMonths
                net_stock_issuance:float = issuance_of_stock - repurchase_of_stock

                market_cap:float = stock.MarketCap
                total_yield:float = stock.ValuationRatios.TotalYield
                common_stock_issuance:float = stock.FinancialStatements.CashFlowStatement.CommonStockIssuance.ThreeMonths
                net_payout:float = (total_yield * market_cap) / (common_stock_issuance / market_cap)

                operating_assets:float = stock.FinancialStatements.BalanceSheet.CurrentAssets.Value
                operating_liabilities:float = stock.FinancialStatements.BalanceSheet.CurrentLiabilities.Value
                net_operating_assets:float = operating_assets - operating_liabilities

                operating_accruals:float = stock_data.update_operating_accruals(net_operating_assets, total_assets) \
                    if stock_data.net_operating_assets_ready() else None

                stock_data.update_net_operating_assets(net_operating_assets)
                if operating_accruals != None:
                    stock_data.update_operating_accruals(operating_accruals)

                # update stock's anomalies data
                stock_data.update_total_assets_values(total_assets)
                stock_data.update_gross_profit(gross_profit)
                stock_data.update_roa(roa)
                stock_data.update_net_stock_issuance(net_stock_issuance)
                stock_data.update_net_payout(net_payout)
                stock_data.set_last_anomalies_update(curr_date)

                if stock_data.is_ready():
                    stocks_with_data.append(stock)
        
        # make sure there are enough stocks for both seletion
        if len(stocks_with_data) < (self.breadth_selection_quantile * self.anomaly_selection_quantile):
            return Universe.Unchanged

        # firstly sort stocks based on their mean weight change - breadth
        breadth_selection_quantile:int = int(len(stocks_with_data) / self.breadth_selection_quantile) 
        sorted_by_breadth:List[FineFundamental] = [
            x for x in sorted(stocks_with_data, key=lambda stock: self.data[stock.Symbol.Value].get_ownership_breadth())]

        low_stocks:List[FineFundamental] = sorted_by_breadth[:breadth_selection_quantile]
        high_stocks:List[FineFundamental] = sorted_by_breadth[-breadth_selection_quantile:]

        stocks_for_trade:Set = set()
        total_anomalies:float = float(len(self.anomalies_symbols))
        weight:float = self.Portfolio.TotalPortfolioValue / 2. / total_anomalies

        anomalies_data:Dict[str, Dict[FineFundamental, float]] = self.CalculateAnomalies(low_stocks)

        # perform short selection and calculate stocks quantities
        for anomaly_symbol, stocks_with_values in anomalies_data.items():
            sorted_by_anomaly_values:List[FineFundamental] = [x[0] for x in sorted(stocks_with_values.items(), key=lambda item: item[1])]

            anomaly_selection_quantile:int = int(len(sorted_by_anomaly_values) / self.anomaly_selection_quantile)
            short_leg:List[FineFundamental] = sorted_by_anomaly_values[:anomaly_selection_quantile]

            total_cap:float = sum(list(map(lambda stock: stock.MarketCap, short_leg)))
            for stock in short_leg:
                stock_price:float = self.data[stock.Symbol.Value].get_last_price()
                quantity:float = np.floor((weight * (stock.MarketCap / total_cap)) / stock_price)

                self.quantities[stock.Symbol] = -quantity

                stocks_for_trade.add(stock.Symbol)

        # reset anomalies data for next calculation
        anomalies_data:Dict[str, Dict[FineFundamental, float]] = self.CalculateAnomalies(high_stocks)

        # perform long selection and calculate stocks quantities
        for anomaly_symbol, stocks_with_values in anomalies_data.items():
            sorted_by_anomaly_values:List[FineFundamental] = [x[0] for x in sorted(stocks_with_values.items(), key=lambda item: item[1])]

            anomaly_selection_quantile:int = int(len(sorted_by_anomaly_values) / self.anomaly_selection_quantile)
            long_leg:List[FineFundamental] = sorted_by_anomaly_values[-anomaly_selection_quantile:]

            total_cap:float = sum(list(map(lambda stock: stock.MarketCap, long_leg)))
            for stock in long_leg:
                stock_price:float = self.data[stock.Symbol.Value].get_last_price()
                quantity:float = np.floor((weight * (stock.MarketCap / total_cap)) / stock_price)

                self.quantities[stock.Symbol] = quantity

                stocks_for_trade.add(stock.Symbol)

        return list(stocks_for_trade)        
        
    def OnData(self, data:Slice) -> None:
        curr_date:datetime.date = self.Time.date()

        if self.hedge_funds_symbol in data and data[self.hedge_funds_symbol]:
            stocks_data:Dict[str, List[float]] = data[self.hedge_funds_symbol].GetProperty('stocks_with_weights')

            for ticker, weights in stocks_data.items():
                mean_weight:float = np.mean(weights)

                if ticker not in self.data:
                    self.data[ticker] = SymbolData(self.mom_period, self.total_assets_period, self.mean_weights_period)

                # make sure mean weight values are consecutive
                if not self.data[ticker].weight_data_still_coming(curr_date, self.max_missing_days):
                    self.data[ticker].reset_weights()

                self.data[ticker].update_mean_weights(curr_date, mean_weight)

                # stock will be selected in CoarseSelectionFuction only, if it has mean values ready
                if self.data[ticker].mean_weight_values_ready():
                    self.tickers_with_data.append(ticker)

            self.last_hedge_funds_update = curr_date
            self.selection_flag = True

        if self.last_hedge_funds_update != None and (curr_date - self.last_hedge_funds_update).days > self.max_missing_days:
            # liquidate portfolio, when data stop coming
            self.Liquidate()

        if not self.rebalance_flag:
            return
        self.rebalance_flag = False

        self.Liquidate()
                
        for symbol, quantity in self.quantities.items():
            if self.Securities[symbol].IsTradable and self.Securities[symbol].Price != 0:
                self.MarketOrder(symbol, quantity)
                
        self.quantities.clear()

    def CalculateAnomalies(self, stocks:List[FineFundamental]) -> Dict[str, Dict[FineFundamental, float]]:
        anomalies_data:Dict[str, Dict[FineFundamental, float]] = { anomaly_symbol: {} for anomaly_symbol in self.anomalies_symbols }
        
        for stock in stocks:
            ticker:str = stock.Symbol.Value
            stock_data:SymbolData = self.data[ticker]

            anomalies_data['AG'][stock] = stock_data.get_asset_growth()
            anomalies_data['GP'][stock] = stock_data.get_gross_profit()
            anomalies_data['MOM'][stock] = stock_data.get_momentum()
            anomalies_data['ROA'][stock] = stock_data.get_roa()
            anomalies_data['NSI'][stock] = stock_data.get_net_stock_issuance()
            anomalies_data['NPAY'][stock] = stock_data.get_net_payout()
            anomalies_data['OA'][stock] = stock_data.get_operating_accruals()

        return anomalies_data

Leave a Reply

Discover more from Quant Buffet

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

Continue reading