
The strategy involves selecting delta-neutral call options, long on low-growth options (GO) stocks and short on high-GO stocks. The portfolio is value-weighted, rebalanced monthly, and excludes dividend-paying stocks.
ASSET CLASS: options, stocks | REGION: United States | FREQUENCY:
Monthly | MARKET: equities | KEYWORD: Growth
I. STRATEGY IN A NUTSHELL
Monthly, select near-the-money call options and compute Growth Options (GO) for each stock. Go long delta-neutral calls of low-GO stocks and short delta-neutral calls of high-GO stocks. Portfolios are value-weighted and rebalanced monthly.
II. ECONOMIC RATIONALE
Investors overreact to high-growth firms, overpricing their call options. Low-GO stocks are underappreciated. The strategy exploits persistent mispricings driven by behavioral biases and limited arbitrage in high-growth options.
III. SOURCE PAPER
Firm Growth Potential and Option Returns [Click to Open PDF]
Panayiotis C. Andreou, Cyprus University of Technology; Turan G. Bali, Georgetown University – McDonough School of Business; Anastasios Kagkadis, University of Liverpool Management School; Neophytos Lambertides, Lancaster University – Department of Accounting and Finance; Panayiotis C. Andreou, Cyprus University of Technology
<Abstract>
We find a negative cross-sectional relation between firm growth potential and future returns on delta-hedged equity options. We investigate several economic mechanisms that might drive this result: overpricing due to investors’ speculation on positive jumps or hedging against negative jumps, overpricing due to investors’ chasing high market beta, and neoclassical frameworks that incorporate priced volatility or jump risk. We show that the documented option return predictability is largely driven by retail investors’ overextrapolating the recent positive stock price jumps of growth-oriented firms and hence overpaying for the respective call options. Overall, we provide novel insights into how investors perceive the uncertainties associated with real options.


