Quant Buffet放轻松,别过度思虑

期权凸性预测连续股票回报策略

登录后收藏

学术论文

A Smiling Bear in the Equity Options Market and the Cross-Section of Stock Returns

作者股票期权市场中的微笑熊与股票回报的横截面 [点击查看论文]

机构
  • Southwestern University of Finance and Economics
  • ?Southwestern University of Finance and Economics (SWUFE)
  • KRKorea University
  • ?Korea University Business School (KUBS)
  • KRGachon University

策略概要

该策略专注于在纽约证券交易所、美国证券交易所和纳斯达克上市的具有流动性期权且可从OptionMetrics获得隐含波动率(IV)数据的股票。投资者使用以下公式计算IV凸度:IV凸度 = IVput(Δ = -0.2) + IVput(Δ = -0.8) − 2 x IVcall(Δ = 0.5)。在每个月末,股票根据其IV凸度被分为五个五等分。投资者做多IV凸度最低的五分之一(Q1),做空IV凸度最高的五分之一(Q5)。投资组合每月重新平衡并持有一个月,股票按价值加权。

II. 策略合理性

由于交易成本较低、杠杆较高且没有卖空限制等优势,知情交易者在股票期权市场比在股票市场更能有效地利用其信息优势。因此,期权价格通常比股票价格更早反映信息。预测股票尾部风险过高的投资者会导致回报的峰态隐含分布,这表明未来股票表现较差。这产生了一种负相关关系,即较高的超额峰度(通过隐含波动率(IV)凸度衡量)预测较低的未来股票回报。

回测表现

波动率9.99%
夏普比率1.05
索提诺比率-0.079
胜率52%

完整 Python 代码

import numpy as np
from AlgorithmImports import *
from typing import Dict, List, Tuple
class OptionsConvexityPredictsConsecutiveStockReturns(QCAlgorithm):
def Initialize(self) -> None:
self.SetStartDate(2015, 1, 1)
self.SetCash(100000)

self.min_expiry: int = 25
self.max_expiry: int = 35
self.quantile: int = 5
self.leverage: int = 5
self.min_share_price: int = 5
self.contracts_count: int = 3
self.thresholds: List[int] = [0.95, 1.05]
self.next_expiry: Union[None, datetime.date] = None

self.fundamental_count: int = 100
self.fundamental_sorting_key = lambda x: x.DollarVolume
self.selection_flag: bool = False
self.stock_universe: List[Symbol] = []
self.option_universe: Dict[Symbol, List[Symbol]] = {}
self.contracts_expiry: Dict[Symbol, datetime.date] = {} # storing contracts expiry date under symbols
self.settings.daily_precise_end_time = False
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.UniverseSettings.Resolution = Resolution.Minute
self.AddUniverse(self.FundamentalSelectionFunction)
self.SetSecurityInitializer(lambda x: x.SetDataNormalizationMode(DataNormalizationMode.Raw))
self.UniverseSettings.DataNormalizationMode = DataNormalizationMode.Raw
self.current_day: int = -1
symbol: Symbol = self.AddEquity("SPY", Resolution.Daily).Symbol
self.Schedule.On(self.DateRules.MonthStart(symbol), self.TimeRules.AfterMarketOpen(symbol), self.Selection)

def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
for security in changes.AddedSecurities:
    security.SetFeeModel(CustomFeeModel())
    security.SetLeverage(self.leverage)
# remove old option contracts
for security in changes.RemovedSecurities:
    symbol = security.Symbol
    if symbol in self.option_universe:
        for option in self.option_universe[symbol]:
            self.RemoveSecurity(option)
        del self.option_universe[symbol]
        self.Liquidate(symbol)
def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
# new symbol selection once a quarter
if not self.selection_flag:
    return Universe.Unchanged
selected: List[Fundamental] = [
    x for x in fundamental 
    if x.HasFundamentalData 
    and x.Market == 'usa' 
    and x.Price > self.min_share_price
]
if len(selected) > self.fundamental_count:
    selected = [
    x for x in sorted(
        selected, 
        key=self.fundamental_sorting_key, 
        reverse=True)[:self.fundamental_count]
        ]     

self.stock_universe = [x.Symbol for x in selected]
return self.stock_universe

def Selection(self) -> None:
if self.Time.month % 3 == 0:
    self.selection_flag = True
    self.Liquidate()

def OnData(self, data: Slice) -> None:
# rebalance daily
if self.current_day == self.Time.day:
    return
self.current_day = self.Time.day

if self.next_expiry and self.Time.date() >= self.next_expiry.date():
    for symbol in self.option_universe:
        for option in self.option_universe[symbol]:
            self.RemoveSecurity(option)
    self.Liquidate()
