Quant Buffet放轻松,别过度思虑

认沽-认购价差预测财报公告后的收益

登录后收藏

学术论文

Volatility Spreads and Earnings Announcement Returns

机构
  • ?Yigit Atilgan. 萨班吉大学。
论文摘要

先前的研究表明,波动率价差可以预测股票回报。如果有信息的投资者的交易活动是波动率价差的重要驱动因素,那么股票回报的可预测性应在重大信息事件期间更为显著。本文研究了波动率价差是否在财报公告期间更强地预测股票回报。波动率价差通过配对的认沽和认购期权之间的隐含波动率差异来衡量,并捕捉期权市场中的价格压力。在为期两天的财报公告窗口期内,包含相对昂贵认购期权的股票所在五分位的异常回报比包含相对昂贵认沽期权的股票所在五分位的异常回报高出1.5%以上。这个结果在使用不同方式衡量波动率价差并控制公司特征和滞后股票回报之后依然成立。当波动率价差使用流动性较强的期权、信息环境更加不对称以及股票流动性较低时,公告回报的可预测性更强。

策略概要

: 美国股票市场的每日盈余波动率差轮换

该策略以具有流动性期权的纽约证券交易所(NYSE)、美国证券交易所(AMEX)和纳斯达克(NASDAQ)公司为目标,围绕财报公告进行交易。在公告前一天的收盘时,计算隐含波动率价差,作为匹配的认沽和认购期权之间的加权差异,使用未平仓合约作为权重。根据波动率价差将股票分为五个等级,并通过根据买卖价差将期权对分为三类来考虑流动性。该策略对波动率价差最高的股票做多,对波动率价差最低的股票做空,持仓两天(公告当天及次日)。投资组合等权重,进行每日再平衡,并使用50%的仓位暴露来管理波动性。

策略合理性

研究表明,期权价格可能预示未来股票回报,因为有信息的交易者通常倾向于选择期权市场,在股票市场之前反映信息。如果交易者预期股价上涨(下跌),则对认购(认沽)期权的需求增加,从而使它们的隐含波动率相对于认沽(认购)期权上升。因此,在股价下跌之前,认沽与认购隐含波动率之间的差距扩大,而在股价上涨之前则缩小。这种由有信息交易驱动的认沽-认购平价偏离,尤其在重大信息事件(如财报公告)期间具有预测性,因为此时市场活动加剧,放大了期权市场行为与未来股票价格走势之间的关系。

回测表现

波动率34.25%
夏普比率2.75
索提诺比率-0.06
胜率50%

完整 Python 代码

from AlgorithmImports import *
import numpy as np
from pandas.tseries.offsets import BDay
import data_tools
from typing import List, Dict, Set, Tuple
#endregion
class PutCallSpreadPredictsEarningsAnnouncementReturns(QCAlgorithm):
def Initialize(self) -> None:
self.SetStartDate(2010, 1, 1)
self.SetCash(100_000)

self.leverage: int = 5
self.min_share_price: int = 5
self.spread_threshold: int = 5
self.top_percentile: int = 80
self.bottom_percentile: int = 20
# self.min_expiry = 30
# self.max_expiry = 60
self.fundamental_count: int = 100
self.fundamental_sorting_key = lambda x: x.DollarVolume

self.data: Dict[Symbol, SymbolData] = {}               # storing daily IV
self.contracts: Dict[Symbol, data_tools.Contract] = {} # storing option contracts
self.tickers_symbols: Dict[str, Symbol] = {}           # storing symbols under their tickers

# quarterly stored volatility spread values for stocks
self.actual_quarter_spread_values: List[float] = []
self.prev_quarter_spread_values: List[float] = []

# parse earnings data
self.earnings_universe: List[str] = [] # stored earnings tickers
self.earnings_by_date: Dict[datetime, str] = {}
earnings_set: Set = set()
# self.first_date:datetime.date|None = None
earnings_data: str = self.Download('data.quantpedia.com/backtesting_data/economic/earnings_dates_eps.json')
earnings_data_json: List[dict] = json.loads(earnings_data)

for obj in earnings_data_json:
    date: datetime.date = datetime.strptime(obj['date'], "%Y-%m-%d").date()
    self.earnings_by_date[date] = []
    
    # if not self.first_date: self.first_date = date
    for stock_data in obj['stocks']:
        ticker: str = stock_data['ticker']
        self.earnings_by_date[date].append(ticker)
        earnings_set.add(ticker)
for ticker in earnings_set:
    self.earnings_universe.append(ticker)
self.symbol: Symbol = self.AddEquity('SPY', Resolution.Minute).Symbol
# equally weighted brackets for traded symbols
self.trade_manager: data_tools.TradeManager = data_tools.TradeManager(self, 10, 10, 2)

self.long: List[Symbol] = []   # long stocks with the highest implied volatility spreads on pre-announcement day
self.short: List[Symbol] = []  # short stocks with the lowest implied volatility spreads on pre-announcement day

