Abnormal Turnover Effect in the Stock Market
Log in to collectAcademic paper
Abnormal Trading Volume and the Cross-Section of Stock Returns
Deok Hyeon Lee; Min Ki Kim; Tong Suk Kim
- KRKorea Advanced Institute of Science and Technology
- ?College of Business, Korea Advanced Institute of Science and Technology (KAIST)
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.
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.
Backtest performance
Annualised return10.82%
Volatility10.45%
Beta0.004
Sharpe ratio1.04
Sortino ratio-0.483
Win rate50%
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"))