“该策略根据36个月滚动回归的异常交易换手率(UTURN)对股票进行排序,做多UTURN较低的股票(Q5),做空UTURN较高的股票(Q1)。”

I. 策略概要

投资范围包括在纽约证券交易所、美国证券交易所和纳斯达克上市的所有普通股。对每只股票的交易换手率应用36个月的滚动窗口回归,将其分为解释性换手率(ETURN)和异常换手率(UTURN)。UTURN被计算为回归的残差,并由其自身的标准差标准化。股票根据UTURN分为五分位数,投资者做多Q5五分位数(低UTURN)中的股票,做空Q1五分位数(高UTURN)中的股票。投资组合等权重,并每月重新平衡。

II. 策略合理性

学术论文表明,以UTURN衡量的异常交易活动与月度范围内的股票回报呈正相关。这种活动受到行为偏差和投资者关注的影响,导致时间序列和横截面数据的交易量出现变化。异常交易的价格影响主要由这些偏差驱动。较高的市场或证券回报预测较高的异常交易活动,对于难以估值的股票,这种影响更为强烈。此外,投资者情绪显著预测异常交易,尤其是在投资者乐观情绪存在时,对于高关注度股票而言。

III. 来源论文

Abnormal Trading Volume and the Cross-Section of Stock Returns [点击查看论文]

<摘要>

交易量高的股票在一周内表现优于其他股票,但在较长期内表现不佳。我们表明,交易量的这种时变可预测性归因于异常交易活动,而异常交易活动无法用过去的交易量来解释。具体而言,我们发现异常交易活动的收益预测能力在未来五周内非常强劲。相反,预期交易活动的预测能力为负,且持续时间更长。我们进一步认为,行为偏差和投资者关注会引发异常交易活动,但其价格影响主要与行为偏差有关。总体证据强调了行为偏差和投资者关注在解释交易量方面的作用。

IV. 回测表现

年化回报10.82%
波动率10.45%
β值0.004
夏普比率1.04
索提诺比率-0.483
最大回撤N/A
胜率50%

V. 完整的 Python 代码

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"))

发表评论

了解 Quant Buffet 的更多信息

立即订阅以继续阅读并访问完整档案。

继续阅读