Option Trading and Returns versus the 52-Week Low
Log in to collectAcademic paper
Option Trading and Returns versus the 52-Week High and Low
Siu Kai Choy; Jason Zhanshun Wei
- 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
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()