Overall Statistics
Total Orders
0
Average Win
0%
Average Loss
0%
Compounding Annual Return
0%
Drawdown
0%
Expectancy
0
Start Equity
100000
End Equity
100000
Net Profit
0%
Sharpe Ratio
0
Sortino Ratio
0
Probabilistic Sharpe Ratio
0%
Loss Rate
0%
Win Rate
0%
Profit-Loss Ratio
0
Alpha
0
Beta
0
Annual Standard Deviation
0
Annual Variance
0
Information Ratio
0
Tracking Error
0
Treynor Ratio
0
Total Fees
$0.00
Estimated Strategy Capacity
$0
Lowest Capacity Asset
Portfolio Turnover
0%
Drawdown Recovery
0
# region imports
from AlgorithmImports import *
# endregion

class PremarketScanner(QCAlgorithm):

    def Initialize(self):
        self.SetStartDate(2025, 9, 5)
        self.SetEndDate(2025, 10, 1)
        self.SetCash(100000)

        self.symbolData = {}
        self.currentDay = None

        # Schedule watchlist compilation a few seconds after market open
        self.Schedule.On(
            self.DateRules.EveryDay(),
            self.TimeRules.AfterMarketOpen("SPY", 1),  # 1 minute after open
            self.CompileWatchlist
        )

        # Reset premarket data at 4 AM each day
        self.Schedule.On(
            self.DateRules.EveryDay(),
            self.TimeRules.At(4, 0),
            self.ResetDailyData
        )

        self.AddEquity("SPY", Resolution.Minute)
        self.AddUniverse(self.CoarseSelectionFilter)

        self.UniverseSettings.ExtendedMarketHours = True
        self.UniverseSettings.Resolution = Resolution.Minute

    def OnData(self, data: Slice):
        # Track premarket data for all securities
        for symbol, symbolData in self.symbolData.items():
            if symbol in data.Bars:
                symbolData.UpdateData(data.Bars[symbol])

    def CoarseSelectionFilter(self, coarse):
        # Filter for liquid stocks with price >= $0.05
        filtered = [
            x.Symbol for x in coarse
            if x.HasFundamentalData
            # THE FIX: Ensure it is a standard Equity type supported by Schwab
            and x.SecurityType == SecurityType.Equity 
            and x.Price >= 0.05
            and x.DollarVolume > 5  # Basic liquidity filter: $500,000+
        ]
        return filtered  # Limit universe size for performance

    def OnSecuritiesChanged(self, changes):
        for security in changes.AddedSecurities:
            if security.Symbol not in self.symbolData.keys():
                self.symbolData[security.Symbol] = SymbolData(self, security)

        for security in changes.RemovedSecurities:
            if security.Symbol in self.symbolData.keys():
                self.symbolData.pop(security.Symbol)

    def ResetDailyData(self):
        """Reset premarket data at the start of each trading day"""
        for symbolData in self.symbolData.values():
            symbolData.ResetDaily()

    def CompileWatchlist(self):
        """Compile watchlist of stocks gapping up 10%+ with premarket volume filter"""

        self.Debug(f"\n{'='*80}")
        self.Debug(f"PREMARKET GAP SCANNER - {self.Time}")
        self.Debug(f"{'='*80}\n")

        gappers = []

        for symbol, symbolData in self.symbolData.items():
            gap_percent = symbolData.GetGapPercent()
            premarket_dollar_volume = symbolData.GetPremarketDollarVolume()
            current_price = symbolData.GetCurrentPrice()

            # Filter criteria matching the websites
            if (gap_percent is not None
                and gap_percent >= 10.0
                and premarket_dollar_volume >= 5  # $500K equivalent (since DollarVolume is scaled)
                and current_price >= 0.05):

                gappers.append({
                    'symbol': symbol,
                    'gap_percent': gap_percent,
                    'pm_volume': premarket_dollar_volume,
                    'price': current_price,
                    'prev_close': symbolData.previousClose
                })

        # Sort by gap percentage (highest first)
        gappers.sort(key=lambda x: x['gap_percent'], reverse=True)

        # Display watchlist
        if gappers:
            self.Debug(f"Found {len(gappers)} stocks gapping up 10%+ with $500K+ premarket volume:\n")
            self.Debug(f"{'Symbol':<10} {'Gap %':<10} {'Price':<10} {'Prev Close':<12} {'PM $ Volume':<15}")
            self.Debug(f"{'-'*70}")

            for g in gappers:
                self.Debug(
                    f"{str(g['symbol'].Value):<10} "
                    f"{g['gap_percent']:>7.2f}%   "
                    f"${g['price']:>7.2f}   "
                    f"${g['prev_close']:>9.2f}   "
                    f"${g['pm_volume']:>13,.0f}"
                )

            self.Debug(f"\n{'='*80}\n")
        else:
            self.Debug("No stocks found matching criteria (10%+ gap, $500K+ PM volume, $0.05+ price)\n")


