股市中的异常换手效应
登录后收藏学术论文
Abnormal Trading Volume and the Cross-Section of Stock Returns
Abnormal Trading Volume and the Cross-Section of Stock Returns [点击查看论文]
- KRKorea Advanced Institute of Science and Technology
- ?College of Business, Korea Advanced Institute of Science and Technology (KAIST)
策略概要
投资范围包括在纽约证券交易所、美国证券交易所和纳斯达克上市的所有普通股。对每只股票的交易换手率应用36个月的滚动窗口回归,将其分为解释性换手率(ETURN)和异常换手率(UTURN)。UTURN被计算为回归的残差,并由其自身的标准差标准化。股票根据UTURN分为五分位数,投资者做多Q5五分位数(低UTURN)中的股票,做空Q1五分位数(高UTURN)中的股票。投资组合等权重,并每月重新平衡。
II. 策略合理性
回测表现
波动率10.45%
夏普比率1.04
索提诺比率-0.483
胜率50%
完整 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"))