Overall Statistics
Total Orders
92572
Average Win
0.11%
Average Loss
-0.02%
Compounding Annual Return
16.188%
Drawdown
6.700%
Expectancy
0.204
Start Equity
10000000
End Equity
41791318.28
Net Profit
317.913%
Sharpe Ratio
1.968
Sortino Ratio
4.355
Probabilistic Sharpe Ratio
100.000%
Loss Rate
83%
Win Rate
17%
Profit-Loss Ratio
6.15
Alpha
0.085
Beta
-0.002
Annual Standard Deviation
0.043
Annual Variance
0.002
Information Ratio
0.001
Tracking Error
0.158
Treynor Ratio
-50.626
Total Fees
$0.00
Estimated Strategy Capacity
$1200000.00
Lowest Capacity Asset
VSS UBJZZ9PBJO85
Portfolio Turnover
109.60%
from AlgorithmImports import *

"""
Opening Range Breakout Universe 

An implementation of the ORB strategy 
Shared by: Quantconnect https://www.quantconnect.com/research/18444/opening-range-breakout-for-stocks-in-play/p1
From research by: Zarattini, Barbon and Aziz.  https://papers.ssrn.com/sol3/papers.cfm?abstract_id=4729284

---

This algorithm implements a classic opening range breakout strategy across a universe of liquid US equities.
The strategy identifies stocks with high relative volume and ATR, then places directional trades based on
breakouts from the opening range (first N minutes of trading).

Key Features:
- Dynamic universe selection based on dollar volume
- Opening range calculation using custom consolidators
- Risk management with stop-loss orders
- Daily liquidation before market close
- Zero commission fee model for backtesting

Strategy Logic:
1. Select top liquid stocks each month
2. Calculate opening range for each stock (default 5 minutes)
3. Identify stocks with high relative volume and ATR
4. Place breakout trades above/below opening range
5. Use stop-loss orders for risk management
6. Liquidate all positions before market close
"""


