投资范围包括AMEX、NYSE和NASDAQ的普通股。异常交易量(ATV)基于当前月与过去12个月平均交易量的对数比,并计算ATV持续性(PATV)。每月初,PATV策略根据持续排名于ATV分位数顶部的股票做多,底部的股票做空。投资组合每月重新平衡,权重均为1/k(等权重)。

策略概述

投资范围包括在 AMEX、NYSE 和 NASDAQ 上市的普通股。1. 月份 m 内某只股票的 ATV(异常交易量)估算为相对于过去 12 个月平均交易量变化的对数(将 m 月的实际交易量除以该股票在 m ‒ 18 至 m ‒ 7 月期间的平均交易量)。为了减少数据污染,投资者在确定参考期时应跳过六个月,因为 ATV 持续性 (ATVP) 变量要求连续五个月的 ATV 数据(类似于 Chae (2005) 的方法)。然而,研究表明,即使不跳过六个月,结果仍然稳健。对于任何一个月内位于 ATV 分位数顶部和底部的股票,我们计算其连续几个月处于相同分位数的次数,并称之为 PATV。例如,如果某只股票在 m、m ‒ 1 和 m ‒ 2 月处于 ATV 分位数顶部(或底部),但在 m ‒ 3 月不在该分位数中,则为 m 月赋予该股票 PATV 值 3(或 ‒3)。符号 𝑃𝐴𝑇𝑉(𝑥, ‒𝑥) 表示通过 PATV(𝑥) 和 PATV(‒𝑥) 值创建的多空组合。2. 在每个月初,我们的 PATV 策略变体会根据 ATV 持续性长度相等(一个月滞后)的股票构建和持有多空组合,做多持续排名位于 ATV 分位数顶部的股票,做空持续排名位于底部的股票。3. 投资组合每月重新平衡,当前月和之前 k ‒ 1 个月中开始的每个头寸的权重均为 1/k(等权重)。

策略合理性

学术界广泛研究了交易量与未来股票回报之间的关系,但尚无令人信服的解释能够全面解释 ATV(异常交易量)出现后复杂的回报模式。该研究的学者提出了一种新颖的解释,重点关注投资者情绪的持续性与其对股价的反转影响。受情绪影响时间较长的股票(即 ATV 持续时间较长的股票)更可能经历回报反转,因为其投资者情绪进一步持续的可能性较低。与投资者情绪对股价的累计影响一致,他们证明了 ATV 持续性负向预测长期股票回报。随着 ATV 持续时间的增加,回报预测能力的强度单调上升。因此,在预测股票回报时,ATV 持续性可以优于常用的 ATV 指标,强调了考虑情绪持续性和反转对资产价格影响的重要性。

论文来源

Persistence or Reversal? the Effects of Abnormal Trading Volume on Stock Returns [点击浏览原文]

<摘要>

在记录根据异常交易量(ATV)极端分位构建的月度投资组合在短期内产生正回报(在长期内产生负回报)之后,我们引入了 ATV 持续性的衡量指标,并根据投资者情绪解释了这种回报的可预测性。ATV 持续性导致投资组合回报在短期内继续漂移。然而,随着投资组合中个股的 ATV 逐渐回归长期均值,投资组合回报下降并转为负值,因为定价错误得以纠正。我们还排除了流动性冲击和持续过度反应理论对观察到的回报预测性的解释。

回测表现

年化收益率20.27%
波动率15.69%
Beta0.135
夏普比率1.29
索提诺比率-0.003
最大回撤N/A
胜率50%

完整python代码

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

Leave a Reply

Discover more from Quant Buffet

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

Continue reading