
The strategy involves going long on the top decile and short on the bottom decile of at-the-money options, delta-hedged and held until maturity. The portfolio is equally weighted.
ASSET CLASS: options | REGION: United States | FREQUENCY:
Monthly | MARKET: equities | KEYWORD: Option
I. STRATEGY IN A NUTSHELL
The strategy trades next-month options from the OptionMetrics Ivy DB. It selects at-the-money calls (moneyness 0.7–1.3) and forms deciles by underlying stock price. The portfolio goes long on the top decile, short on the bottom, is equally weighted, delta-hedged, and held until maturity.
II. ECONOMIC RATIONALE
Retail investors overvalue low-priced stock options, especially on stocks with low institutional ownership. This drives a predictable anomaly, as professional traders act differently, creating exploitable mispricing.
III. SOURCE PAPER
Cheap Options Are Expensive [Click to Open PDF]
Eisdorfer, Assaf and Goyal, Amit and Zhdanov, Alexei, University of Connecticut, University of Lausanne and Swiss Finance Institute, Penn State University, HSE University
<Abstract>
We show that (partial) inattention to the underlying stock prices generates a demand pressure for options on low-priced stocks, resulting in overpricing of such options. Empirically, we find that delta-hedged options on low-priced stocks underperform those on high-priced stocks by 0.63% per week for calls and 0.36% for puts. Natural experiments corroborate this finding; options tend to become relatively more expensive following stock splits, and options on mini-indices are overpriced relative to options written on otherwise identical regular indices. Skewness preference does not explain our results.