class SymbolData:

    def __init__(self, algo, security):
        self.security = security
        self.symbol = security.Symbol
        self.algo = algo

        # Track previous day's close
        self.previousClose = None

        # Track premarket data
        self.premarketBars = []
        self.marketOpenTime = None
        self.currentPrice = None
        self.lastUpdateDay = None

        # Get previous close using history
        self.UpdatePreviousClose()

    def UpdatePreviousClose(self):
        """Update the previous day's close price"""
        # Requesting 2 bars to ensure we get *at least* one complete bar's close
        history = self.algo.History(self.symbol, 2, Resolution.Daily)
        if not history.empty and len(history) >= 1:
            self.previousClose = history['close'].iloc[-1]

    def ResetDaily(self):
        """Reset daily tracking variables at 4 AM"""
        self.premarketBars = []
        self.UpdatePreviousClose()

    def UpdateData(self, bar):
        """Update data with incoming bars"""
        current_time = self.algo.Time

        # Check if we need to reset for a new day
        if self.lastUpdateDay is None or self.lastUpdateDay.date() != current_time.date():
            # Only reset if it's after 4 AM, which is handled by the scheduler's ResetDailyData
            self.lastUpdateDay = current_time

        # Set market open time (9:30 AM ET)
        if self.marketOpenTime is None:
            # Note: QC time is UTC, but the simulator handles the market-open-time conversion
            self.marketOpenTime = current_time.replace(hour=9, minute=30, second=0, microsecond=0)

        # Track current price
        self.currentPrice = bar.Close

        # Collect premarket bars (4:00 AM to 9:29 AM ET)
        # Assuming current_time is converted to exchange time by the platform logic for comparison
        if current_time.hour >= 4 and current_time < self.marketOpenTime:
            self.premarketBars.append({
                'time': current_time,
                'close': bar.Close,
                'volume': bar.Volume
            })

    def GetGapPercent(self):
        """Calculate gap percentage from previous close to current price"""
        if self.previousClose is None or self.previousClose == 0 or self.currentPrice is None:
            return None

        gap_percent = ((self.currentPrice / self.previousClose) - 1) * 100
        return gap_percent

    def GetPremarketDollarVolume(self):
        """Calculate total premarket dollar volume"""
        if not self.premarketBars:
            return 0

        # Dollar volume in the filter is x.DollarVolume > 5, which means $500,000 when using the QC default
        # x.DollarVolume = (Volume * Price) / 100,000. So 5 = $500K
        # Your internal tracking is raw dollar volume, so to be comparable, you'd divide by 100,000 if needed.
        # But for an internal calculation of gappers, raw dollar volume is fine.
        dollar_volume = sum(bar['close'] * bar['volume'] for bar in self.premarketBars)
        return dollar_volume

    def GetCurrentPrice(self):
        """Get current price"""
        return self.currentPrice if self.currentPrice is not None else 0