The strategy sorts stocks based on abnormal trading turnover (UTURN) from a 36-month rolling regression, going long on stocks with low UTURN (Q5) and short on stocks with high UTURN (Q1).

I. STRATEGY IN A NUTSHELL

The strategy trades all common stocks on NYSE, AMEX, and NASDAQ. It separates trading turnover into explained (ETURN) and abnormal (UTURN) components using a 36-month rolling regression. Stocks are sorted by UTURN, going long on low-abnormal-turnover (Q5) stocks and short on high-abnormal-turnover (Q1) stocks. The portfolio is equally weighted and rebalanced monthly.

II. ECONOMIC RATIONALE

Abnormal trading (UTURN) is linked to future stock returns due to behavioral biases and investor attention. High abnormal turnover often predicts higher returns, especially for hard-to-value stocks, with sentiment amplifying the effect.

III. SOURCE PAPER

Abnormal Trading Volume and the Cross-Section of Stock Returns [Click to Open PDF]

Deokhyeon Lee, College of Business, Korea Advanced Institute of Science and Technology (KAIST); Minki Kim, College of Business, Korea Advanced Institute of Science and Technology (KAIST); Tongsuk Kim, College of Business, Korea Advanced Institute of Science and Technology

<Abstract>

Stocks with high trading volume outperform otherwise stocks for one week, but subsequently underperform at the longer horizon. We show that such time-varying predictability of trading volume is attributed to abnormal trading activity, which is not explained by past volume. Specifically, we find that the return forecasting power of abnormal trading activity is strongly positive up to five weeks ahead. In contrast, the predictive power of the expected trading activity is negative, and lasts for longer horizons. We further argue that behavioral biases and investors’ attention induces abnormal trading activity, but its price impact is primarily related to behavioral biases. Overall evidence emphasizes the role of behavioral biases and investors’ attention to explain trading volume.

IV. BACKTEST PERFORMANCE

Annualised Return10.82%
Volatility10.45%
Beta0.004
Sharpe Ratio1.04
Sortino Ratio-0.483
Maximum DrawdownN/A
Win Rate50%

V. FULL PYTHON CODE