self.selection_flag: bool = False
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.settings.daily_precise_end_time = False
self.UniverseSettings.Resolution = Resolution.Minute
self.AddUniverse(self.FundamentalSelectionFunction)
self.SetSecurityInitializer(lambda x: x.SetDataNormalizationMode(DataNormalizationMode.Raw))
self.UniverseSettings.DataNormalizationMode = DataNormalizationMode.Raw
self.Schedule.On(self.DateRules.MonthStart(self.symbol), self.TimeRules.BeforeMarketClose(self.symbol), self.Selection)
def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
for security in changes.AddedSecurities:
    security.SetFeeModel(data_tools.CustomFeeModel())
    security.SetLeverage(self.leverage)
    
def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
# rebalance weekly
if not self.selection_flag:
    return Universe.Unchanged

# select top n stocks by dollar volume with price higher than 5
selected: List[Fundamental] = [
    x for x in fundamental 
    if x.HasFundamentalData 
    and x.MarketCap != 0 
    and x.Market == 'usa' 
    and x.Price > self.min_share_price 
    and x.Symbol.Value in self.earnings_universe
    ]
if len(selected) > self.fundamental_count:
    selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]

self.selection_flag = False

selected_symbols: List[Symbol] = [] # storing symbols of selected stocks
selected_tickers: List[str] = [] # storing tickers of selected stocks

# add new stocks to dictionaries
for stock in selected:
    symbol: Symbol = stock.Symbol
    ticker: str = symbol.Value
    market_cap: float = stock.MarketCap
    
    # remove duplicate stocks from selected
    if ticker in selected_tickers:
        # check if symbol of duplicated ticker was stored in self.data
        if symbol in self.data and symbol in self.contracts:
            # remove stock's contracts
            for contract in self.contracts[symbol].contracts:
                self.RemoveSecurity(contract)
                
            del self.data[symbol]
            del self.contracts[symbol]
        
        continue
    
    # add stock symbol to list of selected stocks
    selected_symbols.append(symbol)
    # add stock ticker to list of selected tickers
    selected_tickers.append(ticker)
    
    # don't override data, if they are consecutive
    if ticker in self.tickers_symbols and symbol in self.data and symbol in self.contracts:
        # update market cap
        self.data[symbol].market_cap = market_cap
        continue
    
    self.data[symbol] = data_tools.SymbolData(market_cap, None)
    # store symbol under stock ticker            
    self.tickers_symbols[ticker] = symbol
    # create object from Contract class for stock symbol
    self.contracts[symbol] = data_tools.Contract(self.Time.date(), [])

# make sure, data are consecutive
remove_tickers_symbols:List[Tuple[str, Symbol]] = [] # storing tuple (ticker, symbol)

for ticker, symbol in self.tickers_symbols.items():
    # add stocks, which weren't selected to remove list
    if symbol not in selected_symbols:
        remove_tickers_symbols.append((ticker, symbol))
        
# remove not selected stocks from dictionaries
for ticker, symbol in remove_tickers_symbols:
    if symbol in self.contracts:
        # remove stock's contracts
        for contract in self.contracts[symbol].contracts:
            self.RemoveSecurity(contract)
            
        del self.contracts[symbol]
        
    if symbol in self.data:   
        # delete stock from dictionaries
        del self.data[symbol]
        del self.tickers_symbols[ticker]

# return symbols of selected stocks    
return selected_symbols
def OnData(self, data: Slice) -> None:
# each day store implied volatility for selected stocks
if self.Time.hour == 9:
    if data.OptionChains.Count != 0:
        # there is no earnings next day
        date_to_check: datetime.date = (self.Time.date() + BDay(1)).date()
        if date_to_check not in self.earnings_by_date:
            return
        # top and bottom percentile of volatility spread values
        top_percentile: Union[None, float] = None
        bottom_percentile: Union[None, float] = None
        
        # spread values are stored for previous quarter
        if len(self.prev_quarter_spread_values) > self.spread_threshold:
            top_percentile = np.percentile(self.prev_quarter_spread_values, self.top_percentile)
            bottom_percentile = np.percentile(self.prev_quarter_spread_values, self.bottom_percentile)
        for kvp in data.OptionChains:
            chain: OptionChains = kvp.Value
            symbol: Symbol = chain.Underlying.Symbol
            
            # get option ticker from option symbol
            ticker: str = symbol.Value
                
            # stock has earnings in one day
            if ticker not in self.earnings_by_date[date_to_check]:
                continue
            if ticker not in self.tickers_symbols:
                continue
            
            # based on option ticker get stock symbol
            stock_symbol: Symbol = self.tickers_symbols[ticker]
            
            # make sure, spread is updated once in a day
            if stock_symbol not in self.data or self.data[stock_symbol].updated_date == self.Time.date():
                continue
            
            contracts: List[OptionContracts] = [x for x in chain]
            
            # make sure, there are enough contracts for stock
            if len(contracts) < 2:
                continue
            
            call_iv: Union[None, float] = None
            put_iv: Union[None, float] = None
            
            # get atm call and atm put contract
            for c in contracts:
                if c.Right == OptionRight.Call:
                    # found atm call
                    call_iv = c.ImpliedVolatility
                elif c.Right == OptionRight.Put:
                    # found atm put
                    put_iv = c.ImpliedVolatility
            
            # check if there are both contracts
            if call_iv and put_iv:
                # calculate volatility spread
                oi: float = c.OpenInterest
                vol_spread: float = oi*(put_iv - call_iv)
                # bid_ask = abs(c.BidPrice - c.AskPrice)
                
                self.actual_quarter_spread_values.append(vol_spread)
                self.data[stock_symbol].updated_date = self.Time.date()
                
                # top and bottom percentile of voltility spread was found
                if top_percentile and bottom_percentile:
                    if vol_spread >= top_percentile:
                        self.long.append(stock_symbol)
                    elif vol_spread <= bottom_percentile:
                        self.short.append(stock_symbol)

