| Overall Statistics |
|
Total Orders 1452 Average Win 3.67% Average Loss -2.35% Compounding Annual Return 109.830% Drawdown 23.700% Expectancy 0.175 Start Equity 10000 End Equity 123520.8 Net Profit 1135.208% Sharpe Ratio 2.258 Sortino Ratio 0.82 Probabilistic Sharpe Ratio 91.602% Loss Rate 54% Win Rate 46% Profit-Loss Ratio 1.56 Alpha 0.711 Beta -0.019 Annual Standard Deviation 0.314 Annual Variance 0.099 Information Ratio 1.813 Tracking Error 0.349 Treynor Ratio -36.885 Total Fees $2906.20 Estimated Strategy Capacity $240000.00 Lowest Capacity Asset SPXW YVUTWL1SJQMM|SPX 31 Portfolio Turnover 1.56% Drawdown Recovery 200 |
"""
SPX Opening Range Breakout (ORB) Options Strategy
----------------------------------
Quantish.io | Quantish.substack.com
This algorithm implements a 60-minute opening range breakout strategy on SPX options.
The strategy identifies the price range during the first hour of trading (9:30-10:30 AM)
and trades breakouts above or below this range using credit spreads.
Strategy Logic:
- Upside Breakout: Sell bull put spread with short strike at opening range low
- Downside Breakout: Sell bear call spread with short strike at opening range high
- Uses $15 wide spreads, holds to expiration
- Filters: Opening range must be >= 0.2% of SPX price), no FOMC days, no trades after 12pm
"""
from AlgorithmImports import *
class SPXORBCreditSpreads(QCAlgorithm):
def initialize(self):
# Initialize backtest
self.set_start_date(2022, 5, 1)
self.set_cash(10000)
self.set_security_initializer(lambda security: security.set_market_price(self.get_last_known_price(security)))
# Setup SPX index and options
index = self.add_index("SPX", Resolution.MINUTE).symbol
option = self.add_index_option(index, "SPXW", Resolution.MINUTE)
option.set_filter(lambda x: x.include_weeklys().strikes(-50, 10).expiration(0, 0))
self.spxw = option.symbol
self.spx = index
self.tickets = []
self.spread_map = {} # symbol -> spread_id mapping
# Entry Parameters, with default values
self.spread_width = int(self.get_parameter("spread_width", 15))
self.trade_bearish = 1 == int(self.get_parameter("trade_bearish", 1))
self.trade_bullish = 1 == int(self.get_parameter("trade_bullish", 1))
self.skip_fridays = 1 == int(self.get_parameter("skip_fridays", 1))
self.skip_wednesdays = 1 == int(self.get_parameter("skip_wednesdays", 1))
self.mon_tue_only = 1 == int(self.get_parameter("mon_tue_only", 0))
# Additional Risk management parameters
self.fixed_order_size = int(self.get_parameter("fixed_order_size", 0))
self.pct_risk = float(self.get_parameter("pct_risk", .2)) # used if order size is 0
self.min_range_threshold = 0.002 # Minimum range threshold (0.2% of underlying price)
# Opening range variables
self.opening_range_minutes = 60 # Use 60-minute opening range
self.opening_range_high = None
self.opening_range_low = None
self.range_established = False
self.breakout_traded = False
self.daily_reset_done = False
# Get FOMC dates
self.fomc_dates = self.get_fomc_dates()
self.log("SPX ORB Options Strategy Initialized")
# Main data handler that orchestrates the entire trading logic flow
# Processes each data tick to update opening range, check for breakouts, and execute trades
# Coordinates daily resets, range establishment, and breakout detection
# -----------------------------------------------------------------------------------------
def on_data(self, slice: Slice) -> None:
# Early exit conditions
if not self._should_process_data(slice):
return
current_time = self.time
current_price = slice.bars[self.spx].close
# Handle daily resets
self._handle_daily_resets(current_time)
# Update opening range during establishment period
self._update_opening_range(current_time, current_price)
# Check if we should trade breakouts
if not self._can_trade_breakout(current_time):
return
# Check for and execute breakout trades
self._check_and_execute_breakouts(slice, current_price)
# Validate whether current data tick should be processed for trading
# Checks algorithm state, day filters, FOMC exclusions, and data availability
# Returns false to skip processing if any exclusion criteria are met
# ---------------------------------------------------------------------------
def _should_process_data(self, slice: Slice) -> bool:
if self.is_warming_up:
self.log("Algorithm is warming up, skipping data")
return False
## TEMP CHECK
if self.mon_tue_only and (self.time.weekday() not in [0,1]):
return False
if self.skip_fridays and self.time.weekday() == 4:
return False
if self.skip_wednesdays and self.time.weekday() == 2:
return False
# Skip FOMC meeting days
if self.time.strftime("%Y-%m-%d") in self.fomc_dates:
# self.log(f"FOMC meeting day {self.time.strftime('%Y-%m-%d')}, skipping trading")
return False
# Check if SPX data is available
if self.spx not in slice or slice[self.spx] is None:
# self.log("SPX data not available in slice")
return False
return True
# Reset daily trading variables at market open and end of day
# Ensures fresh state for each trading session by clearing opening range data
# Manages daily_reset_done flag to prevent multiple resets on same day
# ---------------------------------------------------------------------------
def _handle_daily_resets(self, current_time) -> None:
# Reset daily variables at market open
if current_time.hour == 9 and current_time.minute == 31 and not self.daily_reset_done:
self.opening_range_high = None
self.opening_range_low = None
self.range_established = False
self.range_is_valid = False
self.breakout_traded = False
self.daily_reset_done = True
# self.log("Daily variables reset for new trading day")
# Reset flag at end of day
if current_time.hour == 16:
self.daily_reset_done = False
# Track and update opening range high/low during establishment period (9:30-10:30 AM)
# Continuously monitors price action to identify the true range bounds
# Automatically finalizes range at 10:30 AM and validates minimum size requirements
# -----------------------------------------------------------------------------------
def _update_opening_range(self, current_time, current_price: float) -> None:
# Establish opening range during first 60 minutes
if (current_time.hour == 9 and current_time.minute >= 30) or \
(current_time.hour == 10 and current_time.minute < 31):
if self.opening_range_high is None:
self.opening_range_high = current_price
self.opening_range_low = current_price
# self.log(f"Starting opening range: {current_price:.2f}")
else:
self.opening_range_high = max(self.opening_range_high, current_price)
self.opening_range_low = min(self.opening_range_low, current_price)
# Mark range as established at 10:30 AM
if current_time.hour == 10 and current_time.minute == 31 and not self.range_established:
self._finalize_opening_range(current_price)
# Complete opening range establishment and validate it meets minimum threshold
# Ensures range is large enough (0.2% of SPX price) to constitute a valid trading signal
# Prevents trading on days with insufficient volatility during opening hour
# --------------------------------------------------------------------------------------
def _finalize_opening_range(self, current_price: float) -> None:
"""Finalize the opening range and validate it meets minimum requirements"""
self.range_established = True
# Check if range meets minimum threshold
if self.opening_range_high and self.opening_range_low:
range_size = self.opening_range_high - self.opening_range_low
min_range_required = current_price * self.min_range_threshold
if range_size < min_range_required:
self.log(f"Range too small: {range_size:.2f} < {min_range_required:.2f}")
self.range_is_valid = False
else:
self.range_is_valid = True
return
# Determine if conditions allow for breakout trading execution
# Validates range establishment, time window (before noon), and position status
# Prevents multiple trades per day and ensures proper timing constraints
# -----------------------------------------------------------------------------
def _can_trade_breakout(self, current_time) -> bool:
# Only trade after range is established and before noon
if not self.range_established or not self.range_is_valid or self.breakout_traded or current_time.hour >= 12:
return False
# Return if open position exists
if any([self.portfolio[x.symbol].invested for x in self.tickets]):
self.log("Existing position found, skipping new trades")
return False
return True
# Monitor for breakout signals and trigger appropriate trade execution
# Detects when price breaks above opening range high or below opening range low
# Initiates corresponding credit spread strategy based on breakout direction
# -----------------------------------------------------------------------------
def _check_and_execute_breakouts(self, slice: Slice, current_price: float) -> None:
# Check for breakout signals
upside_breakout = current_price > self.opening_range_high
downside_breakout = current_price < self.opening_range_low
if upside_breakout and self.trade_bullish:
# self.log(f"Upside breakout detected: {current_price:.2f} > {self.opening_range_high:.2f}")
self.current_spread_id = f"upside_{self.time.strftime('%Y%m%d_%H%M%S')}"
self._execute_upside_breakout_trade(slice)
elif downside_breakout and self.trade_bearish:
# self.log(f"Downside breakout detected: {current_price:.2f} < {self.opening_range_low:.2f}")
self.current_spread_id = f"downside_{self.time.strftime('%Y%m%d_%H%M%S')}"
self._execute_downside_breakout_trade(slice)
# Execute bull put spread when price breaks above opening range high
# Sells put spread with short strike at opening range low (expecting support)
# Uses opening range low as anchor point, assuming it will act as support level
# -----------------------------------------------------------------------------
def _execute_upside_breakout_trade(self, slice: Slice) -> None:
current_price = slice.bars[self.spx].close
# Get option chain
chain = slice.option_chains.get(self.spxw)
if not chain:
self.log("No option chain available for upside breakout")
return
# Get the nearest expiry date of the contracts
expiry = min([x.expiry for x in chain])
# self.log(f"Using expiry: {expiry}, Chain has {len(chain)} contracts")
# Sell short put spread (bullish breakout)
puts = sorted([i for i in chain if i.expiry == expiry and i.right == OptionRight.PUT],
key=lambda x: x.strike)
if len(puts) < 2:
self.log(f"Insufficient puts available: {len(puts)}")
return
# self.log(f"Available put strikes: {[p.strike for p in puts[:5]]}...{[p.strike for p in puts[-5:]]}")
# Find puts for short strike - range edge
target_short_strike = self.opening_range_low
strike_description = f"Range low (${target_short_strike:.2f})|{current_price:.2f}"
valid_short_puts = puts
if not valid_short_puts:
self.log("No puts available below current price for short strike")
return
short_put = min(valid_short_puts, key=lambda x: abs(x.strike - target_short_strike))
# self.log(f"Selected short put strike: {short_put.strike} (target: {strike_description})")
# Find put closest to $x below short strike
target_long_strike = short_put.strike - self.spread_width
valid_puts = [put for put in puts if put.strike <= target_long_strike]
long_put = max(valid_puts, key=lambda put: put.strike) if valid_puts else None
if long_put:
put_spread = OptionStrategies.bull_put_spread(self.spxw, short_put.strike, long_put.strike, expiry)
order_size = self.calc_position_size(short_put, long_put)
tag = (f"+P{long_put.strike} -P{short_put.strike} | "
f"orb_low={self.opening_range_low:.2f} | orb_high={self.opening_range_high:.2f} | "
f"spx={current_price:.2f}")
self.tickets = self.buy(put_spread, order_size, tag=tag)
# self.log(f"Upside breakout: Sold put spread {long_put.strike}/{short_put.strike}, size: {order_size}")
self.breakout_traded = True
else:
self.log(f"No long put found ${self.spread_width} below short strike {short_put.strike}")
# Execute bear call spread when price breaks below opening range low
# Sells call spread with short strike at opening range high (expecting resistance)
# Uses opening range high as anchor point, assuming it will act as resistance level
# ---------------------------------------------------------------------------------
def _execute_downside_breakout_trade(self, slice: Slice) -> None:
current_price = slice.bars[self.spx].close
# Get option chain
chain = slice.option_chains.get(self.spxw)
if not chain:
self.log("No option chain available for downside breakout")
return
# Get the nearest expiry date of the contracts
expiry = min([x.expiry for x in chain])
# Sell short call spread (bearish breakout)
calls = sorted([i for i in chain if i.expiry == expiry and i.right == OptionRight.CALL],
key=lambda x: x.strike)
if len(calls) < 2:
self.log(f"Insufficient calls available: {len(calls)}")
return
# Find calls for short strike - range edge
target_short_strike = self.opening_range_high
strike_description = f"Range high ${target_short_strike:.2f}"
# valid_short_calls = calls
valid_short_calls = [call for call in calls if call.strike >= target_short_strike]
# For calls, ensure short strike is above current price (OTM for bearish position)
if not valid_short_calls:
self.log("No calls available above current price for short strike")
return
short_call = min(valid_short_calls, key=lambda x: abs(x.strike - target_short_strike))
# Find call closest to $x above short strike
target_long_strike = short_call.strike + self.spread_width
valid_calls = [call for call in calls if call.strike >= target_long_strike]
long_call = min(valid_calls, key=lambda call: call.strike) if valid_calls else None
if long_call:
call_spread = OptionStrategies.bear_call_spread(self.spxw, short_call.strike, long_call.strike, expiry)
order_size = self.calc_position_size(short_call, long_call)
# Enhanced tag creation
tag = (f"+C{long_call.strike} -C{short_call.strike} | "
f"orb_low={self.opening_range_low:.2f} | orb_high={self.opening_range_high:.2f} | "
f"spx={current_price:.2f}")
self.tickets = self.buy(call_spread, order_size, tag=tag)
self.breakout_traded = True
else:
# self.log(f"No long call found ${self.spread_width} above short strike {short_call.strike}")
pass
# Calculate appropriate position size based on risk management parameters
# Uses either fixed order size or percentage-based sizing depending on configuration
# Ensures position sizing aligns with available capital and risk tolerance
# -----------------------------------------------------------------------------------
def calc_position_size(self, short_contract, long_contract):
if self.fixed_order_size > 0:
return self.fixed_order_size
else:
buying_power = self.portfolio.total_portfolio_value - self.portfolio.total_holdings_value
alloc_power = self.pct_risk * buying_power
strike_diff = abs(short_contract.strike - long_contract.strike)
contract_unit = self.securities[short_contract.symbol].contract_unit_of_trade
margin_req = Math.max((strike_diff * contract_unit * 1), 0) # required for one
order_size = math.floor(alloc_power / margin_req)
return order_size
# Return comprehensive set of FOMC meeting dates to avoid trading
# Federal Reserve meetings create unpredictable volatility that disrupts opening range patterns
# Strategy skips these days to avoid adverse market reactions to monetary policy announcements
# --------------------------------------------------------------------------------------------
def get_fomc_dates(self):
return {
"2025-06-18", "2025-05-07", "2025-03-19", "2025-01-29",
"2024-12-18", "2024-11-07", "2024-09-18", "2024-07-31",
"2024-06-12", "2024-05-01", "2024-03-20", "2024-01-31",
"2023-12-13", "2023-11-01", "2023-09-20", "2023-07-26",
"2023-06-14", "2023-05-03", "2023-03-22", "2023-02-01",
"2022-12-14", "2022-11-02", "2022-09-21", "2022-07-27",
"2022-06-15", "2022-05-04", "2022-03-16", "2021-12-15",
"2021-11-03", "2021-09-22", "2021-07-28", "2021-06-16",
"2021-04-28", "2021-03-17", "2021-01-27", "2020-12-16",
"2020-11-05", "2020-09-16", "2020-07-29", "2020-06-10",
"2020-04-29", "2020-03-31", "2020-03-23", "2020-03-19",
"2020-03-15", "2020-03-03", "2019-10-30", "2019-09-18",
"2019-07-31", "2018-12-19", "2018-09-26", "2018-06-13",
"2018-03-21", "2017-12-13", "2017-06-14", "2017-03-15",
"2016-12-14", "2015-12-16", "2011-06-22", "2008-12-16",
"2008-10-29", "2008-10-08", "2008-09-16", "2008-08-05",
"2008-04-30", "2008-03-18", "2008-03-16", "2008-01-30",
"2008-01-22", "2007-12-11", "2007-10-31", "2007-09-18",
"2007-08-17", "2007-08-07", "2007-06-28", "2007-05-09",
"2007-03-21", "2007-01-31", "2006-12-12", "2006-10-25",
"2006-09-20", "2006-08-08", "2006-06-29", "2006-05-10",
"2006-03-28", "2006-01-31", "2005-12-13", "2005-08-09",
"2005-06-30", "2005-05-03", "2005-03-22", "2005-02-02",
"2004-12-14", "2004-11-10", "2004-09-21", "2004-08-10",
"2004-06-30", "2004-05-04", "2004-03-16", "2004-01-28",
"2003-12-09", "2003-10-28", "2003-09-16", "2003-08-12",
"2003-06-25", "2003-05-06", "2003-03-18", "2003-01-29",
"2002-12-10", "2002-11-06", "2002-09-24", "2002-08-13",
"2002-06-26", "2002-05-07", "2002-03-19", "2002-01-30",
"2001-12-11", "2001-11-06", "2001-10-02", "2001-09-17",
"2001-08-21", "2001-06-27", "2001-05-15", "2001-04-18",
"2001-03-20", "2001-01-31", "2001-01-03", "2000-12-19",
"2000-11-15", "2000-10-03", "2000-08-22", "2000-06-28",
"2000-05-16", "2000-03-21", "2000-02-02"
}
class CustomSlippageModel:
def get_slippage_approximation(self, asset: Security, order: Order) -> float:
# We set a maximum 2% slippage that is linearly linked to the ratio of the order size versus the previous bar's volume.
return asset.price * 0.02 * min(1, order.absolute_quantity / asset.volume)
class CustomSecurityInitializer(BrokerageModelSecurityInitializer):
def __init__(self, brokerage_model, security_seeder):
super().__init__(brokerage_model, security_seeder)
def initialize(self, security):
super().initialize(security)
# To set the slippage model to the custom one for all assets entering the universe.
security.set_slippage_model(CustomSlippageModel())