IV. BACKTEST PERFORMANCE
| Annualised Return | 17.6% |
| Volatility | 10.6% |
| Beta | -0.004 |
| Sharpe Ratio | 1.66 |
| Sortino Ratio | -1.275 |
| Maximum Drawdown | N/A |
| Win Rate | 20% |
V. FULL PYTHON CODE
from AlgorithmImports import *
from numpy import isnan
class GrowthPotentialandOptionsReturns(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2010, 1, 1)
self.SetCash(10_000_000)
self.min_expiry: int = 20
self.max_expiry: int = 30
self.fundamental_count: int = 200
self.fundamental_sorting_key = lambda x: x.DollarVolume
self.subscribed_contracts: Dict[Symbol, Symbol] = {}
self.period: int = 36 * 21
self.percentage_traded: float = .01
self.min_share_price: int = 5
self.quantile: int = 5
self.leverage: int = 40
self.long_symbols: List[Symbol] = []
self.short_symbols: List[Symbol] = []
self.market: Symbol = self.AddEquity('SPY', Resolution.Minute).Symbol
self.market_prices: RollingWindow = RollingWindow[float](self.period)
self.recent_stock_price: Dict[Symbol, float] = {} # whole stock universe recent adjusted prices
self.recent_market_cap: Dict[Symbol, float] = {} # currently selected universe market cap
self.last_expiration_date: Union[None, datetime.date] = None # last expiry date of currently selected traded option universe
self.trade_flag: bool = False
self.selection_flag: bool = False
self.UniverseSettings.Leverage = self.leverage
self.UniverseSettings.Resolution = Resolution.Minute
self.AddUniverse(self.FundamentalSelectionFunction)
self.settings.daily_precise_end_time = False
self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
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.SetMarketPrice(self.GetLastKnownPrice(security))
def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
# store daily recent currently selected universe prices and SPY market price
for stock in fundamental:
symbol: Symbol = stock.Symbol
# store market price history
if symbol == self.market:
self.market_prices.Add(stock.AdjustedPrice)
else:
# store recent stock price
self.recent_stock_price[symbol] = stock.AdjustedPrice
if self.last_expiration_date is not None and not self.trade_flag:
# rebalance after every already traded option expired
if self.last_expiration_date < self.Time.date():
self.last_expiration_date = None
self.Liquidate()
self.selection_flag = True
elif self.last_expiration_date is None:
self.selection_flag = True
# monthly rebalance
if not self.selection_flag:
return Universe.Unchanged
if not self.market_prices.IsReady:
return Universe.Unchanged
selected: List[Fundamental] = [
x for x in fundamental
if x.HasFundamentalData
and x.Market == 'usa'
and x.Price >= self.min_share_price
and x.MarketCap != 0
and not isnan(x.FinancialStatements.CashFlowStatement.OperatingCashFlow.ThreeMonths) and x.FinancialStatements.CashFlowStatement.OperatingCashFlow.ThreeMonths != 0
and not isnan(x.FinancialStatements.BalanceSheet.TotalLiabilitiesAsReported.ThreeMonths) and x.FinancialStatements.BalanceSheet.TotalLiabilitiesAsReported.ThreeMonths != 0
]
GO: Dict[Symbol, float] = {}
for stock in selected:
symbol: Symbol = stock.Symbol
# calculate GO
market_avg_perf: float = pd.Series([x for x in self.market_prices]).pct_change().dropna().mean()
v: float = stock.MarketCap + stock.FinancialStatements.BalanceSheet.TotalLiabilitiesAsReported.ThreeMonths
go_value: float = (v - (stock.FinancialStatements.CashFlowStatement.OperatingCashFlow.ThreeMonths / market_avg_perf))
self.recent_market_cap[symbol] = stock.MarketCap
GO[symbol] = go_value
if len(GO) < self.quantile:
return Universe.Unchanged
quintile: int = int(len(GO) / self.quantile)
sorted_by_go: List[Symbol] = [x[0] for x in sorted(GO.items(), key=lambda x:x[1])]
self.long_symbols = sorted_by_go[:quintile]
self.short_symbols = sorted_by_go[-quintile:]
return self.short_symbols + self.short_symbols
def OnData(self, slice: Slice) -> None:
if self.trade_flag:
self.trade_flag = False
long_count: int = len(self.long_symbols)
short_count: int = len(self.short_symbols)
if long_count == 0 or short_count == 0:
return
long_total_mc: float = sum([self.recent_market_cap[x] for x in self.long_symbols])
short_total_mc: float = sum([self.recent_market_cap[x] for x in self.short_symbols])
# trade execution
for symbol in self.short_symbols + self.long_symbols:
# atm call option contract of current stock might not be subscribed
if symbol not in self.subscribed_contracts:
continue
# get atm call contract based on ticker
atm_call: Symbol = self.subscribed_contracts[symbol]
# make sure subscribed atm call option has data
if slice.contains_key(atm_call) and slice[atm_call] and slice.contains_key(symbol) and slice[symbol]:
stock_price: float = self.recent_stock_price[symbol] if symbol in self.recent_stock_price else .0
if stock_price != 0:
if symbol in self.short_symbols:
# calculate atm call quantity
w: float = self.recent_market_cap[symbol] / long_total_mc
option_quantity: float = (self.Portfolio.TotalPortfolioValue * w * self.percentage_traded) / (stock_price*100)
if option_quantity < 1:
option_quantity = 1
# sell atm call option and hedge position
self.Sell(atm_call, option_quantity)
self.Buy(symbol, option_quantity*50)
else:
# calculate atm call quantity
w: float = self.recent_market_cap[symbol] / short_total_mc
option_quantity: float = (self.Portfolio.TotalPortfolioValue * w * self.percentage_traded) / (stock_price*100)
if option_quantity < 1:
option_quantity = 1
# buy atm call option and hedge position
self.Buy(atm_call, option_quantity)
self.Sell(symbol, option_quantity*50)
expiry: datetime.date = atm_call.ID.Date.date()
self.last_expiration_date = (expiry if expiry > self.last_expiration_date else self.last_expiration_date) if self.last_expiration_date is not None else expiry
# rebalance monthly
if not self.selection_flag:
return
self.selection_flag = False
self.subscribed_contracts.clear()
# subscribe to stocks contracts
for symbol in self.short_symbols + self.long_symbols:
if symbol not in self.recent_stock_price:
continue
# subscribe to contract
contracts: List[Symbol] = self.OptionChainProvider.GetOptionContractList(symbol, self.Time)
# get current price for stock
underlying_price: float = self.recent_stock_price[symbol]
# 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[float] = [
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]
subscriptions = self.SubscriptionManager.SubscriptionDataConfigService.GetSubscriptionDataConfigs(atm_call.Underlying)
if subscriptions:
option: Option = self.AddOptionContract(atm_call, Resolution.Minute, leverage=self.leverage)
option.PriceModel = OptionPriceModels.crank_nicolson_fd()
# store subscribed atm call contract keyed by it's ticker
self.subscribed_contracts[atm_call.Underlying] = atm_call
self.trade_flag = True
if self.Portfolio.Invested:
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"))