Overall Statistics
Total Orders
202
Average Win
0.37%
Average Loss
-0.22%
Compounding Annual Return
-11.168%
Drawdown
6.800%
Expectancy
-0.083
Start Equity
100000
End Equity
98105.31
Net Profit
-1.895%
Sharpe Ratio
-2.53
Sortino Ratio
-3.356
Probabilistic Sharpe Ratio
5.746%
Loss Rate
66%
Win Rate
34%
Profit-Loss Ratio
1.72
Alpha
-0.285
Beta
0.311
Annual Standard Deviation
0.083
Annual Variance
0.007
Information Ratio
-4.672
Tracking Error
0.097
Treynor Ratio
-0.675
Total Fees
$576.42
Estimated Strategy Capacity
$810000.00
Lowest Capacity Asset
TFX R735QTJ8XC9X
Portfolio Turnover
108.33%
# region imports
from AlgorithmImports import *
# endregion

class Mindthegapv2(QCAlgorithm):

    def initialize(self):
        # Set the backtest time frame and initial capital
        self.set_start_date(2019, 2, 12)
        self.set_end_date(2019, 4, 11)
        self.set_cash(100_000)

        # Set warm-up period for indicators (2 days of daily data)
        self.set_warm_up(2, Resolution.DAILY)
        self.is_warming_up = True

        # Dictionary to hold SelectionData objects for each symbol
        self._selection_data_by_symbol = {}

        # List of selected symbols that meet SMA criteria
        self.selected_symbols = []

        # Set universe using SPY constituents, filtered by SMA criteria
        spy = Symbol.create("SPY", SecurityType.EQUITY, Market.USA)
        self._universe = self.add_universe(self.universe.etf(spy), self._fundamental_selection)
        self.UniverseSettings.Resolution = Resolution.MINUTE

        # Schedule trades: open shortly after market open, close before close
        self.schedule.on(self.date_rules.every_day(spy), self.time_rules.after_market_open(spy, 2), self.open_positions)
        self.schedule.on(self.date_rules.every_day(spy), self.time_rules.after_market_open(spy, 15), self.close_positions)

    def _fundamental_selection(self, fundamental: List[Fundamental]) -> List[Symbol]:
        """
        Universe selection method that filters symbols trading above their 100-day SMA.
        """
        current_symbols = [f.symbol for f in fundamental]

        for f in fundamental:
            symbol = f.symbol
            if symbol not in self._selection_data_by_symbol:
                self._selection_data_by_symbol[symbol] = SelectionData(self, symbol)

            # Update the SMA for each symbol
            self._selection_data_by_symbol[symbol].update_sma(self.time, f.adjusted_price)

        # Remove outdated symbols
        for symbol in list(self._selection_data_by_symbol):
            if symbol not in current_symbols:
                del self._selection_data_by_symbol[symbol]

        # Select symbols currently trading above their SMA
        selected = [
            symbol for symbol, data in self._selection_data_by_symbol.items()
            if data.is_above_sma
        ]

        #self.plot("Universe", "Possible", len(fundamental))
        #self.Debug(f"Universe Time {self.time}. Selected Symbols: {selected}")
        self.selected_symbols = selected
        return selected

    def on_data(self, data: Slice):
        """
        Called every minute. Updates ATR values for selected symbols after market open.
        """
        if self.is_warming_up or not (self.time.hour == 9 and self.time.minute == 31):
            return

        for symbol in self.selected_symbols:
            trade_bar = data.bars.get(symbol)
            if trade_bar:
                self._selection_data_by_symbol[symbol]._atr.update(trade_bar)

    def open_positions(self):
        """
        Open positions in symbols that gapped down >3% and meet the ATR threshold.
        """
        if self.is_warming_up:
            return

        # Filter out symbols with valid data and price > $10
        symbols = [
            s for s in self.selected_symbols
            if self.securities.contains_key(s)
            and self.securities[s].has_data
            and self.securities[s].price > 10
        ]

        if not symbols:
            self.Debug("No valid symbols to trade.")
            return

        # Get yesterday’s close prices
        history = self.history(symbols, 1, Resolution.DAILY)
        symbols_to_trade = []

        for symbol in symbols:
            if symbol in history.index.levels[0] and len(history.loc[symbol]) >= 1:
                yesterday_close = history.loc[symbol].iloc[0]['close']
                current_price = self.securities[symbol].price
                atr = self._selection_data_by_symbol[symbol]._atr.current.value
                gap = yesterday_close - current_price

                #self.Debug(f"{symbol}: Close={yesterday_close}, Current={current_price}, ATR={atr}, Gap={gap}")

                # ATR-based entry threshold
                if atr > 0 and gap >= 1.2 * atr:
                    symbols_to_trade.append(symbol)
                    #self.Debug(f"{symbol} passed ATR-based entry condition.")

        # Sort symbols by ATR value descending, take top 20
        symbols_to_trade = sorted(
            symbols_to_trade,
            key=lambda x: self._selection_data_by_symbol[x]._atr.current.value,
            reverse=True
        )[:5]

        if not symbols_to_trade:
            self.Debug("No symbols passed gap down and ATR filter.")
            return

        # Equal weight allocation
        weight = 1 / len(symbols_to_trade)
        self.set_holdings([PortfolioTarget(s, weight) for s in symbols_to_trade], True)
        self.Debug(f"Set Holdings at {self.time}: {symbols_to_trade}")
        self.plot("Universe", "Symbols Traded", len(symbols_to_trade))

    def close_positions(self):
        """
        Liquidates all open positions before market close.
        """
        self.liquidate()
        self.Debug(f"Liquidated all positions at {self.time}")


# Supporting class for holding SMA and ATR indicators
class SelectionData:

    def __init__(self, algorithm: QCAlgorithm, symbol: Symbol, sma_period=100, atr_period=14):
        self.algorithm = algorithm
        self.symbol = symbol

        # Create indicators
        self._sma = SimpleMovingAverage(sma_period)
        self._atr = AverageTrueRange(atr_period, MovingAverageType.Simple)

        # Warm up indicators with historical data
        algorithm.warm_up_indicator(symbol, self._sma, Resolution.DAILY)
        algorithm.warm_up_indicator(symbol, self._atr, Resolution.DAILY)

        # Boolean flag for SMA filter
        self.is_above_sma = False

    def update_sma(self, time, value):
        """
        Updates the SMA and evaluates if the price is above SMA.
        """
        #self.algorithm.Debug(f"[{self.symbol}] SMA update at {time} with price {value}")
        if self._sma.update(time, value):
            self.is_above_sma = value > self._sma.current.value
            #self.algorithm.Debug(f"[{self.symbol}] SMA: {self._sma.current.value}, Above SMA: {self.is_above_sma}")