“The strategy identifies Milan Stock Exchange stocks with abnormal trading volume and 1% daily gains, buying at close, holding one day, and rebalancing daily for short-term momentum gains.”

I. 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.

II. 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.

III. SOURCE PAPER

THE INFORMATION CONTENT OF ABNORMAL TRADING VOLUME [Click to Open PDF]

<Abstract>

This paper empirically investigates how abnormal trading volume reveals new information to market participants. Trading volume is generally regarded as a good proxy for information flow and theory argues that it enhances the information set of investors. However, as yet, no research has related the presence of abnormal trading volume to firm characteristics, such as ownership and governance structure, which also has a theoretical link to information quality. I find strong excess returns around extreme trading levels, which is only moderately attributable to information disclosure. Moreover, these returns are not caused by liquidity fluctuations since prices do not reverse over the following period. In contrast, and in violation of the semi-strong form of market efficiency, there is evidence of price momentum, suggesting that traders can implement successful portfolio strategies based on the observation of current volumes. Consistent with the hypotheses presented in this study, the information content of abnormal trading volume is related to ownership characteristics, such as the level of control and the family-firm status.

IV. BACKTEST PERFORMANCE

Annualised Return33.91%
VolatilityN/A
Beta0.74
Sharpe RatioN/A
Sortino Ratio0.394
Maximum DrawdownN/A
Win Rate49%

V. 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

Leave a Reply

Discover more from Quant Buffet

Subscribe now to keep reading and get access to the full archive.

Continue reading