class OpeningRangeBreakoutUniverseAlgorithm(QCAlgorithm):

    def initialize(self):
        """
        Initialize the algorithm with parameters, universe, and scheduling.
        
        Sets up the trading environment including:
        - Backtest period and capital allocation
        - Zero commission fee model
        - Risk management parameters
        - Universe selection criteria
        - Daily liquidation schedule
        - Indicator warm-up period
        """
        # Backtest configuration
        self.set_start_date(2016, 1, 1)
        # self.set_end_date(2017, 1, 1)  # Uncomment to set end date
        self.set_cash(10_000_000)  # Starting capital: $10M

        # Apply zero commission fee model for accurate backtesting
        self.set_security_initializer(lambda security: security.set_fee_model(ConstantFeeModel(0)))

        # Core strategy parameters
        self.max_positions = 20         # Maximum number of concurrent positions
        self.risk = 0.01               # Risk per position as fraction of portfolio (1%)
        self.entry_gap = 0.1           # Entry buffer as fraction of ATR from breakout level
        
        # Universe and indicator parameters (configurable via algorithm parameters)
        self._universe_size = self.get_parameter("universeSize", 1000)  # Number of stocks in universe
        self._atr_threshold = 0.5      # Minimum ATR value for stock selection
        self._indicator_period = 14    # Period for ATR and volume SMA calculations (days)
        self._opening_range_minutes = self.get_parameter("openingRangeMinutes", 5)  # Opening range duration
        self._leverage = 4             # Maximum leverage for universe securities
        
        # Dictionary to store SymbolData objects for each security
        self._symbol_data_by_symbol = {}

        # Add SPY as benchmark and for scheduling reference
        self._spy = self.add_equity("SPY").symbol

        # Configure universe selection for liquid US equities
        self.universe_settings.leverage = self._leverage
        # Note: Asynchronous universe updates disabled to maintain correct time synchronization
        # self.universe_settings.asynchronous = True  # won't work correct since self.time will be wrong
        self.last_month = None  # Track month changes for universe updates
        self._universe = self.add_universe(self.filter_universe)

        # Schedule daily position liquidation 1 minute before market close
        # This ensures all positions are closed daily as per strategy design
        self.schedule.on(
            self.date_rules.every_day(self._spy),
            self.time_rules.before_market_close(self._spy, 1),
            self.liquidate
        )

        # Warm-up indicators for 2x the indicator period to ensure stability
        self.set_warm_up(timedelta(days=2 * self._indicator_period))

    def filter_universe(self, fundamentals):
        """
        Filter universe to select the most liquid stocks for trading.
        
        Universe selection criteria:
        - Price > $5 (excludes penny stocks)
        - Excludes SPY benchmark
        - Sorted by dollar volume (liquidity)
        - Limited to top N stocks (configurable via universe_size parameter)
        
        Updates monthly to reduce computational overhead while maintaining
        exposure to the most liquid securities.
        
        Args:
            fundamentals: List of fundamental data for all available securities
            
        Returns:
            List of Symbol objects for selected securities, or Universe.UNCHANGED
        """
        # Only update universe selection on the first day of each month
        # This reduces computational load while maintaining exposure to liquid stocks
        if self.time.month == self.last_month:
            return Universe.UNCHANGED

        self.last_month = self.time.month
        
        # Filter and sort securities by dollar volume (liquidity measure)
        # Exclude penny stocks (price < $5) and SPY benchmark
        return [
            f.symbol for f in sorted(
                [f for f in fundamentals if f.price > 5 and f.symbol != self._spy],
                key=lambda f: f.dollar_volume,
                reverse=True  # Highest dollar volume first
            )[:self._universe_size]  # Limit to top N securities
        ]

    def on_securities_changed(self, changes):
        """
        Handle universe changes by initializing SymbolData for new securities.
        
        Creates SymbolData objects for each new security that enters the universe.
        Each SymbolData object manages:
        - Opening range calculation via consolidators
        - ATR and volume indicators
        - Trade entry and exit logic
        - Order management
        
        Args:
            changes: SecurityChanges object containing added/removed securities
        """
        # Initialize SymbolData objects for new securities entering the universe
        for security in changes.added_securities:
            self._symbol_data_by_symbol[security.symbol] = SymbolData(
                self, 
                security, 
                self._opening_range_minutes,
                self._indicator_period
            )

    def on_data(self, slice):
        """
        Main trading logic executed on each data slice.
        
        Strategy execution flow:
        1. Check if opening range period has completed (9:30 AM + opening_range_minutes)
        2. Filter stocks based on selection criteria:
           - Active price > 0
           - In current universe
           - Relative volume > 1 (above average)
           - ATR > threshold (sufficient volatility)
        3. Rank by relative volume and limit to max_positions
        4. Scan each selected stock for breakout opportunities
        
        Args:
            slice: Data slice containing market data for all securities
        """
        # Skip processing during warm-up period or outside trading window
        # Only process after opening range completes (9:30 AM + opening_range_minutes)
        if self.is_warming_up or not (self.time.hour == 9 and self.time.minute == 30 + self._opening_range_minutes):
            return

        # Filter stocks meeting selection criteria and rank by relative volume
        filtered = sorted(
            [
                self._symbol_data_by_symbol[s] for s in self.active_securities.keys
                if self.active_securities[s].price > 0  # Active price check
                and s in self._universe.selected  # In current universe (Note: condition may be redundant)
                and self._symbol_data_by_symbol[s].relative_volume > 1  # Above average volume
                and self._symbol_data_by_symbol[s].ATR.current.value > self._atr_threshold  # Sufficient volatility
            ],
            key=lambda x: x.relative_volume,  # Sort by relative volume (liquidity/interest)
            reverse=True  # Highest relative volume first
        )[:self.max_positions]  # Limit to maximum number of positions

        # Scan each selected stock for breakout trading opportunities
        for symbolData in filtered:
            symbolData.scan()

    def on_order_event(self, orderEvent):
        """
        Handle order events for trade management.
        
        Processes filled orders to trigger follow-up actions:
        - Entry orders trigger stop-loss order placement
        - Maintains order state in corresponding SymbolData objects
        
        Args:
            orderEvent: OrderEvent containing order status and details
        """
        # Only process filled orders
        if orderEvent.status != OrderStatus.FILLED:
            return

        # Forward order events to corresponding SymbolData for trade management
        if orderEvent.symbol in self._symbol_data_by_symbol:
            self._symbol_data_by_symbol[orderEvent.symbol].on_order_event(orderEvent)


