Quant BuffetRelax, Not Over Thinking

Option Trading and Returns versus the 52-Week Low

Log in to collect

Academic paper

Option Trading and Returns versus the 52-Week High and Low

AuthorsSiu Kai Choy; Jason Zhanshun Wei

Institute
  • King's College London
  • CAUniversity of Toronto
  • ?University of Toronto - Rotman School of Management

Strategy in a nutshell

The strategies focus on U.S. stocks and their options from the CRSP database. Stocks are screened for liquidity, option maturity (30–60 days), moneyness (0.8–1.2), and bid-ask criteria.

52-week High Strategy: Stocks are ranked by price-to-high (PTH) ratio. Delta-hedged calls are bought for stocks near their 52-week highs and sold for those farther from highs.

52-week Low Strategy: Stocks are ranked by price-to-low (PTL) ratio, inverted so higher PTL indicates proximity to the low. Delta-hedged puts are bought for stocks near 52-week lows and sold for those farther away.

Portfolios are value-weighted by open interest and rebalanced monthly.

Economic rationale

Both strategies exploit the anchoring behavioral bias, where investors focus on past 52-week price extremes rather than intrinsic value.

Near highs, call options are undervalued due to downward-biased implied volatility and demand-pressure effects, producing higher subsequent delta-hedged returns.

Near lows, put options are mispriced due to investors’ underreaction to price declines, creating opportunities for higher delta-hedged returns.

These strategies systematically capture predictable return patterns from investors’ sluggish incorporation of information at price extremes.

Backtest performance

Annualised return8.34%
Volatility7.4%
Beta-0.062
Sharpe ratio1.13
Win rate45%

Full Python code

from AlgorithmImports import *
import data_tools
# endregion

class OptionTradingandReturnsversusthe52WeekLow(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_ptl:List[Symbol] = []
self.lowest_ptl: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 = {}      # 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

if self.coarse_count <= 1000:
    selected:List[CoarseFundamental] = 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]
else:
    selected:List[CoarseFundamental] = [x for x in coarse if x.HasFundamentalData and x.Market == 'usa']

self.selected_symbols = list(map(lambda stock: stock.Symbol, selected))

return self.selected_symbols

def OnData(self, data:Slice) -> None:
if self.highest_ptl and self.lowest_ptl:
    delta_by_symbol:dict[Symobl, float] = {}
    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_ptl if x in oi_by_symbol])
        for symbol in self.highest_ptl:
            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_ptl if x in oi_by_symbol])
        for symbol in self.lowest_ptl:
            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_ptl.clear()
    self.lowest_ptl.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
            low:float = data[symbol].Low

            self.data[symbol].update(price, low)

# rebalance monthly
if not self.selection_flag:
    return
self.selection_flag = False

# calculate ptl
ptl:dict[Symbol, float] = { symbol : self.data[symbol].PTL() for symbol in self.selected_symbols if symbol in self.data and self.data[symbol].PTL_data_ready() }

if len(ptl) < self.quantile:
    self.Liquidate()
    return

# sorting
quantile:int = int(len(ptl) / self.quantile)
sorted_by_ptl:List[Symbol] = [x[0] for x in sorted(ptl.items(), key=lambda item: item[1])]

self.highest_ptl = sorted_by_ptl[-quantile:]
self.lowest_ptl = sorted_by_ptl[:quantile]

for symbol in self.highest_ptl + self.lowest_ptl:
    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()