期权/股票成交量比率变化预测股票回报
登录后收藏学术论文
The Option to Stock Volume Ratio and Future Returns
期权/股票成交量比率与未来回报 [点击查看论文]
- The University of Texas at Austin
- Massachusetts Institute of Technology
- ?Massachusetts Institute of Technology (MIT) - Sloan School of Management
策略概要
该策略目标是具有流动性期权的美国公司,排除CEFs、REITs、ADRs和价格低于1美元的股票。每个月,投资者计算O/S比率,即总期权成交量(所有行权价,从月底后五天开始的30个交易日内到期)除以总股票成交量,标准化为100股批次。O/S比率的变化定义为当前O/S比率与其六个月平均值之间的差异,并按平均值缩放。投资者卖空具有高O/S比率变化的股票,买入具有低变化的股票。投资组合等权重,并每月再平衡。
II. 策略合理性
研究表明,O/S比率与未来回报之间的负相关关系源于股票市场高昂的卖空成本,促使掌握负面信息的交易者倾向于选择期权市场。资本约束和卖空股票的费用导致知情交易者比看涨信号更频繁地使用期权来应对看跌信号。这种行为导致相对期权成交量与未来股票表现之间存在可预测的负相关关系
回测表现
波动率15.5%
夏普比率0.69
索提诺比率-0.119
胜率50%
完整 Python 代码
from AlgorithmImports import *
from typing import List, Dict
from dataclasses import dataclass
#endregion
class ChangeInOptionStockVolumeRatioPredictsStockReturns(QCAlgorithm):
def Initialize(self) -> None:
self.SetStartDate(2014, 1, 1)
self.SetCash(100000)
self.tickers_to_ignore: List[str] = ['AMD', 'TSLA', 'MSFT']
self.min_expiry: int = 20
self.max_expiry: int = 30
self.period: int = 6 # need n monthly volumes
self.min_period_len: int = 14 # need at least n daily volumes and at least n minute volumes
self.quantile: int = 5
self.leverage: int = 15
self.min_share_price: int = 5
self.current_fundamental: List[Symbol] = []
self.previous_fundamental: List[Symbol] = []
self.data: Dict[Symbol, SymbolData] = {}
self.subscribed_contracts: Dict[Symbol, Contracts] = {} # subscribed option universe
# initial data feed
self.AddEquity('SPY', Resolution.Minute)
self.months_counter: int = 1
self.fundamental_count: int = 100
self.fundamental_sorting_key = lambda x: x.DollarVolume
self.selection_flag: bool = True
self.subscribing_flag: bool = False
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.UniverseSettings.Resolution = Resolution.Minute
self.settings.daily_precise_end_time = False
self.AddUniverse(self.FundamentalSelectionFunction)
self.SetSecurityInitializer(lambda x: x.SetDataNormalizationMode(DataNormalizationMode.Raw))
self.UniverseSettings.DataNormalizationMode = DataNormalizationMode.Raw
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]:
# rebalance yearly
if not self.selection_flag:
return Universe.Unchanged
# change flags values
self.selection_flag = False
self.subscribing_flag = True
# filter top n U.S. stocks by dollar volume
selected: List[Fundamental] = [
x for x in fundamental
if x.HasFundamentalData
and x.Market == 'usa'
and x.Price > self.min_share_price
and x.Symbol.Value not in self.tickers_to_ignore
]
if len(selected) > self.fundamental_count:
selected = [
x for x in sorted(
selected,
key=self.fundamental_sorting_key,
reverse=True)[:self.fundamental_count]]
for stock in selected:
symbol: Symbol = stock.Symbol
self.current_fundamental.append(symbol)
# make sure data are consecutive
if symbol not in self.data or symbol not in self.previous_fundamental:
self.data[symbol] = SymbolData(self.period)
# return newly selected symbols
return self.current_fundamental
def OnData(self, data: Slice) -> None:
for stock_symbol in self.current_fundamental:
# stock has to have subscribed option contracts
if stock_symbol not in self.subscribed_contracts:
continue
if self.Securities[stock_symbol].IsDelisted:
continue
# check if any of the subscribed contracts expired
if self.subscribed_contracts[stock_symbol].expiry_date - timedelta(days=1) <= self.Time.date():
for c in self.subscribed_contracts[stock_symbol].contracts:
self.RemoveOptionContract(c)
self.subscribed_contracts[stock_symbol].contracts.clear()
# remove Contracts object for current symbol
del self.subscribed_contracts[stock_symbol]
else:
# collect volumes
stock_volume: Union[None, float] = data[stock_symbol].Value if stock_symbol in data and data[stock_symbol] else None
option_volumes: List[float] = []
option_contracts: List[Symbol] = self.subscribed_contracts[stock_symbol].contracts
for option_contract in option_contracts:
if option_contract in data and data[option_contract]:
# option volume isn't in data object
option_volumes.append(self.Securities[option_contract].Volume)
# make sure all volumes were collected
if stock_volume is not None and len(option_volumes) == len(option_contracts):
# store volumes
if stock_symbol not in self.data:
self.data[stock_symbol] = SymbolData(self.period)
# store minute stock volume
self.data[stock_symbol].stock_minute_volumes.append(stock_volume)
# store total minute option volume
self.data[stock_symbol].options_minute_volumes.append(sum(option_volumes))
if stock_symbol in self.data:
if self.Time.hour == 16 and self.Time.minute == 0 and self.data[stock_symbol].minute_volumes_ready(self.min_period_len):
self.data[stock_symbol].update_daily_volumes()
# perform trade, when there are no active contracts for current selection
if len(self.subscribed_contracts) == 0 and not self.subscribing_flag and self.Time.hour != 0:
OS_ratio_change: Dict[Symbol, float] = {}
for stock_symbol in self.current_fundamental:
if stock_symbol not in self.data:
continue
if self.Securities[stock_symbol].IsDelisted:
continue
symbol_obj: SymbolData = self.data[stock_symbol]
# each stock has to have at least minimum daily volumes
if symbol_obj.daily_volumes_ready(self.min_period_len):
symbol_obj.update_os_ratios()
else:
self.data[stock_symbol].clear_data()
del self.data[stock_symbol]
# OS ratios data has to be ready
if not symbol_obj.is_ready():
continue
OS_ratios_values: List[float] = [x for x in symbol_obj.os_ratios]
mean_os_ratios_value: float = np.mean(OS_ratios_values)
OS_ratio_change_value: float = (OS_ratios_values[0] - mean_os_ratios_value) / mean_os_ratios_value
# store OS ratio change keyed by stock symbol
OS_ratio_change[stock_symbol] = OS_ratio_change_value
# make sure there are enough stocks with data
if len(OS_ratio_change) >= self.quantile:
# perform selection
quantile: int = int(len(OS_ratio_change) / self.quantile)
sorted_by_ratio: List[Symbol] = [x[0] for x in sorted(OS_ratio_change.items(), key=lambda item: item[1])]
# long low and short high
long: List[Symbol] = sorted_by_ratio[:quantile]
short: List[Symbol] = sorted_by_ratio[-quantile:]
# trade execution
targets: List[PortfolioTarget] = []
for i, portfolio in enumerate([long, short]):
for symbol in portfolio:
if symbol in data and data[symbol]:
targets.append(PortfolioTarget(symbol, ((-1) ** i) / len(portfolio)))
self.SetHoldings(targets, True)
elif not self.selection_flag:
# liquidate all positions from previous selection
self.Liquidate()
if len(self.current_fundamental) != 0 and self.months_counter % 12 == 0:
# reinitialize previous fundamental
self.previous_fundamental = list(map(lambda symbol: symbol, self.current_fundamental))
# make space for new stocks from fundamental
self.current_fundamental.clear()
# increase month counter
self.months_counter += 1
# perform next selection
self.selection_flag = True
elif not self.subscribing_flag and len(self.current_fundamental) != 0:
# perform new subscribtion without selection
self.subscribing_flag = True
# increase months counter
self.months_counter += 1
return # skip to firstly perform fundamental selection and then contracts subscribing
# subscribe to new contracts after selection
if len(self.subscribed_contracts) == 0 and self.subscribing_flag:
for symbol in self.current_fundamental:
if self.Securities[symbol].IsDelisted:
continue
# get all contracts for current stock symbol
contracts: List[Symbol] = self.OptionChainProvider.GetOptionContractList(symbol, self.Time)
# get current price for etf
underlying_price: float = self.Securities[symbol].Price
# get strikes from commodity future contracts
strikes: List[float] = [i.ID.StrikePrice for i in contracts]
# can't filter contracts, if there isn't any strike price
if len(strikes) <= 0 or underlying_price == 0:
continue
atm_strike: float = min(strikes, key=lambda x: abs(x-underlying_price))
itm_strike: float = min(strikes, key=lambda x: abs(x-(underlying_price*0.95)))
otm_strike: float = min(strikes, key=lambda x: abs(x-(underlying_price*1.05)))
# filter calls and puts contracts with one month expiry
atm_calls, atm_puts = self.FilterContracts(atm_strike, contracts, underlying_price)
itm_calls, itm_puts = self.FilterContracts(itm_strike, contracts, underlying_price)
otm_calls, otm_puts = self.FilterContracts(otm_strike, contracts, underlying_price)
# make sure, there is at least one call and put contract
if len(atm_calls) > 0 and len(atm_puts) > 0 and len(itm_calls) > 0 and len(itm_puts) > 0 and len(otm_calls) > 0 and len(otm_puts) > 0:
# sort by expiry
atm_call, atm_put = self.SortByExpiry(atm_calls, atm_puts)
itm_call, itm_put = self.SortByExpiry(itm_calls, itm_puts)
otm_call, otm_put = self.SortByExpiry(otm_calls, otm_puts)
atm_call_subscriptions: List[SubscriptionDataConfig] = self.SubscriptionManager.SubscriptionDataConfigService.GetSubscriptionDataConfigs(atm_call.Underlying)
# check if stock's call and put contract was successfully subscribed
if atm_call_subscriptions:
selected_contracts: List[Symbol] = [atm_call, atm_put, itm_call, itm_put, otm_call, otm_put]
for contract in selected_contracts:
# add contract
self.AddOptionContract(contract, Resolution.Minute)
# retrieve expiry date for contracts
expiry_date: datetime.date = atm_call.ID.Date.date() if atm_call.ID.Date.date() < atm_put.ID.Date.date() else atm_put.ID.Date.date()
# store contracts with expiry date under stock's symbol
self.subscribed_contracts[symbol] = Contracts(expiry_date, underlying_price, selected_contracts)
# at least one stock has to have successfully subscribed all option contracts, to stop subscribing
if len(self.subscribed_contracts) > 0:
self.subscribing_flag = False
def FilterContracts(self,
strike: float,
contracts: List[Symbol],
underlying_price: float) -> List[Symbol]:
''' filter call and put contracts from contracts parameter '''
''' return call and put contracts '''
calls: List[Symbol] = [] # storing call contracts
puts: List[Symbol] = [] # storing put contracts
for contract in contracts:
# check if contract has one month expiry
if self.min_expiry < (contract.ID.Date - self.Time).days < self.max_expiry:
# check if contract is call
if contract.ID.OptionRight == OptionRight.Call and contract.ID.StrikePrice == strike:
calls.append(contract)
# check if contract is put
elif contract.ID.OptionRight == OptionRight.Put and contract.ID.StrikePrice == strike:
puts.append(contract)
# return filtered calls and puts with one month expiry
return calls, puts
def SortByExpiry(self,
calls: List[Symbol],
puts: List[Symbol]) -> List[Symbol]:
''' return option call and option put with farest expiry '''
call: List[Symbol] = sorted(calls, key = lambda x: x.ID.Date, reverse=True)[0]
put: List[Symbol] = sorted(puts, key = lambda x: x.ID.Date, reverse=True)[0]
return call, put
class SymbolData:
def __init__(self, period: int) -> None:
self.os_ratios: RollingWindow = RollingWindow[float](period)
self.stock_minute_volumes: List[float] = []
self.options_minute_volumes: List[float] = []
self.stock_daily_volumes: List[float] = []
self.options_daily_volumes: List[float] = []
def update_daily_volumes(self) -> None:
self.stock_daily_volumes.append(sum(self.stock_minute_volumes))
self.options_daily_volumes.append(sum(self.options_minute_volumes))
self.stock_minute_volumes.clear()
self.options_minute_volumes.clear()
def update_os_ratios(self) -> None:
os_ratio_value = sum(self.options_daily_volumes) / sum(self.stock_daily_volumes)
self.os_ratios.Add(os_ratio_value)
self.stock_daily_volumes.clear()
self.options_daily_volumes.clear()
def clear_data(self) -> None:
self.stock_minute_volumes.clear()
self.options_minute_volumes.clear()
self.stock_daily_volumes.clear()
self.options_daily_volumes.clear()
def daily_volumes_ready(self, period: int) -> bool:
return len(self.stock_daily_volumes) >= period and len(self.options_daily_volumes) >= period
def minute_volumes_ready(self, period: int) -> bool:
return len(self.stock_minute_volumes) >= period and len(self.options_minute_volumes) >= period
def is_ready(self) -> bool:
return self.os_ratios.IsReady
@dataclass
class Contracts():
expiry_date: datetime.date
underlying_price: float
contracts: List[Symbol]
# Custom fee model
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters: OrderFeeParameters) -> OrderFee:
fee: float = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))