"""
SymbolData Class

Manages individual security data and trading logic for the opening range breakout strategy.

Key responsibilities:
- Calculate opening range using time-based consolidators
- Track relative volume using simple moving average
- Maintain ATR indicator for volatility measurement
- Execute breakout trading logic with risk management
- Handle order lifecycle and stop-loss placement

The class encapsulates all security-specific state and logic, allowing the main algorithm
to focus on universe selection and coordination.
"""


class SymbolData:
    def __init__(self, algorithm: QCAlgorithm, security, openingRangeMinutes, indicatorPeriod):
        """
        Initialize SymbolData with indicators and consolidators.
        
        Sets up the infrastructure for tracking:
        - Opening range bars via time-based consolidation
        - ATR indicator for volatility measurement
        - Volume SMA for relative volume calculation
        - Order tickets for trade management
        
        Args:
            algorithm: Reference to main QCAlgorithm instance
            security: Security object for this symbol
            openingRangeMinutes: Duration of opening range in minutes
            indicatorPeriod: Period for ATR and volume SMA calculations
        """
        # Algorithm and security references
        self.algorithm = algorithm
        self.security = security
        
        # Opening range and volume tracking
        self.opening_bar = None      # Current day's opening range bar
        self.relative_volume = 0     # Current relative volume ratio
        
        # Technical indicators
        self.ATR = algorithm.ATR(security.symbol, indicatorPeriod, resolution=Resolution.DAILY)
        self.volumeSMA = SimpleMovingAverage(indicatorPeriod)  # For relative volume calculation
        
        # Order management
        self.stop_loss_price = None   # Calculated stop loss price
        self.entry_ticket = None      # Entry order ticket
        self.stop_loss_ticket = None  # Stop loss order ticket

        # Time-based consolidator for opening range calculation
        # Consolidates bars for the specified opening range duration
        self.consolidator = algorithm.consolidate(
            security.symbol, 
            TimeSpan.from_minutes(openingRangeMinutes), 
            self.consolidation_handler
        )

    def consolidation_handler(self, bar):
        """
        Handle consolidated opening range bars.
        
        Processes each opening range bar to:
        1. Update relative volume calculation
        2. Store the opening bar for breakout analysis
        3. Maintain volume SMA for relative volume tracking
        
        Only processes one opening bar per trading day to avoid multiple
        entries on the same day.
        
        Args:
            bar: Consolidated bar representing the opening range
        """
        # Skip if we already have an opening bar for this trading day
        # This prevents multiple entries on the same day
        if self.opening_bar and self.opening_bar.time.date() == bar.time.date():
            return

        # Calculate relative volume (current volume / average volume)
        # Relative volume > 1 indicates above-average trading interest
        self.relative_volume = (
            bar.volume / self.volumeSMA.current.value 
            if self.volumeSMA.is_ready and self.volumeSMA.current.value > 0 
            else 0
        )
        
        # Update volume SMA with current bar's volume
        self.volumeSMA.update(bar.end_time, bar.volume)
        
        # Store opening bar for breakout analysis
        self.opening_bar = bar

    def scan(self):
        """
        Scan for opening range breakout opportunities.
        
        Implements the core breakout logic:
        1. Analyze opening bar direction (bullish/bearish)
        2. Place breakout trades based on opening range high/low
        3. Set appropriate stop-loss levels using ATR-based buffer
        
        Bullish breakout: Enter long above opening range high
        Bearish breakout: Enter short below opening range low
        
        Only executes if opening bar data is available.
        """
        # Skip if no opening bar data available
        if not self.opening_bar:
            return

        # Determine opening bar direction and place appropriate breakout trade
        if self.opening_bar.close > self.opening_bar.open:
            # Bullish opening bar - place long breakout above opening range high
            # Stop loss set below entry with ATR-based buffer
            self.place_trade(
                self.opening_bar.high,  # Entry price (breakout above high)
                self.opening_bar.high - self.algorithm.entry_gap * self.ATR.current.value  # Stop loss
            )
        elif self.opening_bar.close < self.opening_bar.open:
            # Bearish opening bar - place short breakout below opening range low
            # Stop loss set above entry with ATR-based buffer
            self.place_trade(
                self.opening_bar.low,   # Entry price (breakout below low)
                self.opening_bar.low + self.algorithm.entry_gap * self.ATR.current.value   # Stop loss
            )

    def place_trade(self, entryPrice, stopPrice):
        """
        Calculate position size and place breakout trade with risk management.
        
        Position sizing methodology:
        1. Calculate risk per position based on portfolio allocation
        2. Determine quantity based on entry-to-stop distance
        3. Apply portfolio allocation limits
        4. Place stop market entry order
        
        Risk management:
        - Fixed risk per position (algorithm.risk / max_positions)
        - Stop loss calculated using ATR-based buffer
        - Position size limited by portfolio allocation constraints
        
        Args:
            entryPrice: Price level for trade entry (breakout level)
            stopPrice: Stop loss price level
        """
        # Calculate risk allocation per position
        # Total portfolio risk distributed equally across max positions
        risk_per_position = (
            self.algorithm.portfolio.total_portfolio_value * self.algorithm.risk
        ) / self.algorithm.max_positions
        
        # Calculate position quantity based on risk and stop distance
        # Quantity = Risk Amount / (Entry Price - Stop Price)
        quantity = int(risk_per_position / (entryPrice - stopPrice))

        # Apply portfolio allocation limits using QuantConnect's built-in calculator
        # This ensures we don't exceed reasonable position sizes
        quantity_limit = self.algorithm.calculate_order_quantity(
            self.security.symbol, 
            1 / self.algorithm.max_positions  # Equal allocation across max positions
        )
        
        # Determine position direction and apply quantity limits
        if quantity > 0:
            sign = 1      # Long position
        elif quantity < 0:
            sign = -1     # Short position
        else:
            sign = 0      # No position (should not occur with valid inputs)
            
        # Apply the smaller of calculated quantity or allocation limit
        quantity = int(min(abs(quantity), quantity_limit) * sign)

        # Place trade if quantity is valid
        if quantity != 0:
            # Store stop loss price for later use
            self.stop_loss_price = stopPrice
            
            # Place stop market entry order
            # This will trigger when price breaks through the entry level
            self.entry_ticket = self.algorithm.stop_market_order(
                self.security.symbol, 
                quantity, 
                entryPrice, 
                "Entry"  # Order tag for identification
            )

    def on_order_event(self, orderEvent):
        """
        Handle order events for trade management.
        
        Processes filled entry orders to automatically place corresponding
        stop-loss orders. This ensures every position has risk management
        protection immediately upon entry.
        
        Trade management flow:
        1. Entry order fills
        2. Stop-loss order placed immediately
        3. Position is now protected with automatic exit
        
        Args:
            orderEvent: OrderEvent containing order details and status
        """
        # Check if this is our entry order being filled
        if self.entry_ticket and orderEvent.order_id == self.entry_ticket.order_id:
            # Place stop loss order immediately upon entry fill
            # Quantity is negative of entry (opposite direction for exit)
            self.stop_loss_ticket = self.algorithm.stop_market_order(
                self.security.symbol, 
                -self.entry_ticket.quantity,  # Opposite direction of entry
                self.stop_loss_price,         # Pre-calculated stop price
                "Stop Loss"                   # Order tag for identification
            )