from AlgorithmImports import *
import statsmodels.api as sm
from typing import List, Dict
from numpy import isnan
class AbnormalTurnoverEffectInTheStockMarket(QCAlgorithm):
    def Initialize(self):
        self.SetStartDate(2000, 1, 1)
        self.SetCash(100000)
        
        self.leverage:int = 5
        self.quantile:int = 5
        self.exchange_codes:List[str] = ['NYS', 'NAS', 'ASE']	
        self.symbol:Symbol = self.AddEquity("SPY", Resolution.Daily).Symbol
        
        self.data:Dict[Symbol, SymbolData] = {}
        self.long:List[Symbol] = []
        self.short:List[Symbol] = []
        
        self.period:int = 21 # need n of daily volumes
        self.regression_period:int = 12 # need n of last turnovers and n * (self.turnover_period - 1) for regression
        
        self.fundamental_sorting_key = lambda x: x.DollarVolume
        self.fundamental_count:int = 500 # selecting n stocks by dollar volume from fundamentalSelectionFunction
        self.selection_flag:bool = False
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.FundamentalSelectionFunction)
        self.Schedule.On(self.DateRules.MonthStart(self.symbol), self.TimeRules.AfterMarketOpen(self.symbol), self.Selection)
        self.settings.daily_precise_end_time = False
        
    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[Symbol]:
        # update stocks volumes every day
        for stock in fundamental:
            symbol = stock.Symbol
            
            # update stock's volume
            if symbol in self.data:
                self.data[symbol].update(stock.Volume)
        
        # rebalance monthly
        if not self.selection_flag:
            return Universe.Unchanged
        
        # select stocks, which had spin off
        selected:List[Fundamental] = [x for x in fundamental if x.HasFundamentalData and not isnan(x.EarningReports.BasicAverageShares.ThreeMonths > 0) and x.EarningReports.BasicAverageShares.ThreeMonths > 0 \
                            and x.SecurityReference.ExchangeId in self.exchange_codes]
        if len(selected) > self.fundamental_count:
            selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]
        u_turn:Dict[Symbol, float] = {} # storing U-TURN of filtered stocks
        
        # warm up selected symbols
        for stock in selected:
            symbol:Symbol = stock.Symbol
            if symbol not in self.data:
                self.data[symbol] = SymbolData(self.period, self.regression_period)
                history:dataframe = self.History(symbol, self.period, Resolution.Daily)
                if history.empty:
                    self.Log(f"Not enough data for {symbol} yet")
                    continue
                volumes:Closes = history.loc[symbol].volume
                for _, volume in volumes.items():
                    self.data[symbol].update(volume)
            
            if not self.data[symbol].is_ready():
                continue
            
            # check if there is enough data for regression
            if self.data[symbol].turnovers_ready():
                # get x and y for regression
                x, y = self.data[symbol].get_regression_data(self.regression_period)
                
                # calculate regression
                regression_model = self.MultipleLinearRegression(x, y)
                
                # get last residual
                last_resid:float = regression_model.resid[-1]
                # calculate std of all regression residuals
                resid_std:float = np.std(regression_model.resid)
                
                # calculate and store stock's U-TURN
                u_turn[symbol] = last_resid / resid_std
            
            # get stock's volumes for last month
            monthly_volume:float = self.data[symbol].monthly_volume()
            # get stock's shares oustanding
            shares_outstanding:float = stock.EarningReports.BasicAverageShares.ThreeMonths
            
            # calculate and update turnovers for current stock
            self.data[symbol].update_turnovers(monthly_volume / shares_outstanding)
            
        # check if there are enough stocks for quintile selection
        if len(u_turn) < self.quantile:
            return Universe.Unchanged
        
        # sort stocks by U-TURN
        quintile:int = int(len(u_turn) / self.quantile)
        sorted_by_u_turn:List[Symbol] = [x[0] for x in sorted(u_turn.items(), key=lambda item: item[1])]
        
        # select long stocks
        self.long = sorted_by_u_turn[:quintile]
        # select short stocks
        self.short = sorted_by_u_turn[-quintile:]
        
        return [x for x in self.long + self.short]
    def OnData(self, data: Slice) -> None:
        if not self.selection_flag:
            return
        self.selection_flag = False
        
        # trade execution
        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 MultipleLinearRegression(self, x, y):
        # x = np.array(x).T
        # x = sm.add_constant(x)
        result = sm.OLS(endog=y, exog=x).fit()
        return result
        
    def Selection(self) -> None:
        self.selection_flag = True
        
class SymbolData():
    def __init__(self, period:int, regression_period:int):
        self.volumes:RollingWindow = RollingWindow[float](period)
        # storing turnovers for regression
        self.turnovers:RollingWindow = RollingWindow[float](regression_period * 2)
            
    def update(self, volume:float):
        self.volumes.Add(volume)
        
    def update_turnovers(self, turnover:float):
        self.turnovers.Add(turnover)
            
    def is_ready(self) -> bool:
        return self.volumes.IsReady
        
    def turnovers_ready(self) -> bool:
        return self.turnovers.IsReady
        
    def monthly_volume(self) -> float:
        volumes = [x for x in self.volumes]
        return sum(volumes)
            
    def get_regression_data(self, regression_period:int):
        # get turnovers
        turnovers:List[float] = [x for x in self.turnovers]
        # reverse list for easier implementation of storing regression data
        turnovers.reverse()
        
        x:List[float] = [] # storing one data point of regression_x in loop
        regression_y:List[float] = []
        regression_x:List[float] = []
        
        for turnover in turnovers:
            if len(x) == (regression_period - 1):
                # add intercept to current x data point
                x = [1] + x
                # add last turnover for current data point in regression to regression_y
                regression_y.append(turnover)
                # add one data point of x to regression_x
                regression_x.append(x)
                # remove intercept and firstly added turnover
                x = x[2:]
            
            # keep adding turnovers to x
            x.append(turnover)
            
        return regression_x, regression_y
        
# custom fee model
class CustomFeeModel(FeeModel):
    def GetOrderFee(self, parameters):
        fee = 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