IV. BACKTEST PERFORMANCE
| Annualised Return | 32.32% |
| Volatility | 13.32% |
| Beta | -0.023 |
| Sharpe Ratio | 2.43 |
| Sortino Ratio | -1.005 |
| Maximum Drawdown | N/A |
| Win Rate | 28% |
V. FULL PYTHON CODE
from AlgorithmImports import *
from typing import List, Dict, Tuple
#endregion
class CheapOptionsAreExpensive(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2016, 1, 1)
self.SetCash(1000000)
self.min_expiry: int = 35
self.max_expiry: int = 60
self.leverage: int = 5
self.min_share_price: int = 5
self.subscribed_contracts_treshold: int = 10
self.quantile: int = 10
self.subscribed_contracts: Dict[str, Symbol] = {}
self.tickers_symbols: Dict[str, Symbol] = {}
self.day: int = -1
self.fundamental_count: int = 100
self.fundamental_sorting_key = lambda x: x.DollarVolume
self.opened_position_with_expiry: List[Tuple[Symbol, Symbol, datetime.date]] = [] # stock symbol, contract symbol, option expiry
self.settings.daily_precise_end_time = False
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
self.UniverseSettings.Resolution = Resolution.Minute
self.AddUniverse(self.FundamentalSelectionFunction)
self.SetSecurityInitializer(lambda x: x.SetDataNormalizationMode(DataNormalizationMode.Raw))
self.UniverseSettings.DataNormalizationMode = DataNormalizationMode.Raw
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]:
# rebalance on contract expirations
if len(self.tickers_symbols) != 0:
return Universe.Unchanged
# select top n stocks by dollar volume with price higher than 5
selected: List[Fundamental] = [
x for x in fundamental
if x.HasFundamentalData
and x.Market == 'usa'
and x.Price > self.min_share_price
]
if len(selected) > self.fundamental_count:
selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]
self.tickers_symbols = {x.Symbol.Value: x.Symbol for x in selected}
# return symbols of selected stocks
return [x.Symbol for x in selected]
def OnData(self, data: Slice) -> None:
# on next DataBar(1 minute after subscription) after contracts subscription trade selected contracts and their stocks
if len(self.subscribed_contracts) >= self.subscribed_contracts_treshold:
stock_prices: Dict[str, float] = {} # storing stock prices keyed by atm call contracts
for ticker, contract_symbol in self.subscribed_contracts.items():
if ticker in self.tickers_symbols:
stock_symbol: Symbol = self.tickers_symbols[ticker]
# make sure, there are DataBars for stock and atm contract
if stock_symbol in data and data[stock_symbol] and contract_symbol in data and data[contract_symbol]:
underlying_price: float = data[stock_symbol].Value
# store stock underlying price keyed by ticker
stock_prices[ticker] = underlying_price
traded: bool = False
# make sure, there are enough data for quantile selection
if len(stock_prices) >= self.quantile:
# sorting by underlying stock price
quantile: int = int(len(stock_prices) / self.quantile)
sorted_by_price = sorted(stock_prices.items(), key = lambda x: x[1], reverse=True)
long: List[Symbol] = sorted_by_price[:quantile]
short: List[Symbol] = sorted_by_price[-quantile:]
long_w: float = 1 / len(long)
short_w: float = 1 / len(short)
for ticker, price in long:
# retrieve atm contract symbol based on ticker
contract_symbol: Symbol = self.subscribed_contracts[ticker]
# retrieve stock symbol based on ticker
stock_symbol: Symbol = self.tickers_symbols[ticker]
equity: float = self.Portfolio.TotalPortfolioValue * long_w
options_q: int = int(equity / (price * 100))
# buy contract
self.Securities[contract_symbol].MarginModel = BuyingPowerModel(2)
if contract_symbol in data and data[contract_symbol] and stock_symbol in data and data[stock_symbol]:
self.Buy(contract_symbol, options_q)
self.Sell(stock_symbol,options_q * 50) # initial delta hedge
self.opened_position_with_expiry.append((stock_symbol, contract_symbol, contract_symbol.ID.Date.date()))
traded = True
for ticker, price in short:
# retrieve atm contract symbol based on ticker
contract_symbol = self.subscribed_contracts[ticker]
# retrieve stock symbol based on ticker
stock_symbol = self.tickers_symbols[ticker]
equity = self.Portfolio.TotalPortfolioValue * short_w
options_q = int(equity / (price * 100))
# sell contract
self.Securities[contract_symbol].MarginModel = BuyingPowerModel(2)
if contract_symbol in data and data[contract_symbol] and stock_symbol in data and data[stock_symbol]:
self.Sell(contract_symbol, options_q)
self.Buy(stock_symbol, options_q * 50) # initial delta hedge
self.opened_position_with_expiry.append((stock_symbol, contract_symbol, contract_symbol.ID.Date.date()))
traded = True
if traded:
# clear dictionary and wait for next selection and contracts subscription
self.subscribed_contracts.clear()
# check if contracts expiries once in a day
if self.day == self.Time.day:
return
self.day = self.Time.day
# positions are opened
if len(self.opened_position_with_expiry) != 0:
positions_to_remove: List[Tuple[Symbol, Symbol, datetime.date]] = []
for opened_position_with_expiry in self.opened_position_with_expiry:
stock_symbol: Symbol = opened_position_with_expiry[0]
contract_symbol: Symbol = opened_position_with_expiry[1]
exp: datetime.date = opened_position_with_expiry[2]
if exp <= self.Time.date():
self.Liquidate(contract_symbol) # liquidate contract
self.Liquidate(stock_symbol) # liquidate hedge
positions_to_remove.append(opened_position_with_expiry)
for pos_to_remove in positions_to_remove:
self.opened_position_with_expiry.remove(pos_to_remove)
if len(self.opened_position_with_expiry) == 0:
# perform next selection of stock
self.tickers_symbols.clear()
self.subscribed_contracts.clear()
return
# subscribe to new contracts, when last one expiries
if not self.Portfolio.Invested:
for _, symbol in self.tickers_symbols.items():
if self.Securities[symbol].IsDelisted:
continue
# subscribe to contract
contracts: List[Symbol] = self.OptionChainProvider.GetOptionContractList(symbol, self.Time)
# get current price for stock
underlying_price: float = self.Securities[symbol].Price
# get strikes from stock contracts
strikes: List[float] = [i.ID.StrikePrice for i in contracts]
# check if there is at least one strike
if len(strikes) <= 0:
continue
# at the money
atm_strike: float = min(strikes, key=lambda x: abs(x-underlying_price))
# filtred contracts based on option rights and strikes
atm_calls: List[Symbol] = [i for i in contracts if i.ID.OptionRight == OptionRight.Call and
i.ID.StrikePrice == atm_strike and
self.min_expiry <= (i.ID.Date - self.Time).days <= self.max_expiry]
# make sure there are enough contracts
if len(atm_calls) > 0:
# sort by expiry
atm_call: Symbol = sorted(atm_calls, key = lambda item: item.ID.Date, reverse=True)[0]
# add contract
option: Option = self.AddOptionContract(atm_call, Resolution.Minute)
option.SetDataNormalizationMode(DataNormalizationMode.Raw)
# store subscribed atm call contract keyed by it's ticker
self.subscribed_contracts[atm_call.Underlying.Value] = atm_call
else:
if len(self.opened_position_with_expiry) == 0:
self.Liquidate()
# Custom fee model.
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters: OrderFeeParameters) -> OrderFee:
fee: float = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))