Quant BuffetRelax, Not Over Thinking

Persistence of Abnormal Trading Volume Effect

Log in to collect

Academic paper

Persistence or Reversal?  the Effects of Abnormal Trading Volume on Stock Returns

AuthorsMingyi Li; Xiangkang Yin; Jing Zhao

Institute
  • La Trobe University
  • Deakin University
  • Research Network (United States)
  • ?Financial Research Network (FIRN)
  • ?La Trobe University - La Trobe Business School

Strategy in a nutshell

The strategy trades common stocks on AMEX, NYSE, and NASDAQ based on persistent abnormal trading volume (PATV). Stocks with consistently high (low) ATV over consecutive months are longed (shorted) in equal-weight portfolios, rebalanced monthly. Positions carry equal weights across the previous kkk months of persistence.

Economic rationale

Persistent abnormal trading volume captures investor sentiment that affects stock prices over time. Stocks with long-lasting sentiment-driven volume are more likely to reverse, making PATV a stronger predictor of future returns than simple AT

Backtest performance

Annualised return20.27%
Volatility15.69%
Beta0.135
Sharpe ratio1.29
Sortino ratio-0.003
Win rate50%

Full Python code

from AlgorithmImports import *
from typing import List, Dict
from dateutil.relativedelta import relativedelta
import numpy as np
# endregion

class PersistenceofAbnormalTradingVolumeEffect(QCAlgorithm):

def Initialize(self) -> None:
self.SetStartDate(2000, 1, 1)
self.SetCash(100000)

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

self.long:List[Symbol] = []
self.short:List[Symbol] = []
self.long_by_month:Dict[int, Symbol] = {}
self.short_by_month:Dict[int, Symbol] = {}
self.symbol_data:Dict[Symbol, SymbolData] = {}

self.monthly_period:int = 18
self.delete_treshold:int = 2
self.quantile:int = 5
self.leverage:int = 5

self.fundamental_count:int = 1000
self.selection_flag:bool = False
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelectionFunction)
self.Schedule.On(self.DateRules.MonthStart(self.market), self.TimeRules.AfterMarketOpen(self.market), self.Selection)

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

for security in changes.RemovedSecurities:
    if security.Symbol in self.symbol_data:
        self.symbol_data.pop(security.Symbol)

def FundamentalSelectionFunction(self, fundamental:List[Fundamental]) -> List[Symbol]:
# store volume every day
for stock in fundamental:
    symbol:Symbol = stock.Symbol

    if symbol in self.symbol_data:
        self.symbol_data[symbol].update_daily_volume(stock.Volume)

# monthly selection
if not self.selection_flag:
    return Universe.Unchanged

selected:List[Symbol] = [x.Symbol
    for x in sorted([x for x in fundamental if x.HasFundamentalData and x.Market == 'usa' and x.AdjustedPrice >= 1 and x.MarketCap != 0 and \
    (x.SecurityReference.ExchangeId == 'NYS') or (x.SecurityReference.ExchangeId == 'NAS') or (x.SecurityReference.ExchangeId == 'ASE')
    ], 
    key = lambda x: x.DollarVolume, reverse = True)][:self.fundamental_count]

# selected:List[Fundamental] = [x for x in fundamental if x.HasFundamentalData and x.Market == 'usa' and x.AdjustedPrice >= 1 and x.MarketCap != 0 and \
#     (x.SecurityReference.ExchangeId == 'NYS') or (x.SecurityReference.ExchangeId == 'NAS') or (x.SecurityReference.ExchangeId == 'ASE')
# ]        
# if len(selected) > self.fundamental_count:
#     selected = [x.Symbol for x in sorted(selected, key=lambda x: x.MarketCap, reverse=True)[:self.fundamental_count]]
# else:
#     selected = list(map(lambda x: x.Symbol, selected))

# store monthly data
for symbol in self.symbol_data:
    self.symbol_data[symbol].update_monthly_data()

ATV:Dict[Symbol, float] = {}
for symbol in selected:
    if symbol not in self.symbol_data:
        self.symbol_data[symbol] = SymbolData(self.monthly_period)

    if self.symbol_data[symbol].is_ready():
        ATV[symbol] = self.symbol_data[symbol].ATV()

# sort and divide to upper decile and lower decile
if len(ATV) >= self.quantile:
    sorted_volume:List[Symbol] = sorted(ATV, key=ATV.get, reverse=True)
    quantile:int = len(sorted_volume) // self.quantile
    self.long_by_month[self.Time.month] = sorted_volume[:quantile]
    self.short_by_month[self.Time.month] = sorted_volume[-quantile:]

    self.long = symbol_quantile_check(self.Time, self.long_by_month)
    self.short = symbol_quantile_check(self.Time, self.short_by_month)

    if len(self.long_by_month) > self.delete_treshold:
        self.long_by_month.pop(list(self.long_by_month.keys())[0])
        self.short_by_month.pop(list(self.short_by_month.keys())[0])
    
return self.long + self.short

def OnData(self, data: Slice) -> None:
# monthly rebalance
if not self.selection_flag:
    return
self.selection_flag = False

# order 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 Selection(self) -> None:
self.selection_flag = True

def symbol_quantile_check(time:datetime, symbols:Dict[int, List[Symbol]]) -> List[Symbol]:
t_1 = (time - relativedelta(months=1)).month
t_2 = (time - relativedelta(months=2)).month
if t_1 in symbols and t_2 in symbols:
# fet the symbols for the months
m_month = set(symbols[time.month])
m_1_month = set(symbols[t_1])
m_2_month = set(symbols[t_2])

# symbols in m, m-1 but not in m-2
missing_symbols = list(m_1_month.intersection(m_month).difference(m_2_month))
return missing_symbols

return []

# custom fee model
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters):
fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))

class SymbolData():
def __init__(self, monthly_period:int) -> None:
self._monthly_period = monthly_period

self._daily_volume_date:List[float] = []
self._recent_monthly_volume_mean:float = None
self._monthly_volume_sum:RollingWindow = RollingWindow[float](monthly_period)

def update_daily_volume(self, volume:float) -> None:
self._daily_volume_date.append(volume)

def update_monthly_data(self) -> None:
self._recent_monthly_volume_mean = np.mean(list(self._daily_volume_date))
self._monthly_volume_sum.Add(sum(list(self._daily_volume_date)))
self._daily_volume_date.clear()

def ATV(self) -> float:
atv:float = np.log(self._recent_monthly_volume_mean / np.mean(list(self._monthly_volume_sum)[-12:]))
return atv

def is_ready(self) -> bool:
return self._monthly_volume_sum.IsReady and self._recent_monthly_volume_mean is not None