
The investment universe consists of S&P 500 market (various ETFs [SPY for U. S., SXR8.DE for Europe, for example], or even CFDs [U. K. and Commonwealth]), and put options on U. S. stocks (equity). (The option data is obtained from the IvyDB database provided by OptionMetrics, and the stock daily return is obtained from the CRSP database.)
ASSET CLASS: ETFs, funds, futures, options | REGION: United States | FREQUENCY:
Monthly | MARKET: equities | KEYWORD: Hedging, Tail Risk
I. STRATEGY IN A NUTSHELL
Universe: S&P 500 (via ETFs like SPY, SXR8.DE, or CFDs) plus U.S. stock put options (OptionMetrics IvyDB, CRSP).
Method: At each month-end, allocate 2% risk budget to the cheapest 20% of OTM puts (≈90 options, ~40 on SPX constituents), chosen with delta ≈ –10% and 6–12 months to expiry. Equal dollar-weighted across puts. The remaining 98% capital is invested in the S&P 500 index. Portfolio rebalanced monthly.
II. ECONOMIC RATIONALE
Tail-risk hedging protects against extreme downturns. Standard insurance via options drags returns, but selecting cheap OTM puts by a price-based heuristic provides efficient downside protection with minimal cost. This enhances portfolio resilience in crises without materially compromising long-run performance, making the strategy an effective balance between market exposure and crash insurance.
III. SOURCE PAPER
Tail Risk Hedging: The Search for Cheap Options [Click to Open PDF]
Poh Ling Neo, Singapore University of Social Sciences ; Chyng Wen Tee, Singapore Management University – Lee Kong Chian School of Business
<Abstract>
We find that a simple heuristic of sorting liquid equity options by dollar price to construct a portfolio of cheap put options leads to a surprisingly robust tail risk hedge – the superior performance holds even when compared against advanced empirical option strategies. Further investigation reveals the asymmetry in market correlation under different market conditions as the mechanism of this robust hedging performance. The correlation spike accompanying tail risk events leads to most of these options moving into the money, compensating the losses incurred on a broad-base equity index holding. During normal market conditions, these options benefit from the diversification effect due to a lower market correlation, thus mitigating the portfolio drag effect.


IV. BACKTEST PERFORMANCE
| Annualised Return | 7.64% |
| Volatility | 12.62% |
| Beta | 0.459 |
| Sharpe Ratio | 0.61 |
| Sortino Ratio | 0.677 |
| Maximum Drawdown | -47.63% |
| Win Rate | 65% |
V. FULL PYTHON CODE
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"))