“该策略在标普500指数成分股和虚值看跌期权上投资。每月分配2%风险预算等权重买入价格最低的20%看跌期权(delta接近-10%,到期6-12个月),其余98%投资于标普500指数成分股,并每月进行再平衡。”
资产类别:ETF、基金、期货、期权 | 地区:美国 | 频率:每月 | 市场:股票 | 关键词:对冲,尾部风险
策略概述
投资范围包括标普500市场(可以是各种ETF,如美国的SPY或欧洲的SXR8.DE,甚至英国及英联邦地区的CFD),以及美国股票的看跌期权。(期权数据来自OptionMetrics的IvyDB数据库,股票的每日回报数据来自CRSP数据库。)
根据我们的价格启发式方法,我们根据美元价格对投资范围内的看跌期权(OTM期权)进行排序。在每月结束时,我们分配2%的风险预算来购买价格最低的20%的看跌期权,每个期权的美元权重相等——每期平均涉及90个期权,其中约40个期权的标的为SPX指数成分股。
每月结束时,我们为每只可选择的股票选择一个虚值(OTM)的看跌期权。期权选择的标准是delta值最接近-10%,到期时间为6个月至1年之间。(选择该delta值是为了在下行风险保护和风险预算之间取得平衡,而期权到期时间的选择则是为了尽量减少交易次数,受限于流动性,期权到期时需要滚动对冲头寸。)
剩余98%的资本分配在标普500指数(广泛市场)成分股上。
投资者需每月进行再平衡,具体权重如上文所述(期权对冲组合是等权重的)。
策略合理性
有效的尾部风险对冲对投资组合在极端市场条件下的应对能力至关重要。由于难以预测尾部风险事件,投资组合经理通常会通过调整组合持仓来应对这一不可预知的情况。这通常通过多样化配置或购买保险类证券来实现,而后者需要分配一部分风险预算,可能对组合表现产生拖累。研究中的分析表明,通过价格启发式选择的股票期权具有多样化的公司特征,其中不到一半是SPX成分股。在正常市场条件下,这些期权的多样化回报表现使得近20%的虚值看跌期权转为实值,从而弥补了风险支出,减轻了组合的拖累。在市场动荡时,市场相关性大幅上升,超过65%的这些虚值看跌期权深度实值,提供了SPX持仓的尾部风险对冲。因此,这一启发式方法能够利用市场相关性在不同市场条件下的非对称行为,提供经济高效的尾部风险对冲。
论文来源
Tail Risk Hedging: The Search for Cheap Options [点击浏览原文]
- 新加坡社会科学大学 Poh Ling Neo
- 新加坡管理大学李光前商学院 Chyng Wen Tee
<摘要>
我们发现通过简单的启发式方法对流动性良好的股票期权按美元价格排序来构建便宜的看跌期权组合,能够实现意外强大的尾部风险对冲——即使与高级的经验期权策略相比,该组合表现依然出色。进一步的研究表明,不同市场条件下市场相关性的非对称性是这种稳健对冲表现的机制。尾部风险事件伴随的相关性激增使得大多数这些期权变为实值,弥补了广泛股票指数持仓的损失。在正常市场条件下,由于较低的市场相关性,这些期权受益于多样化效应,从而缓解了组合的拖累效应。”


回测表现
| 年化收益率 | 7.64% |
| 波动率 | 12.62% |
| Beta | 0.459 |
| 夏普比率 | 0.61 |
| 索提诺比率 | 0.677 |
| 最大回撤 | -47.63% |
| 胜率 | 65% |
完整python代码
from AlgorithmImports import *
from typing import List, Dict
# endregion
class TailRiskHedgingwithCheapOptions(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2010, 1, 1)
self.SetCash(1_000_000)
seeder = FuncSecuritySeeder(self.GetLastKnownPrices)
self.SetSecurityInitializer(lambda security: seeder.SeedSecurity(security))
self.leverage: int = 5
self.quantile: int = 5
self.min_expiry: int = 6 * 30
self.max_expiry: int = 12 * 30
self.min_delta: float = 0.1
self.exchanges: List[str] = ['NYS', 'NAS', 'ASE']
self.active_stock_universe: List[Symbol] = []
self.monthly_subscribed_contracts: List[OptionContract] = []
self.market: Symbol = self.AddEquity('SPY', Resolution.Daily).Symbol
self.option_port_weight: float = 0.02
self.market_port_weight: float = 0.98
self.fundamental_count: int = 100
self.fundamental_sorting_key = lambda x: x.MarketCap
self.selection_flag: bool = False
self.rebalance_flag: bool = False
self.UniverseSettings.Resolution = Resolution.Daily
self.AddUniverse(self.FundamentalSelectionFunction)
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.UniverseSettings.DataNormalizationMode = DataNormalizationMode.Raw
self.Schedule.On(self.DateRules.MonthStart(self.market), self.TimeRules.BeforeMarketClose(self.market, 0), self.Selection)
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]:
if not self.selection_flag:
return Universe.Unchanged
selected: List[Fundamental] = [
f for f in fundamental if f.HasFundamentalData and \
f.MarketCap != 0 and \
f.SecurityReference.ExchangeId in self.exchanges
]
if len(selected) > self.fundamental_count:
selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]
self.monthly_subscribed_contracts.clear()
self.active_stock_universe = list(map(lambda stock: stock.Symbol, selected))
return self.active_stock_universe
def OnData(self, slice: Slice) -> None:
if self.selection_flag:
self.selection_flag = False
for symbol in self.active_stock_universe:
# subscribe to contract
contracts: List[Symbol] = self.OptionChainProvider.GetOptionContractList(symbol, self.Time)
underlying_price: float = self.Securities[symbol].Price
strikes: List[float] = [i.ID.StrikePrice for i in contracts]
if len(strikes) <= 0:
continue
otm_strike: float = min(strikes, key=lambda x: abs(x - (underlying_price * (1. + self.min_delta))))
otm_puts: List[Symbol] = self.SelectContracts(contracts, OptionRight.Put, otm_strike)
# make sure there are enough contracts
if len(otm_puts) > 0:
# sort by expiry and subscribe nearest contract
nearest_contracts: Symbol = sorted(otm_puts, key=lambda item: item.ID.Date)[0]
option: OptionContract = self.AddOptionContract(nearest_contracts, Resolution.Daily)
option.PriceModel = OptionPriceModels.CrankNicolsonFD()
self.monthly_subscribed_contracts.append(option)
self.rebalance_flag = True
if len(self.monthly_subscribed_contracts) != 0 and slice.OptionChains.Count != 0 and self.rebalance_flag:
self.rebalance_flag = False
option_price: Dict[Symbol, float] = {
c.Symbol : c.AskPrice for c in self.monthly_subscribed_contracts
}
if len(option_price) < self.quantile:
return
quantile: int = int(len(option_price) / self.quantile)
sorted_by_opt_price: List[OptionContract] = sorted(option_price, key=option_price.get)
cheapest: List[OptionContract] = sorted_by_opt_price[:quantile]
# trade execution
for option_c in cheapest:
if option_c in slice and slice[option_c]:
self.SetHoldings(option_c, self.option_port_weight / len(cheapest))
self.SetHoldings(self.market, self.market_port_weight)
def SelectContracts(self, contracts: List[Symbol], option_right: int, strike: float) -> List[Symbol]:
return [i for i in contracts if i.ID.OptionRight == option_right \
and i.ID.StrikePrice == strike and self.min_expiry < (i.ID.Date - self.Time).days < self.max_expiry]
def Selection(self) -> None:
self.selection_flag = True
# Custom fee model
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters):
fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))