# for symbol in self.option_universe:
#     # subscribe to new contracts, because current ones has expiried
#     if symbol not in self.contracts_expiry or self.contracts_expiry[symbol] <= self.Time.date():
    
if not self.Portfolio.Invested:
    for symbol in self.stock_universe:
        contracts: List[Symbol] = self.OptionChainProvider.GetOptionContractList(symbol, self.Time)
        underlying_price: float = self.Securities[symbol].Price
        
        if self.Securities[symbol].IsDelisted:
            continue
        strikes: List[float] = [i.ID.StrikePrice for i in contracts]
        if len(strikes) > 0:
            atm_strike: float = min(strikes, key=lambda x: abs(x-underlying_price))
            itm_strike: float = min(strikes, key=lambda x: abs(x-(underlying_price*min(self.thresholds))))
            otm_strike: float = min(strikes, key=lambda x: abs(x-(underlying_price*max(self.thresholds))))
            
            atm_calls: List[Symbol] = [i for i in contracts if i.ID.OptionRight == OptionRight.Call and 
                                                 i.ID.StrikePrice == atm_strike and 
                                                 self.min_expiry < (i.ID.Date - self.Time).days < self.max_expiry]

            itm_puts: List[Symbol] = [i for i in contracts if i.ID.OptionRight == OptionRight.Put and 
                                                 i.ID.StrikePrice == itm_strike and 
                                                 self.min_expiry < (i.ID.Date - self.Time).days < self.max_expiry]

            otm_puts: List[Symbol] = [i for i in contracts if i.ID.OptionRight == OptionRight.Put and 
                                                 i.ID.StrikePrice == otm_strike and 
                                                 self.min_expiry < (i.ID.Date - self.Time).days < self.max_expiry]
            
            if len(atm_calls) > 0 and len(itm_puts) > 0 and len(otm_puts) > 0:
                # sort by expiry
                atm_call: List[Symbol] = sorted(atm_calls, key = lambda x: x.ID.Date)[0]
                itm_put: List[Symbol] = sorted(itm_puts, key = lambda x: x.ID.Date)[0]
                otm_put: List[Symbol] = sorted(otm_puts, key = lambda x: x.ID.Date)[0]
                
                # store expiry date
                # self.contracts_expiry[symbol] = itm_put.ID.Date.date()
                self.next_expiry = atm_call.ID.Date
                # add contracts
                option: Option = self.AddOptionContract(atm_call, Resolution.Minute)
                option.PriceModel = OptionPriceModels.CrankNicolsonFD()
                
                option: Option = self.AddOptionContract(itm_put, Resolution.Minute)
                option.PriceModel = OptionPriceModels.CrankNicolsonFD()
                
                option: Option = self.AddOptionContract(otm_put, Resolution.Minute)
                option.PriceModel = OptionPriceModels.CrankNicolsonFD()
                
                options: List[Symbol] = [atm_call, itm_put, otm_put]
                self.option_universe[symbol] = options           
    
    iv_convexity: Dict[Symbol, float] = {} 
    if data.OptionChains.Count != 0:
        for kvp in data.OptionChains:
            chain: OptionChain = kvp.Value
            contracts: List[Symbol] = [x for x in chain]
            if len(contracts) == self.contracts_count:
                atm_call_iv: Union[None, float] = None
                itm_put_iv: Union[None, float] = None
                otm_put_iv: Union[None, float] = None
                symbol: Symbol = chain.Underlying.Symbol
                for c in contracts:
                    if c.Right == OptionRight.Call:
                        # found atm call
                        atm_call_iv = c.ImpliedVolatility
                    else:
                        # found put option
                        underlying_price:float = self.Securities[c.UnderlyingSymbol].Price
                        if c.Strike < underlying_price:
                            # found itm put
                            itm_put_iv = c.ImpliedVolatility
                        else:
                            # found otm put
                            otm_put_iv = c.ImpliedVolatility
                
                if atm_call_iv and itm_put_iv and otm_put_iv:
                    iv_convexity[symbol] = itm_put_iv + otm_put_iv - (2*atm_call_iv)
    long: List[Symbol] = []
    short: List[Symbol] = []

    # convexity sorting
    if len(iv_convexity) >= self.quantile:
        sorted_by_convexity: List[Tuple[Symbol, float]] = sorted(iv_convexity.items(), key = lambda x: x[1], reverse = True)
        quantile: int = int(len(sorted_by_convexity) / self.quantile)
        long = [x[0] for x in sorted_by_convexity[-quantile:]]
        short = [x[0] for x in sorted_by_convexity[: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)
# 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"))