该策略涵盖CRSP数据库中的所有股票及其相关期权。筛选出符合条件的期权(交易量不为零、到期日在30至60天之间、moneyness在0.8至1.2之间、买卖价差百分比小于100%)。每月最后一个交易日计算股票的“价格与52周高点比率”(PTH),根据PTH将股票分为五组。对PTH最高组买入看涨期权,最低组卖出看涨期权,进行日常对冲。净余额按无风险利率投资或借贷,组合等权重分配并每月重新平衡。

策略概述

投资范围包括CRSP数据库中的所有股票及其相关期权合约。首先,对期权进行筛选,保留满足以下条件的期权:交易量不为零、到期日在30至60天之间(即只保留下月到期的期权)、行权价格与股票价格的比率(moneyness)在0.8至1.2之间、买入价和买卖价差为正且买卖价差的百分比(即买卖价差除以中间价)小于100%。其次,在每个月的最后一个交易日,计算每只股票的“价格与52周高点比率”(PTH),即股票价格除以其52周高点。第三,根据PTH比率将股票分为五个分位组,其中最高分位包含PTH最高的股票,最低分位包含PTH最低的股票。第四,对于每只最高分位的股票,买入一张看涨期权,并在下个月进行日常的对冲调整;相反,对于最低分位的股票,卖出一张看涨期权,并在下个月进行日常的对冲调整。将净余额以无风险利率进行投资或借贷。投资组合根据未平仓合约的美元价值进行等权重分配,并每月重新平衡。

策略合理性

根据Driessen、Lin和Van Hemert(2012)的研究,当股票价格接近52周高点或低点时,看涨和看跌期权的隐含波动率下降,这表明由锚定效应导致的股票价格对新闻反应的滞后。结果是,当股票价格接近52周高点或低点时,期权隐含波动率的预测偏低,导致看涨和看跌期权被低估。然而,Garleanu、Pedersen和Poteshman(2009)的需求压力理论表明,看涨和看跌期权的定价在相反方向上存在误差。具体而言,当股票价格接近52周高点时,看涨期权被低估、看跌期权被高估;当股票价格接近52周低点时则相反。需求压力效应与波动率效应在股票价格接近52周高点(或低点)时相互增强,但在股票价格接近52周低点(或高点)时则相互抵消。根据这一理论,当股票价格接近52周高点(或低点)时,之后的对冲回报率对看涨期权(或看跌期权)明显更高。

论文来源

Option Trading and Returns versus the 52-Week High and Low [点击浏览原文]

<摘要>

我们发现期权交易者受到股票价格接近52周高点或低点的锚定效应影响。具体表现为:1)当股票价格接近52周高点或低点时,所有期权的交易量减少;2)当股票价格接近52周高点时,看涨期权的买卖不平衡减少,而看跌期权的买卖不平衡增加;相反,当股票价格接近52周低点时情况相反;3)无论是看涨期权还是看跌期权,当股票价格接近52周高点或低点时,之后的对冲回报率更高。

回测表现

年化收益率8.47%
波动率11.05%
Beta-0.163
夏普比率0.77
索提诺比率N/A
最大回撤N/A
胜率51%

完整python代码

