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.)

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 Return7.64%
Volatility12.62%
Beta0.459
Sharpe Ratio0.61
Sortino Ratio0.677
Maximum Drawdown-47.63%
Win Rate65%

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"))

Leave a Reply

Discover more from Quant Buffet

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

Continue reading