Overall Statistics
Total Orders
296
Average Win
1.22%
Average Loss
-0.84%
Compounding Annual Return
1.070%
Drawdown
14.300%
Expectancy
0.044
Start Equity
100000
End Equity
102153.78
Net Profit
2.154%
Sharpe Ratio
-0.671
Sortino Ratio
-0.571
Probabilistic Sharpe Ratio
7.215%
Loss Rate
57%
Win Rate
43%
Profit-Loss Ratio
1.45
Alpha
-0.032
Beta
-0.097
Annual Standard Deviation
0.066
Annual Variance
0.004
Information Ratio
-1.251
Tracking Error
0.133
Treynor Ratio
0.453
Total Fees
$163.23
Estimated Strategy Capacity
$350000000.00
Lowest Capacity Asset
SPY R735QTJ8XC9X
Portfolio Turnover
20.49%
Drawdown Recovery
675
# region imports
from AlgorithmImports import *
from datetime import time, timedelta
# endregion

class PreMarketGapEMABounceStrategy(QCAlgorithm):
    """
    Pre-market Gap and EMA Bounce Strategy
    
    Strategy Logic:
    1. Calculate pre-market high/low levels (4:00 AM - 9:30 AM ET)
    2. Detect breakouts during regular hours (9:30 AM - 4:00 PM ET)
    3. Wait for EMA bounce confirmation before entry
    4. Manage positions with 2% stop loss and 4% take profit
    """
    
    def initialize(self):
        # Set strategy parameters
        self.set_start_date(2023, 1, 1)
        self.set_end_date(2024, 12, 31)
        self.set_cash(100000)
        
        # Add SPY with minute resolution and extended market hours
        self.spy = self.add_equity("SPY", Resolution.MINUTE, 
                                  extended_market_hours=True).symbol
        
        # Strategy parameters - MATCHING ORIGINAL NOTEBOOK EXACTLY
        self.ema_period = 20  # FIXED: Changed from 9 to 20 to match profitable notebook
        self.confirmation_bars = 1  # Reduced from 2 to 1 for more immediate entries like notebook
        self.stop_loss_pct = 0.02
        self.take_profit_pct = 0.04
        self.max_risk_per_trade = 0.02
        
        # Initialize EMA indicator
        self.ema = ExponentialMovingAverage("EMA", self.ema_period)
        
        # State variables
        self.premarket_high = None
        self.premarket_low = None
        self.current_date = None
        self.breakout_direction = None
        self.breakout_confirmed = False
        self.confirmation_count = 0
        self.waiting_for_bounce = False
        self.position_entry_price = None
        self.stop_loss_price = None
        self.take_profit_price = None
        self.previous_bar = None  # Store previous bar for bounce detection
        self.previous_ema = None  # Store previous EMA value
        self.daily_trades = 0  # Track trades per day
        self.max_daily_trades = 1  # Limit to 1 trade per day like notebook
        self.previous_close = None  # Store previous day's close for gap calculation
        self.entry_date = None  # Track when position was entered for max hold period
        
        # Signal quality filters - more reasonable thresholds
        self.min_gap_size = 0.004  # Minimum 0.4% gap (was too restrictive at 0.5%)
        self.max_gap_size = 0.03   # Maximum 3% gap
        self.min_volume = 800000   # Minimum 800K volume for breakouts (was too high at 1M)
        
        # Market hours
        self.premarket_start = time(4, 0)
        self.market_open = time(9, 30)
        self.market_close = time(16, 0)
        self.entry_cutoff = time(15, 0)
        
        # Tracking variables
        self.premarket_bars = []
        self.last_bar_time = None
        
        # Schedule daily reset
        self.schedule.on(self.date_rules.every_day(self.spy),
                        self.time_rules.at(4, 0),
                        self.reset_daily_variables)
        
        # Schedule premarket level calculation
        self.schedule.on(self.date_rules.every_day(self.spy),
                        self.time_rules.at(9, 25),
                        self.calculate_premarket_levels)
        
        # Schedule end of day exit - only exit losing positions, let winners run overnight
        self.schedule.on(self.date_rules.every_day(self.spy),
                        self.time_rules.before_market_close(self.spy, 1),  # 1 minute before close
                        self.end_of_day_exit)
    
    def reset_daily_variables(self):
        """Reset variables at the start of each trading day"""
        self.premarket_high = None
        self.premarket_low = None
        self.breakout_direction = None
        self.breakout_confirmed = False
        self.confirmation_count = 0
        self.waiting_for_bounce = False
        self.premarket_bars = []
        self.previous_bar = None  # Reset previous bar tracking
        self.previous_ema = None  # Reset previous EMA tracking
        self.daily_trades = 0  # Reset daily trade counter
        self.previous_close = None  # Reset for gap calculation
        # Note: Don't reset entry_date here - let it persist for overnight holds
        
        if self.current_date != self.time.date():
            self.current_date = self.time.date()
            self.debug(f"Starting new trading day: {self.current_date}")
            self.debug(f"Daily trade limit: {self.max_daily_trades}")
    
    def calculate_premarket_levels(self):
        """Calculate premarket high and low levels with gap size filtering"""
        if len(self.premarket_bars) > 0:
            highs = [bar.high for bar in self.premarket_bars]
            lows = [bar.low for bar in self.premarket_bars]
            
            self.premarket_high = max(highs)
            self.premarket_low = min(lows)
            
            # Get previous day's close for gap calculation
            if self.previous_close is None:
                # Get historical data for previous close
                history = self.history(self.spy, 2, Resolution.DAILY)
                if len(history) >= 1:
                    self.previous_close = history['close'].iloc[-1]
            
            # Calculate gap sizes
            if self.previous_close is not None:
                gap_up = (self.premarket_high - self.previous_close) / self.previous_close
                gap_down = (self.previous_close - self.premarket_low) / self.previous_close
                max_gap = max(gap_up, gap_down)
                
                # Only proceed if gap is within acceptable range
                if self.min_gap_size <= max_gap <= self.max_gap_size:
                    self.debug(f"Valid gap detected - PMH: {self.premarket_high:.2f}, PML: {self.premarket_low:.2f}, Gap: {max_gap*100:.2f}%")
                else:
                    self.debug(f"Gap out of range - Gap: {max_gap*100:.2f}%, Min: {self.min_gap_size*100:.1f}%, Max: {self.max_gap_size*100:.1f}%")
                    # Reset premarket levels to prevent trading
                    self.premarket_high = None
                    self.premarket_low = None
                    return
            
            self.debug(f"Premarket levels - High: {self.premarket_high:.2f}, Low: {self.premarket_low:.2f}")
        else:
            self.debug("No premarket data available")
    
    def on_data(self, data):
        """Main data processing function"""
        if self.spy not in data.bars:
            return
        
        bar = data.bars[self.spy]
        current_time = self.time.time()
        
        # Update EMA and store previous values
        if self.ema.is_ready:
            self.previous_ema = self.ema.current.value
        self.ema.update(bar.end_time, bar.close)
        
        # Store premarket bars
        if self.premarket_start <= current_time < self.market_open:
            self.premarket_bars.append(bar)
            self.previous_bar = bar  # Track previous bar
            return
        
        # Process regular market hours only
        if not (self.market_open <= current_time <= self.market_close):
            return
        
        # Skip if we don't have premarket levels yet
        if self.premarket_high is None or self.premarket_low is None:
            return
        
        # Skip if EMA is not ready
        if not self.ema.is_ready:
            self.previous_bar = bar  # Still track previous bar
            return
        
        # Manage existing position
        if self.portfolio.invested:
            self.manage_position(bar)
            self.previous_bar = bar  # Update previous bar
            return
        
        # Skip late entries (avoid entries too close to market open and close)
        if current_time < time(9, 35) or current_time > self.entry_cutoff:
            self.previous_bar = bar  # Update previous bar
            return
        
        # Skip if daily trade limit reached
        if self.daily_trades >= self.max_daily_trades:
            self.previous_bar = bar  # Update previous bar
            return
        
        # Look for breakout signals
        if not self.breakout_confirmed:
            self.check_for_breakout(bar)
        
        # Look for EMA bounce entry
        if self.waiting_for_bounce:
            self.check_for_ema_bounce_entry(bar)
        
        # Update previous bar for next iteration
        self.previous_bar = bar
    
    def check_for_breakout(self, bar):
        """Check for breakout above premarket high or below premarket low with volume filter"""
        # Add volume filter for breakouts
        if bar.volume < self.min_volume:
            return
        
        # Debug logging for breakout detection
        self.debug(f"Checking breakout - PMH: {self.premarket_high:.2f}, PML: {self.premarket_low:.2f}, High: {bar.high:.2f}, Low: {bar.low:.2f}, Close: {bar.close:.2f}, Volume: {bar.volume}")
        
        # Check for breakout above premarket high
        if bar.high > self.premarket_high and self.breakout_direction != "short":
            if self.breakout_direction != "long":
                self.breakout_direction = "long"
                self.confirmation_count = 0
                self.debug(f"Potential LONG breakout detected - High: {bar.high:.2f} > PMH: {self.premarket_high:.2f}")
            
            # Check for confirmation - simplified to just require close in breakout direction
            if bar.close > self.premarket_high:  # Close above PMH for long confirmation
                self.confirmation_count += 1
                self.debug(f"Long breakout confirmation {self.confirmation_count}/{self.confirmation_bars} - Close: {bar.close:.2f} > PMH: {self.premarket_high:.2f}")
                if self.confirmation_count >= self.confirmation_bars:
                    self.breakout_confirmed = True
                    self.waiting_for_bounce = True
                    self.debug(f"LONG breakout CONFIRMED after {self.confirmation_count} bars")

        # Check for breakout below premarket low
        elif bar.low < self.premarket_low and self.breakout_direction != "long":
            if self.breakout_direction != "short":
                self.breakout_direction = "short"
                self.confirmation_count = 0
                self.debug(f"Potential SHORT breakout detected - Low: {bar.low:.2f} < PML: {self.premarket_low:.2f}")
            
            # Check for confirmation - simplified to just require close in breakout direction
            if bar.close < self.premarket_low:  # Close below PML for short confirmation
                self.confirmation_count += 1
                self.debug(f"Short breakout confirmation {self.confirmation_count}/{self.confirmation_bars} - Close: {bar.close:.2f} < PML: {self.premarket_low:.2f}")
                if self.confirmation_count >= self.confirmation_bars:
                    self.breakout_confirmed = True
                    self.waiting_for_bounce = True
                    self.debug(f"SHORT breakout CONFIRMED after {self.confirmation_count} bars")
    
    def check_for_ema_bounce_entry(self, bar):
        """Check for EMA bounce entry signal - EXACT match to original notebook logic"""
        if not self.ema.is_ready or self.previous_bar is None or self.previous_ema is None:
            return False
            
        current_ema = self.ema.current.value
        prev_ema = self.previous_ema
        
        # Debug logging for signal analysis
        self.debug(f"EMA bounce check - Direction: {self.breakout_direction}, Current EMA: {current_ema:.2f}, Prev EMA: {prev_ema:.2f}")
        
        if self.breakout_direction == "long":
            # Realistic EMA bounce conditions - matching actual market behavior
            prev_touched_ema = self.previous_bar.low <= prev_ema * 1.002  # 0.2% buffer - more realistic
            current_above_ema = bar.close > current_ema  # Just needs to be above EMA, no additional buffer
            momentum_up = bar.close > self.previous_bar.close  # Any positive momentum
            
            self.debug(f"Long conditions - Prev touched EMA: {prev_touched_ema} (prev_low: {self.previous_bar.low:.2f} vs {prev_ema * 1.002:.2f})")
            self.debug(f"Current above EMA: {current_above_ema} (close: {bar.close:.2f} vs {current_ema:.2f})")
            self.debug(f"Momentum up: {momentum_up} (close: {bar.close:.2f} vs prev_close: {self.previous_bar.close:.2f})")
            
            if (prev_touched_ema and current_above_ema and momentum_up):
                self.debug(f"LONG EMA bounce signal triggered!")
                self.enter_long_position(bar.close)
        
        elif self.breakout_direction == "short":
            # Realistic EMA bounce conditions - matching actual market behavior
            prev_touched_ema = self.previous_bar.high >= prev_ema * 0.998  # 0.2% buffer - more realistic
            current_below_ema = bar.close < current_ema  # Just needs to be below EMA, no additional buffer
            momentum_down = bar.close < self.previous_bar.close  # Any negative momentum
            
            self.debug(f"Short conditions - Prev touched EMA: {prev_touched_ema} (prev_high: {self.previous_bar.high:.2f} vs {prev_ema * 0.998:.2f})")
            self.debug(f"Current below EMA: {current_below_ema} (close: {bar.close:.2f} vs {current_ema:.2f})")
            self.debug(f"Momentum down: {momentum_down} (close: {bar.close:.2f} vs prev_close: {self.previous_bar.close:.2f})")
            
            if (prev_touched_ema and current_below_ema and momentum_down):
                self.debug(f"SHORT EMA bounce signal triggered!")
                self.enter_short_position(bar.close)
    
    def calculate_simple_stop_loss(self, entry_price, direction):
        """Calculate simple 2% stop loss (matching original strategy)"""
        stop_pct = 0.02  # Simple 2% stop loss
        
        if direction == "long":
            return entry_price * (1 - stop_pct)
        else:
            return entry_price * (1 + stop_pct)
    
    def enter_long_position(self, entry_price):
        """Enter long position with simple 2% risk management and proper orders"""
        # Simple stop loss calculation
        stop_price = self.calculate_simple_stop_loss(entry_price, "long")
        stop_distance = entry_price - stop_price
        
        # Simple position sizing: 2% portfolio risk
        portfolio_value = self.portfolio.total_portfolio_value
        risk_amount = portfolio_value * 0.02  # 2% risk per trade
        
        shares = int(risk_amount / stop_distance)
        
        # Limit position size to available cash
        max_shares = int(self.portfolio.cash / entry_price)
        shares = min(shares, max_shares)
        
        if shares > 0:
            # Enter position with market order
            self.market_order(self.spy, shares)
            
            # Set up stop loss and take profit orders
            self.position_entry_price = entry_price
            self.stop_loss_price = stop_price
            self.take_profit_price = entry_price * (1 + self.take_profit_pct)
            self.waiting_for_bounce = False
            self.entry_date = self.time.date()  # Track entry date for max hold period
            
            # Place stop loss and take profit orders
            self.stop_market_order(self.spy, -shares, self.stop_loss_price)
            self.limit_order(self.spy, -shares, self.take_profit_price)
            
            # Increment daily trade counter
            self.daily_trades += 1
            
            self.debug(f"Entered LONG position: {shares} shares at ${entry_price:.2f}")
            self.debug(f"Stop Loss Order: ${self.stop_loss_price:.2f}, Take Profit Order: ${self.take_profit_price:.2f}")
            self.debug(f"Daily trades: {self.daily_trades}/{self.max_daily_trades}")
    
    def enter_short_position(self, entry_price):
        """Enter short position with simple 2% risk management and proper orders"""
        # Simple stop loss calculation
        stop_price = self.calculate_simple_stop_loss(entry_price, "short")
        stop_distance = stop_price - entry_price
        
        # Simple position sizing: 2% portfolio risk
        portfolio_value = self.portfolio.total_portfolio_value
        risk_amount = portfolio_value * 0.02  # 2% risk per trade
        
        shares = int(risk_amount / stop_distance)
        
        # Limit position size to available cash
        max_shares = int(self.portfolio.cash / entry_price)
        shares = min(shares, max_shares)
        
        if shares > 0:
            # Enter position with market order (negative shares for short)
            self.market_order(self.spy, -shares)
            
            # Set up stop loss and take profit orders
            self.position_entry_price = entry_price
            self.stop_loss_price = stop_price
            self.take_profit_price = entry_price * (1 - self.take_profit_pct)
            self.waiting_for_bounce = False
            self.entry_date = self.time.date()  # Track entry date for max hold period
            
            # Place stop loss and take profit orders (positive shares to cover short)
            self.stop_market_order(self.spy, shares, self.stop_loss_price)
            self.limit_order(self.spy, shares, self.take_profit_price)
            
            # Increment daily trade counter
            self.daily_trades += 1
            
            self.debug(f"Entered SHORT position: {shares} shares at ${entry_price:.2f}")
            self.debug(f"Stop Loss Order: ${self.stop_loss_price:.2f}, Take Profit Order: ${self.take_profit_price:.2f}")
            self.debug(f"Daily trades: {self.daily_trades}/{self.max_daily_trades}")
    
    def manage_position(self, bar):
        """Position management - orders should handle exits automatically"""
        # With stop loss and take profit orders in place, we don't need manual management
        # This function is kept for any additional monitoring/logging
        if self.portfolio.invested:
            current_price = bar.close
            position = self.portfolio[self.spy]
            
            # Optional: Log position status - only if we have stop loss price set
            if self.stop_loss_price is not None:
                if position.is_long and current_price <= self.stop_loss_price * 1.01:
                    self.debug(f"LONG position near stop loss: current ${current_price:.2f}, stop ${self.stop_loss_price:.2f}")
                elif position.is_short and current_price >= self.stop_loss_price * 0.99:
                    self.debug(f"SHORT position near stop loss: current ${current_price:.2f}, stop ${self.stop_loss_price:.2f}")
            else:
                # Position exists but we don't have stop loss info (likely overnight hold)
                if self.position_entry_price is not None:
                    if position.is_long:
                        pnl_pct = (current_price - self.position_entry_price) / self.position_entry_price
                    else:
                        pnl_pct = (self.position_entry_price - current_price) / self.position_entry_price
                    self.debug(f"Managing overnight position: current ${current_price:.2f}, P&L: {pnl_pct*100:.2f}%")
    
    def end_of_day_exit(self):
        """Smart overnight decision - exit if losing OR if held too long"""
        if self.portfolio.invested and self.position_entry_price is not None:
            position = self.portfolio[self.spy]
            current_price = self.securities[self.spy].price
            
            # Calculate P&L
            if position.is_long:
                pnl_pct = (current_price - self.position_entry_price) / self.position_entry_price
            else:
                pnl_pct = (self.position_entry_price - current_price) / self.position_entry_price
            
            # Calculate days held
            days_held = (self.time.date() - self.entry_date).days if self.entry_date else 0
            
            # Exit if losing more than 0.5% OR if held more than 3 days (prevent buy-and-hold drift)
            should_exit = pnl_pct < -0.005 or days_held > 3
            
            if should_exit:
                self.liquidate(self.spy)
                self.debug(f"End of day exit - P&L: {pnl_pct*100:.2f}%, Days held: {days_held}, Reason: {'Loss' if pnl_pct < -0.005 else 'Max hold period'}")
                # Reset tracking variables when we exit
                self.position_entry_price = None
                self.stop_loss_price = None
                self.take_profit_price = None
                self.entry_date = None
            else:
                self.debug(f"Holding profitable position overnight - P&L: {pnl_pct*100:.2f}%, Days held: {days_held}")
        
        # Store the closing price for next day's gap calculation
        current_price = self.securities[self.spy].price
        if current_price > 0:
            self.previous_close = current_price
            self.debug(f"Stored closing price: {self.previous_close:.2f}")
    
    def on_order_event(self, order_event):
        """Handle order events"""
        if order_event.status == OrderStatus.FILLED:
            self.debug(f"Order filled: {order_event.fill_quantity} shares at ${order_event.fill_price:.2f}")
    
    def on_end_of_day(self):
        """Only reset tracking variables if no position is held"""
        # Don't reset if we're holding a position overnight
        if not self.portfolio.invested:
            self.position_entry_price = None
            self.stop_loss_price = None
            self.take_profit_price = None
            self.entry_date = None
            self.debug("Reset position tracking - no position held")