
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).
ASSET CLASS: stocks | REGION: United States | FREQUENCY:
Monthly | MARKET: equities | KEYWORD: Abnormal, Turnover Effect, Stock Market
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 Return | 10.82% |
| Volatility | 10.45% |
| Beta | 0.004 |
| Sharpe Ratio | 1.04 |
| Sortino Ratio | -0.483 |
| Maximum Drawdown | N/A |
| Win Rate | 50% |
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"))