| Overall Statistics |
|
Total Orders 77 Average Win 0.21% Average Loss -0.25% Compounding Annual Return -1.275% Drawdown 0.600% Expectancy -0.165 Start Equity 1000000 End Equity 994565 Net Profit -0.544% Sharpe Ratio -17.16 Sortino Ratio -7.403 Probabilistic Sharpe Ratio 0.000% Loss Rate 55% Win Rate 45% Profit-Loss Ratio 0.85 Alpha -0.064 Beta -0 Annual Standard Deviation 0.004 Annual Variance 0 Information Ratio -1.488 Tracking Error 0.112 Treynor Ratio 375.908 Total Fees $68.00 Estimated Strategy Capacity $260000.00 Lowest Capacity Asset PG YMQUHZ6C29JA|PG R735QTJ8XC9X Portfolio Turnover 0.46% |
from AlgorithmImports import *
from datetime import timedelta
import numpy as np
from volatility_utils import VolatilityCalculator
from options_utils import OptionsManager
from trade_manager import TradeManager
"""
This strategy exploits earnings-related IV crush through ATM calendar
spreads, entering positions when both IV term structure is abnormally
steep and IV/RV ratio indicates overpricing before announcements.
By selling front-month options against back-month options at identical
strikes, it capitalizes on the asymmetric volatility compression between
expirations, generating profits regardless of moderate price movement
in the underlying. Primary risk includes gap moves exceeding expected
range and abnormal post-earnings IV behavior.
Backtest Author : u/shock_and_awful (reddit)
Strategy Creator : @VolatilityVibes (youtube)
"""
class EarningsVolatilityCalendarSpread(QCAlgorithm):
def initialize(self):
"""
Initialize the algorithm with settings, schedules, and variables.
Sets up the earnings data source, universe settings, and scheduling for precise entry/exit timing.
"""
self.set_start_date(2024, 6, 1)
self.set_end_date(2024, 11, 3)
self.set_cash(1000000)
self.set_security_initializer(BrokerageModelSecurityInitializer(self.brokerage_model, FuncSecuritySeeder(self.get_last_known_prices)))
# Initialize utility classes
self.volatility = VolatilityCalculator(self)
self.options = OptionsManager(self)
self.trade_manager = TradeManager(self)
# Local Enums for Earning report time.
# TODO: Will change later and replace with official LEAN ENUMS
# Important because we dont know if LEAN expects 1 for BMO
self.IS_BMO = 0 # Before Market Open
self.IS_AMC = 1 # After Market Close
# Add earnings data source to track upcoming corporate earnings
self.add_universe(EODHDUpcomingEarnings, self.selection)
# Set universe settings for data resolution and normalization
self.universe_settings.resolution = Resolution.MINUTE
self.universe_settings.data_normalization_mode = DataNormalizationMode.RAW
# Initialize variables to track earnings
self.earnings_schedule = {} # Will store: {symbol: (date, is_amc)}
# Define threshold values from the technical specification
self.threshold_volume = 1500000 # Minimum average daily volume requirement
self.threshold_iv_rv_ratio = 1.25 # Minimum IV30/RV30 ratio requirement
self.threshold_ts_slope = -0.00406 # Maximum term structure slope requirement
self.risk_per_trade = 0.01 # 1% of portfolio per trade for position sizing
# Define a symbol for scheduling (needed for market hours reference)
self.schedule_symbol = Symbol.Create("SPY", SecurityType.EQUITY, Market.USA)
self.add_equity("SPY", Resolution.MINUTE)
# Schedule entry evaluation 15 minutes before market close
self.schedule.on(
self.date_rules.every_day(self.schedule_symbol),
self.time_rules.before_market_close(self.schedule_symbol, 15),
self.evaluate_entry_conditions
)
# Schedule position closing 15 minutes after market open
self.schedule.on(
self.date_rules.every_day(self.schedule_symbol),
self.time_rules.after_market_open(self.schedule_symbol, 15),
self.close_positions
)
def selection(self, earnings: List[EODHDUpcomingEarnings]) -> List[Symbol]:
"""
Filter stocks with upcoming earnings within the next 4 days.
Args:
earnings: List of upcoming earnings announcements
Returns:
List of symbols that meet the selection criteria
"""
# Look ahead 4 days to handle weekends properly
selected_symbols = []
# List of allowed symbols
allowed_symbols = ["AAPL", "NVDA", "MSFT", "AMZN", "META", "GOOGL", "AVGO",\
"GOOG", "TSLA", "JPM", "LLY", "V", "XOM", "UNH", "MA", \
"NFLX", "COST", "JNJ", "PG", "WMT", "ABBV", "ADBE"]
for earning in earnings:
# Only process allowed symbols
if earning.symbol.value not in allowed_symbols:
continue
if earning.report_date <= self.time + timedelta(days=4):
# Store both the report date and whether it's AMC (1) or BMO (0)
is_amc = earning.report_time == self.IS_AMC
self.earnings_schedule[earning.symbol] = (earning.report_date, is_amc)
selected_symbols.append(earning.symbol)
return selected_symbols
def on_securities_changed(self, changes: SecurityChanges) -> None:
"""
Handle changes to the securities universe.
Adds options for new securities without liquidating positions for removed securities.
Args:
changes: Securities added or removed from the universe
"""
# Add option chains for newly added securities
for added in [security for security in changes.added_securities if security.type == SecurityType.EQUITY]:
self.log(f"Added security {added.symbol} to universe")
# Add option contracts for the equity
option = self.add_option(added.symbol)
# Set filter for option contracts - wider range to ensure finding suitable contracts
option.set_filter(lambda u: (u.strikes(-2, +2).expiration(0, 60)))
def evaluate_entry_conditions(self):
"""
Evaluates entry conditions for calendar spreads 15 minutes before market close.
This is the core strategy logic that checks all predictor variables against thresholds.
"""
self.log(f"[3:45 PM] Evaluating entry conditions at {self.time}")
# Track symbols that we've processed and can remove from earnings schedule
symbols_to_remove = []
# Check securities with earnings tomorrow
for underlying_symbol, earnings_info in list(self.earnings_schedule.items()):
earnings_date, is_amc = earnings_info
# Only trade if earnings are tomorrow
if earnings_date.date() != (self.time + timedelta(days=1)).date():
continue
self.log(f"[{underlying_symbol} Entry Check] - Earnings on {earnings_date} {'AMC' if is_amc else 'BMO'}")
# Check if underlying is tradable and has valid price
if not self.options.is_underlying_valid(underlying_symbol):
# If we've decided not to enter, we can remove this from tracking
symbols_to_remove.append(underlying_symbol)
continue
# Get option contracts for possible calendar spread
atm_strike, expiry_dict = self.options.get_atm_options(underlying_symbol)
if atm_strike is None or expiry_dict is None:
symbols_to_remove.append(underlying_symbol)
continue
# Find suitable expiration pairs for calendar spread
expiry_pair = self.options.find_expiry_pair(expiry_dict)
if expiry_pair is None:
symbols_to_remove.append(underlying_symbol)
continue
near_term_days, longer_term_days = expiry_pair
# Check average volume (least expensive calculation first)
avg_volume = self.volatility.calculate_average_volume(underlying_symbol)
if avg_volume is None or avg_volume <= self.threshold_volume:
symbols_to_remove.append(underlying_symbol)
continue
# Get term structure data
days_array, iv_array = self.volatility.calculate_term_structure(expiry_dict)
# Calculate term structure slope
ts_slope_result = self.volatility.calculate_term_structure_slope(days_array, iv_array)
if ts_slope_result is None or ts_slope_result[0] >= self.threshold_ts_slope:
symbols_to_remove.append(underlying_symbol)
continue
ts_slope, term_structure = ts_slope_result
# Calculate realized volatility and IV/RV ratio using the already computed term structure
iv_rv_ratio = self.volatility.calculate_iv_rv_ratio(underlying_symbol, term_structure)
if iv_rv_ratio is None or iv_rv_ratio <= self.threshold_iv_rv_ratio:
symbols_to_remove.append(underlying_symbol)
continue
# Log predictor variables
self.log(
f"....Term Slope: {round(ts_slope,5)}, "
f"IV/RV: {round(iv_rv_ratio,2)}, "
f"Avg Vol: {round(avg_volume,0)}"
)
# All conditions met, select contracts for calendar spread
self.log(f"....All entry conditions met for {underlying_symbol}")
# Select appropriate option contracts
near_term_contract, longer_term_contract = self.options.select_option_contracts(
expiry_dict, near_term_days, longer_term_days, OptionRight.CALL)
if not near_term_contract or not longer_term_contract:
symbols_to_remove.append(underlying_symbol)
continue
# Calculate position size
position_size = self.options.calculate_position_size(
near_term_contract, longer_term_contract, self.risk_per_trade)
# Create calendar spread trade
trade_success = self.trade_manager.enter_calendar_spread(
underlying_symbol, near_term_contract, longer_term_contract, position_size)
# If we didn't enter a trade, add to removal list
if not trade_success:
symbols_to_remove.append(underlying_symbol)
# Clean up earnings schedule for symbols we've processed and decided not to trade
for symbol in symbols_to_remove:
if symbol in self.earnings_schedule:
self.log(f"Removing {symbol} from earnings schedule - no trade entered")
del self.earnings_schedule[symbol]
def close_positions(self):
"""
Close positions 15 minutes after market open for stocks that had earnings.
Handles different timing for BMO (Before Market Open) and AMC (After Market Close) reports.
"""
self.log(f"[9:45 AM] Checking for open positions to close at {self.time}")
# Process exit logic based on earnings timing
closed_symbols = self.trade_manager.process_earnings_exit(self.earnings_schedule)
# Remove closed symbols from earnings schedule
for symbol in closed_symbols:
if symbol in self.earnings_schedule:
del self.earnings_schedule[symbol]
self.log(f"....Removed {symbol} from earnings schedule after position closed")
def on_order_event(self, order_event):
# Order event handling (if needed)
pass from AlgorithmImports import *
class OptionsManager:
"""
Handles all options-related functionality including finding ATM options,
selecting expiration pairs, and building option positions.
"""
def __init__(self, algorithm):
"""
Initialize with a reference to the main algorithm.
Args:
algorithm: The main algorithm instance for accessing options chains and other methods
"""
self.algorithm = algorithm
def get_atm_options(self, symbol):
"""
Get the ATM option contracts for the symbol.
Args:
symbol: The underlying security symbol
Returns:
tuple: (atm_strike, expiry_dict) or (None, None) if not found
"""
# Get option chain
option_chain = self.algorithm.option_chain(symbol)
if option_chain is None or not list(option_chain):
self.algorithm.log(f"....No option chain available for {symbol}")
return None, None
underlying_price = self.algorithm.securities[symbol].price
# Find ATM strike - get the strike closest to current price
strikes = [contract.strike for contract in option_chain]
atm_strike = min(strikes, key=lambda x: abs(x - underlying_price))
# Get all contracts at ATM strike
atm_contracts = [contract for contract in option_chain if contract.strike == atm_strike]
# Separate by expiration dates
expiry_dict = {}
for contract in atm_contracts:
days_to_expiry = (contract.expiry - self.algorithm.time).days
if days_to_expiry not in expiry_dict:
expiry_dict[days_to_expiry] = []
expiry_dict[days_to_expiry].append(contract)
# Need at least two different expiration dates
if len(expiry_dict) < 2:
# self.algorithm.log(f"Not enough expiration dates for {symbol}")
return None, None
return atm_strike, expiry_dict
def find_expiry_pair(self, expiry_dict):
"""
Find suitable pair of expirations for a calendar spread.
Args:
expiry_dict: Dictionary of option contracts by days to expiry
Returns:
tuple: (near_term_days, longer_term_days) or None if not found
"""
# Find near-term and longer-term expirations
expiry_days = sorted(expiry_dict.keys())
near_term_days = expiry_days[0]
# Find expiration ~30 days later than nearest (as specified in technical doc)
target_days = near_term_days + 30
longer_term_days = None
for d in expiry_days:
if d > near_term_days:
if longer_term_days is None or abs(d - target_days) < abs(longer_term_days - target_days):
longer_term_days = d
if longer_term_days is None:
# self.algorithm.log(f"No suitable longer-term expiration")
return None
return near_term_days, longer_term_days
def select_option_contracts(self, expiry_dict, near_term_days, longer_term_days, right=OptionRight.CALL):
"""
Select option contracts for the given expirations and option right.
Args:
expiry_dict: Dictionary of option contracts by days to expiry
near_term_days: Days to expiry for near-term option
longer_term_days: Days to expiry for longer-term option
right: Option right (CALL or PUT)
Returns:
tuple: (near_term_contract, longer_term_contract) or (None, None) if not found
"""
# Select ATM contracts for the two expirations with the specified right
near_term_options = [c for c in expiry_dict[near_term_days] if c.right == right]
longer_term_options = [c for c in expiry_dict[longer_term_days] if c.right == right]
if not near_term_options or not longer_term_options:
return None, None
# Select the contracts
near_term_contract = near_term_options[0]
longer_term_contract = longer_term_options[0]
return near_term_contract, longer_term_contract
def calculate_position_size(self, near_term_contract, longer_term_contract, risk_per_trade):
"""
Calculate position size based on risk percentage of portfolio.
Implements the fixed percentage position sizing from the technical specification.
Args:
near_term_contract: The near-term option contract
longer_term_contract: The longer-term option contract
risk_per_trade: Percentage of portfolio to risk per trade
Returns:
Number of contracts to trade
"""
# Check if we should just return 1 contract
if (self.algorithm.get_parameter("justOneContract", 0)) == 1:
return 1
# Get prices
near_term_price = near_term_contract.bid_price
longer_term_price = longer_term_contract.ask_price
# Calculate net debit for the calendar spread
net_debit = longer_term_price - near_term_price
# Calculate maximum risk based on portfolio value
max_risk = self.algorithm.portfolio.cash * risk_per_trade
# Calculate position size
if net_debit <= 0:
# If net credit, use minimum size
return 1
position_size = max(1, int(max_risk / (net_debit * 100))) # multiply by 100 for option contract multiplier
return position_size
def is_underlying_valid(self, symbol):
"""
Check if the underlying security is valid and has a price.
Args:
symbol: The underlying security symbol
Returns:
bool: True if valid, False otherwise
"""
if symbol not in self.algorithm.securities:
self.algorithm.log(f"....{symbol} not in securities collection")
return False
underlying_price = self.algorithm.securities[symbol].price
if underlying_price == 0:
self.algorithm.log(f"....Invalid price for {symbol}")
return False
return True from AlgorithmImports import *
from datetime import timedelta
class TradeManager:
"""
Handles trade execution, position tracking, and order management
for the earnings volatility calendar spread strategy.
"""
def __init__(self, algorithm):
"""
Initialize with a reference to the main algorithm.
Args:
algorithm: The main algorithm instance for accessing trading methods
"""
self.algorithm = algorithm
self.trades = {} # Will store: {symbol: (near_term_symbol, long_term_symbol, position_size)}
def enter_calendar_spread(self, symbol, near_term_contract, longer_term_contract, position_size):
"""
Enter a calendar spread trade for the given symbol.
Args:
symbol: The underlying security symbol
near_term_contract: The near-term option contract
longer_term_contract: The longer-term option contract
position_size: Number of contracts to trade
Returns:
bool: True if the trade was entered successfully
"""
if not near_term_contract or not longer_term_contract:
self.algorithm.log(f"....Could not find suitable option contracts for {symbol}")
return False
# Add option contracts before trading them
near_term_contract_symbol = self.algorithm.add_option_contract(near_term_contract).symbol
longer_term_contract_symbol = self.algorithm.add_option_contract(longer_term_contract).symbol
# Place orders for calendar spread
# Sell near-term, buy longer-term (long calendar spread)
near_term_order = self.algorithm.sell(near_term_contract_symbol, position_size)
longer_term_order = self.algorithm.buy(longer_term_contract_symbol, position_size)
# Store trade information for later reference and exit
self.trades[symbol] = (near_term_contract.symbol, longer_term_contract.symbol, position_size)
self.algorithm.log(f"....Entered calendar spread for {symbol}: ")
self.algorithm.log(f"....Sold {position_size} of {near_term_contract.symbol}")
self.algorithm.log(f"....Bought {position_size} of {longer_term_contract.symbol}")
return True
def close_positions_for_symbol(self, symbol):
"""
Close positions for a specific symbol.
Args:
symbol: The underlying symbol to close positions for
Returns:
bool: True if positions were closed, False if no positions found
"""
if symbol not in self.trades:
return False
near_term_symbol, longer_term_symbol, position_size = self.trades[symbol]
# Close positions - buy back what we sold, sell what we bought
self.algorithm.buy(near_term_symbol, position_size)
self.algorithm.sell(longer_term_symbol, position_size)
self.algorithm.log(f"....Closed position for {symbol} after earnings")
# Remove from active trades
del self.trades[symbol]
return True
def close_all_positions(self):
"""
Close all open positions.
Returns:
int: Number of positions closed
"""
positions_closed = 0
for symbol in list(self.trades.keys()):
if self.close_positions_for_symbol(symbol):
positions_closed += 1
return positions_closed
def process_earnings_exit(self, earnings_schedule):
"""
Process exit logic for positions based on earnings reports.
Args:
earnings_schedule: Dictionary mapping symbols to earnings info (date, is_amc)
Returns:
list: Symbols for which positions were closed
"""
closed_symbols = []
for underlying_symbol, trade_info in list(self.trades.items()):
if underlying_symbol not in earnings_schedule:
continue
earnings_info = earnings_schedule.get(underlying_symbol)
if earnings_info is None:
continue
earnings_date, is_amc = earnings_info
should_exit = False
# Check if we should exit based on earnings timing (BMO/AMC)
if is_amc:
# For AMC earnings, exit the morning after the earnings date
if earnings_date.date() < self.algorithm.time.date():
should_exit = True
self.algorithm.log(f"....{underlying_symbol} Exited - AMC earnings on {earnings_date.date()} (previous day or earlier)")
else:
# For BMO earnings, exit the same morning
if earnings_date.date() == self.algorithm.time.date():
should_exit = True
self.algorithm.log(f"....{underlying_symbol} Exited - BMO earnings today {earnings_date.date()}")
if not should_exit:
continue
if self.close_positions_for_symbol(underlying_symbol):
closed_symbols.append(underlying_symbol)
return closed_symbols
def get_active_trades(self):
"""
Get list of active trades.
Returns:
dict: Dictionary of active trades
"""
return self.trades import numpy as np
from scipy.interpolate import interp1d
from AlgorithmImports import *
class VolatilityCalculator:
"""
Handles all volatility and term structure related calculations for the strategy.
"""
def __init__(self, algorithm):
"""
Initialize with a reference to the main algorithm.
Args:
algorithm: The main algorithm instance for accessing history and other methods
"""
self.algorithm = algorithm
def build_term_structure(self, days, ivs):
"""
Build term structure using linear interpolation to estimate IV at any DTE.
Args:
days: Array of days to expiration
ivs: Array of implied volatilities corresponding to each expiration
Returns:
Function that interpolates IV for any given days to expiration
"""
# Sort by days to ensure proper ordering
sort_idx = days.argsort()
days_sorted = days[sort_idx]
ivs_sorted = ivs[sort_idx]
# Create interpolation function
spline = interp1d(days_sorted, ivs_sorted, kind='linear', fill_value="extrapolate")
def term_spline(dte):
if dte < days_sorted[0]:
return ivs_sorted[0]
elif dte > days_sorted[-1]:
return ivs_sorted[-1]
else:
return float(spline(dte))
return term_spline
def calculate_term_structure(self, expiry_dict):
"""
Calculate term structure data from option expirations.
Args:
expiry_dict: Dictionary of option contracts by days to expiry
Returns:
tuple: (days_array, iv_array)
"""
days_array = np.array(sorted(expiry_dict.keys()))
# Calculate average IV for each expiration (combining calls and puts)
iv_array = np.array([
np.mean([c.implied_volatility for c in expiry_dict[days]])
for days in days_array
])
return days_array, iv_array
def calculate_term_structure_slope(self, days_array, iv_array):
"""
Calculate the slope of the IV term structure.
Args:
days_array: Array of days to expiration
iv_array: Array of implied volatilities
Returns:
tuple: (slope_value, term_structure_function)
"""
# Create interpolation function for IV term structure
term_structure = self.build_term_structure(days_array, iv_array)
# Get IV at near term and at 45 days
near_term_days = days_array[0]
near_term_iv = term_structure(near_term_days)
iv45 = term_structure(45)
# Calculate term structure slope
ts_slope = (iv45 - near_term_iv) / (45 - near_term_days)
return ts_slope, term_structure
def calculate_realized_volatility(self, symbol):
"""
Calculate realized volatility using Yang-Zhang method.
This is a more accurate volatility measurement that accounts for overnight jumps.
Args:
symbol: Stock symbol to calculate volatility for
Returns:
Annualized realized volatility or None if calculation fails
"""
# Get price history
history = self.algorithm.history(symbol, 30, Resolution.DAILY)
if history.empty or len(history) < 30:
return None
# Extract OHLC prices
open_prices = history['open'].values
high_prices = history['high'].values
low_prices = history['low'].values
close_prices = history['close'].values
# Calculate components for Yang-Zhang volatility
n = len(close_prices)
close_to_close = np.log(close_prices[1:] / close_prices[:-1])
open_to_open = np.log(open_prices[1:] / open_prices[:-1])
log_hl = np.log(high_prices[1:] / low_prices[1:])
# Calculate overnight volatility (close to open)
overnight_vol = np.sum(np.square(np.log(open_prices[1:] / close_prices[:-1]))) / (n - 1)
# Calculate open to close volatility
open_close_vol = np.sum(np.square(np.log(close_prices[1:] / open_prices[1:]))) / (n - 1)
# Calculate Rogers-Satchell volatility
log_ho = np.log(high_prices[1:] / open_prices[1:])
log_lo = np.log(low_prices[1:] / open_prices[1:])
log_co = np.log(close_prices[1:] / open_prices[1:])
rs_vol = np.sum(log_ho * (log_ho - log_co) + log_lo * (log_lo - log_co)) / (n - 1)
# Calculate k factor
k = 0.34 / (1.34 + (n + 1) / (n - 1))
# Combine the components for Yang-Zhang volatility
yang_zhang_vol = overnight_vol + k * open_close_vol + (1 - k) * rs_vol
# Convert to annualized volatility (assuming 252 trading days per year)
annualized_vol = np.sqrt(yang_zhang_vol * 252)
return annualized_vol
def calculate_average_volume(self, symbol):
"""
Calculate 30-day average trading volume for a symbol.
Used as one of the three predictor variables.
Args:
symbol: Stock symbol to calculate average volume for
Returns:
30-day average volume or None if calculation fails
"""
history = self.algorithm.history(symbol, 30, Resolution.DAILY)
if history.empty or len(history) < 30:
return None
return history['volume'].mean()
def calculate_iv_rv_ratio(self, symbol, term_structure):
"""
Calculate the IV/RV ratio for a given symbol
Args:
symbol: The underlying security symbol
term_structure: The precomputed term structure function
Returns:
float: IV/RV ratio or None if calculation failed
"""
# Calculate 30-day realized volatility using Yang-Zhang method
rv30 = self.calculate_realized_volatility(symbol)
if rv30 is None or rv30 == 0:
self.algorithm.log(f"....Could not calculate realized volatility for {symbol}")
return None
# Get implied volatility for 30 days from the provided term structure
iv30 = term_structure(30)
iv_rv_ratio = iv30 / rv30
return iv_rv_ratio