if self.Time.hour == 15 and self.Time.minute == 59:
    # check expiry of contracts
    for symbol in self.data:
        # remove contract, when it has 1 day to expiry
        if symbol in self.contracts and ((self.contracts[symbol].expiry_date - timedelta(days=1)) <= self.Time.date()):
            # remove expired contracts
            for contract in self.contracts[symbol].contracts:
                self.RemoveSecurity(contract)
            # remove Contracts object for current symbol
            del self.contracts[symbol]
            
        # subscribe to contracts, if stock symbol doesn't have any
        if symbol not in self.contracts:
            # get new contracts after expiration
            self.SubscribeOptionContracts(symbol)
    
    # try liquidate
    self.trade_manager.TryLiquidate()
    
    # open new trades
    for symbol in self.long:
        if symbol in data and data[symbol]:
            self.trade_manager.Add(symbol, True)
    for symbol in self.short:
        if symbol in data and data[symbol]:
            self.trade_manager.Add(symbol, False)
    
    self.long = []
    self.short = []

def SubscribeOptionContracts(self, symbol: Symbol) -> None:
''' get atm and atm strike for specific symbol then it filters atm call and atm put ''' 
''' if there are enough atm calls and atm puts this function subscribes one of their contracts based on expiry and store expiry date ''' 

# get all contracts for current commodity future
contracts: List[Symbol] = self.OptionChainProvider.GetOptionContractList(symbol, self.Time)
# get current price for commodity future
underlying_price: float = self.Securities[symbol].Price

# get strikes from commodity future contracts
strikes: List[float] = [i.ID.StrikePrice for i in contracts]

# check if there is at least one strike    
if len(strikes) <= 0:
    return

# at the money
atm_strike: Union[None, float] = None
atm_strike: float = min(strikes, key=lambda x: abs(x-underlying_price))

# filtred contracts based on option rights and strikes
atm_calls: List[Symbol] = self.FilterContracts(contracts, OptionRight.Call, atm_strike)
atm_puts: List[Symbol] = self.FilterContracts(contracts, OptionRight.Put, atm_strike)

# make sure there are enough contracts
if len(atm_calls) > 0 and len(atm_puts) > 0:
    # sort by expiry
    atm_call: List[Symbol] = sorted(atm_calls, key = lambda item: item.ID.Date, reverse=True)[0]
    atm_put: List[Symbol] = sorted(atm_puts, key = lambda x: x.ID.Date, reverse=True)[0]
    
    # add contracts
    for contract in [atm_call, atm_put]:
        self.AddContract(contract)
    
    # get expiry date of contracts
    expiry_date: datetime.date = atm_call.ID.Date.date()
    # create new Contract object for stock            
    self.contracts[symbol] = data_tools.Contract(expiry_date, [atm_call, atm_put])   
    
def FilterContracts(self, 
                contracts: List[Symbol], 
                option_right: float, 
                strike: float) -> List[Symbol]:
''' filter contracts based on option_right and select only contracts with expiry in next month are selected '''

# filter contracts based on option right and select only contracts with next month expiry
filtered_contracts: List[Symbol] = [i for i in contracts if i.ID.OptionRight == option_right and 
                                         i.ID.StrikePrice == strike and 
                                         (self.Time.month + 1) == i.ID.Date.month]
                                        #  self.min_expiry < (i.ID.Date - self.Time).days < self.max_expiry]
# return filtered contracts
return filtered_contracts

def AddContract(self, contract: Symbol) -> None:
''' subcribe to contract, set price model and normalization mode '''
option: Option = self.AddOptionContract(contract, Resolution.Minute)
option.PriceModel = OptionPriceModels.CrankNicolsonFD()
option.SetDataNormalizationMode(DataNormalizationMode.Raw)

def Selection(self) -> None:
self.selection_flag = True

# store spread values quarterly 
if self.Time.month % 3 == 0:
    self.prev_quarter_spread_values = self.actual_quarter_spread_values
    self.actual_quarter_spread_values = []