Quant BuffetRelax, Not Over Thinking

Abnormal Volume Effect in the Stock Market

Log in to collect

Academic paper

Strategy in a nutshell

The strategy analyzes stocks on the Milan Stock Exchange, potentially applicable to other markets. Daily, the investor identifies stocks with "abnormal" trading volume, defined as volumes exceeding 2.33 standard deviations above the 66-day average. Eligible stocks must show no abnormal volume in the prior 30 days and close with at least a 1% gain on the event day. These stocks are purchased at the market close and held for one day. Positions are equally weighted, and the portfolio is rebalanced daily. This approach leverages short-term trading volume spikes and price momentum for potential gains.

Economic rationale

Academic research attributes this anomaly to insider trading, suggesting that uneven information distribution among market participants allows trading volumes to provide valuable insights. Large volume changes, particularly in the absence of news, may reflect non-public information, signaling potential future excess returns.

Backtest performance

Annualised return33.91%
Beta0.74
Sortino ratio0.394
Win rate49%

Full Python code

import numpy as np
from AlgorithmImports import *
from typing import List, Dict
from pandas.core.frame import DataFrame
class AbnormalVolumeEffectStockMarket(QCAlgorithm):
def Initialize(self) -> None:
self.SetStartDate(2000, 1, 1)
self.SetCash(100000)
market:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol

self.data:Dict[Symbol, SymbolData] = {}
self.period:int = 66
self.leverage:int = 5
self.std_treshold:float = 2.33
self.performance_treshold:float = 0.01

self.long:List[Symbol] = []

self.selection_flag:bool = False
self.last_selection:List[Fundamental] = []

self.fundamental_sorting_key = lambda x: x.MarketCap
self.fundamental_count:int = 1000
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelectionFunction)
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.settings.daily_precise_end_time = False
self.Schedule.On(self.DateRules.MonthStart(market), self.TimeRules.AfterMarketOpen(market), self.Selection)

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 the rolling window every day.
for stock in fundamental:
    symbol:Symbol = stock.Symbol
    if symbol in self.data:
        # Store daily price and volume.
        self.data[symbol].update(stock.AdjustedPrice, stock.Volume)

# return already selected universe during the month
if self.selection_flag:
    self.selection_flag = False
    selected:List[Fundamental] = [
        x for x in fundamental if x.HasFundamentalData and x.Market == 'usa' and x.MarketCap != 0
    ]
    if len(selected) > self.fundamental_count:
        selected = sorted(selected, key = self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]
    self.last_selection = selected
    # Warmup price rolling windows.
    for stock in self.last_selection:
        symbol:Symbol = stock.Symbol
        if symbol in self.data:
            continue
        self.data[symbol] = SymbolData(symbol, self.period, -1)
        history:DataFrame = self.History(symbol, self.period, Resolution.Daily)
        if history.empty:
            self.Log(f"Not enough data for {symbol} yet")
            continue
        if 'close' in history and 'volume' in history:
            closes:Series = history.loc[symbol]['close']
            volumes:Series = history.loc[symbol]['volume']
            for (time1, close), (time2, volume) in zip(closes.items(), volumes.items()):
                self.data[symbol].update(close, volume)
# fundamental returned ready data
for stock in self.last_selection:
    symbol:Symbol = stock.Symbol
    if symbol not in self.data:
        continue
    if not self.data[symbol].is_ready():
        continue
    volumes:List[float] = [x for x in self.data[symbol]._volume]
    volume_mean:float = np.mean(volumes)
    volume_std:float = np.std(volumes)
    volume:float = volumes[0] # Takes todays volume
       
    closes:List[float] = [x for x in self.data[symbol]._price][:2] # First two are newest
    todays_return:float = (closes[0] - closes[1]) / closes[1]
     
    if volume > volume_mean + (self.std_treshold * volume_std):
        # selects only firms with no abnormal volume over the preceding 30 trading days
        if (self.data[symbol]._abnormal_date == -1) or (self.data[symbol]._abnormal_date < (self.Time - timedelta(days=30))):
            # if the stocks finished the day with at least a 1% gain
            if todays_return >= self.performance_treshold:
                self.long.append(symbol)
        self.data[symbol]._abnormal_date = self.Time
            
return self.long

def OnData(self, data: Slice) -> None:
# Trade execution
targets:List[PortfolioTarget] = [PortfolioTarget(symbol, 1. / len(self.long)) for symbol in self.long if symbol in data and data[symbol]]
self.SetHoldings(targets, True)
self.long.clear()

def Selection(self) -> None:
self.selection_flag = True

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, symbol:Symbol, period:int, abnormal_date:datetime):
self._symbol:Symbol = symbol
self._price:RollingWindow = RollingWindow[float](period)
self._volume:RollingWindow = RollingWindow[float](period)
self._abnormal_date:datetime = abnormal_date

def update(self, price:float, volume:float):
self._price.Add(price)
self._volume.Add(volume)

def is_ready(self) -> bool:
return self._price.IsReady and self._volume.IsReady
Abnormal Volume Effect in the Stock Market |… | Quant Buffet