from AlgorithmImports import *
import data_tools
# endregion
class OptionTradingandReturnsversusthe52WeekHigh(QCAlgorithm):
    def Initialize(self):
        self.SetStartDate(2010, 1, 1)
        self.SetCash(100000)
        self.leverage:int = 5
        self.quantile:int = 5
        self.portfolio_percentage:float = 1
        self.high_period:int = 52 * 5
        self.exchanges:list[str] = ['NYS', 'NAS', 'ASE']
        self.data:dict[Symbol, data_tools.SymbolData] = {}
        self.selected_symbols:list[Symbol] = []
        self.highest_PTH:List[Symbol] = []
        self.lowest_PTH:List[Symbol] = []
        self.market_symbol:Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
        # options storage
        self.min_expiry:int = 30
        self.max_expiry:int = 60
        self.subscribed_contracts:dict = {}      # subscribed option universe
        self.coarse_count:int = 500
        self.selection_flag:bool = False
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.CoarseSelectionFunction)
        self.SetSecurityInitializer(lambda x: x.SetDataNormalizationMode(DataNormalizationMode.Raw))
        self.UniverseSettings.DataNormalizationMode = DataNormalizationMode.Raw
        self.Schedule.On(self.DateRules.MonthStart(self.market_symbol), self.TimeRules.BeforeMarketClose(self.market_symbol, 0), self.Selection)
    def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
        for security in changes.AddedSecurities:
            security.SetFeeModel(data_tools.CustomFeeModel())
            security.SetLeverage(self.leverage)
            
            symbol:Symbol = security.Symbol
            if symbol not in self.data:
                self.data[symbol] = data_tools.SymbolData(self.high_period)
        
        for security in changes.RemovedSecurities:
            symbol:Symbol = security.Symbol
            if symbol in self.data:
                del self.data[symbol]
    def CoarseSelectionFunction(self, coarse:List[CoarseFundamental]) -> List[Symbol]:
        if not self.selection_flag:
            return Universe.Unchanged
        
        selected:list = sorted([x for x in coarse if x.HasFundamentalData and x.Market == 'usa' and x.Price > 5],
            key=lambda x: x.DollarVolume, reverse=True)[:self.coarse_count]
        
        self.selected_symbols = list(map(lambda stock: stock.Symbol, selected))
        return self.selected_symbols
        
    def OnData(self, data:Slice) -> None:
        if self.highest_PTH and self.lowest_PTH:
            oi_by_symbol:dict[Symobl, float] = {}
            # for each stock in the highest quintile, buy one call option and delta-hedge in the next month with daily rebalancing. Conversely, for each stock in the lowest quintile, sell one call option and delta-hedge in the next month.
            if self.subscribed_contracts:
                # store OI values
                if data.OptionChains.Count != 0:
                    for kvp in data.OptionChains:
                        contract = list(kvp.Value)[0]
                        symbol:Symbol = contract.UnderlyingSymbol
                        open_interest:float = contract.OpenInterest
                        if open_interest != 0:
                            oi_by_symbol[symbol] = open_interest
                # trade options and hedge
                total_oi:float = sum([oi_by_symbol[x] for x in self.highest_PTH if x in oi_by_symbol])
                for symbol in self.highest_PTH:
                    if symbol in data and data[symbol]:
                        if symbol in self.subscribed_contracts:
                            if symbol in oi_by_symbol:
                                price:float = data[symbol].Value
                                weight:float = oi_by_symbol[symbol] / total_oi
                                
                                equity:float = self.Portfolio.TotalPortfolioValue * weight
                                options_q:int = int(equity / (price * 100))
                                self.Buy(self.subscribed_contracts[symbol], options_q)
                                
                total_oi:float = sum([oi_by_symbol[x] for x in self.lowest_PTH if x in oi_by_symbol])
                for symbol in self.lowest_PTH:
                    if symbol in data and data[symbol]:
                        if symbol in self.subscribed_contracts:
                            if symbol in oi_by_symbol:
                                price:float = data[symbol].Value
                                weight:float = oi_by_symbol[symbol] / total_oi
                                
                                equity:float = self.Portfolio.TotalPortfolioValue * weight
                                options_q:int = int(equity / (price * 100))
                                self.Sell(self.subscribed_contracts[symbol], options_q)
                                
            self.subscribed_contracts.clear()
            self.highest_PTH.clear()
            self.lowest_PTH.clear()
        # store daily prices
        for symbol in self.selected_symbols:
            if symbol in self.data:
                if symbol in data and data[symbol] and data[symbol].High != 0 and data[symbol].Value != 0:
                    price:float = data[symbol].Value
                    high:float = data[symbol].High
                    self.data[symbol].update(price, high)
        # rebalance monthly
        if not self.selection_flag:
            return
        self.selection_flag = False
        # calculate PTH
        PTH:dict[Symbol, float] = { symbol : self.data[symbol].PTH() for symbol in self.selected_symbols if symbol in self.data and self.data[symbol].PTH_data_ready() }
        if len(PTH) < self.quantile:
            self.Liquidate()
            return
        # sorting
        quantile:int = int(len(PTH) / self.quantile)
        sorted_by_PTH:List[Symbol] = [x[0] for x in sorted(PTH.items(), key=lambda item: item[1])]
        self.highest_PTH = sorted_by_PTH[-quantile:]
        self.lowest_PTH = sorted_by_PTH[:quantile]
        for symbol in self.highest_PTH + self.lowest_PTH:
            if symbol in data and data[symbol]:
                # get all contracts for current stock symbol
                contracts:List = self.OptionChainProvider.GetOptionContractList(symbol, self.Time)
                # get current price for stock
                underlying_price:float = data[symbol].Value
                
                # get strikes from commodity future contracts
                strikes:List[float] = [c.ID.StrikePrice for c 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))
                
                # filter calls contracts with one month expiry
                atm_calls:List = [ contract for contract in contracts if self.min_expiry < (contract.ID.Date - self.Time).days < self.max_expiry and contract.ID.OptionRight == OptionRight.Call and contract.ID.StrikePrice == atm_strike ]
                # make sure, there is at least one call contract
                if len(atm_calls) > 0:
                    # sort by expiry
                    atm_call = sorted(atm_calls, key = lambda x: x.ID.Date, reverse=True)[0]
                    
                    atm_call_subscriptions = self.SubscriptionManager.SubscriptionDataConfigService.GetSubscriptionDataConfigs(atm_call.Underlying)
                    
                    # check if stock's call contract was successfully subscribed
                    if atm_call_subscriptions:
                        # add contract
                        option = self.AddOptionContract(atm_call, Resolution.Daily)
                        option.PriceModel = OptionPriceModels.CrankNicolsonFD()
                        # store contracts by stock's symbol
                        self.subscribed_contracts[symbol] = atm_call
    def Selection(self) -> None:
        self.selection_flag = True
        self.Liquidate()

Leave a Reply

Discover more from Quant Buffet

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

Continue reading