Overall Statistics
Total Orders
1266
Average Win
1.38%
Average Loss
-1.26%
Compounding Annual Return
89.518%
Drawdown
40.700%
Expectancy
0.421
Start Equity
100000
End Equity
2443408.43
Net Profit
2343.408%
Sharpe Ratio
1.643
Sortino Ratio
1.78
Probabilistic Sharpe Ratio
81.079%
Loss Rate
32%
Win Rate
68%
Profit-Loss Ratio
1.10
Alpha
0.471
Beta
1.959
Annual Standard Deviation
0.392
Annual Variance
0.153
Information Ratio
1.863
Tracking Error
0.298
Treynor Ratio
0.329
Total Fees
$30323.56
Estimated Strategy Capacity
$160000000.00
Lowest Capacity Asset
TQQQ UK280CGTCB51
Portfolio Turnover
19.19%
Drawdown Recovery
298
# ==============================================================================
# QuantConnect Verification Script — Leveraged ETF Regime Rotation
# ==============================================================================
# Strategies:
#   TQQQ: Asymmetric stay-in (OBV>SMA20 & QQQ_leading_SPY & OBV>SMA50) + RSI<30 override
#         Defensive sleeve: 50% GLD + 50% SVXY
#   SOXL: AND-stack (QQQ_leading_SPY & QQQ_RSI<75 & SMH_mom3m>0 & QQQ_SMA150_slope_up)
#         Defensive sleeve: 100% GLD
#
# Expected Results (5Y backtest June 2020 - May 2025):
#   TQQQ: CAGR~109%, DD~-31%, TIM~41%
#   SOXL: CAGR~139%, DD~-39%, TIM~37%
#
# TIMING CONVENTION:
#   Signal computed at close[d] → trade executed at close[d] → earns return close[d] to close[d+1]
#   In QC: Schedule rebalance BeforeMarketClose to evaluate signals and trade.
# ==============================================================================

from AlgorithmImports import *
import numpy as np
from collections import deque


class LeveragedRegimeRotation(QCAlgorithm):

    def Initialize(self):
        # --- Backtest window ---
        self.SetStartDate(2020, 6, 1)
        self.SetEndDate(2025, 5, 31)
        self.SetCash(100000)

        # --- Add equities ---
        self.tqqq = self.AddEquity("TQQQ", Resolution.Daily).Symbol
        self.soxl = self.AddEquity("SOXL", Resolution.Daily).Symbol
        self.qqq = self.AddEquity("QQQ", Resolution.Daily).Symbol
        self.spy = self.AddEquity("SPY", Resolution.Daily).Symbol
        self.smh = self.AddEquity("SMH", Resolution.Daily).Symbol
        self.gld = self.AddEquity("GLD", Resolution.Daily).Symbol
        self.svxy = self.AddEquity("SVXY", Resolution.Daily).Symbol

        # --- Parameters ---
        self.RSI_REENTRY_THRESHOLD = 30
        self.QQQ_RSI_OVERBOUGHT = 75
        self.QQQ_SPY_RATIO_PERIOD = 50
        self.OBV_SMA_SHORT = 20
        self.OBV_SMA_LONG = 50
        self.SMH_MOM_PERIOD = 63
        self.QQQ_SMA150_PERIOD = 150
        self.QQQ_SMA150_SLOPE_LOOKBACK = 10

        # --- Allocation (split capital equally between the two strategies) ---
        self.TQQQ_ALLOC = 0.5
        self.SOXL_ALLOC = 0.5

        # --- Manual indicator history buffers ---
        # TQQQ OBV
        self.tqqq_close_history = deque(maxlen=300)
        self.tqqq_volume_history = deque(maxlen=300)
        self.tqqq_obv_history = deque(maxlen=60)

        # QQQ/SPY ratio
        self.qqq_close_history = deque(maxlen=200)
        self.spy_close_history = deque(maxlen=200)
        self.qqq_spy_ratio_history = deque(maxlen=60)

        # QQQ RSI
        self.qqq_gain_history = deque(maxlen=20)
        self.qqq_loss_history = deque(maxlen=20)
        self.qqq_prev_close = None
        self.qqq_avg_gain = None
        self.qqq_avg_loss = None
        self.qqq_rsi_warmup_count = 0

        # SMH momentum
        self.smh_close_history = deque(maxlen=70)

        # QQQ SMA150
        self.qqq_sma150_history = deque(maxlen=15)

        # --- State ---
        self.tqqq_in_market = True
        self.soxl_in_market = True
        self.warmup_days = 200
        self.days_elapsed = 0

        # --- Schedule rebalance at end of day ---
        self.Schedule.On(
            self.DateRules.EveryDay("SPY"),
            self.TimeRules.BeforeMarketClose("SPY", 5),
            self.Rebalance
        )

        # --- Warmup ---
        self.SetWarmUp(self.warmup_days, Resolution.Daily)

    def OnData(self, data):
        # Accumulate price/volume data for manual indicators
        if data.Bars.ContainsKey(self.tqqq):
            bar = data.Bars[self.tqqq]
            self.tqqq_close_history.append(bar.Close)
            self.tqqq_volume_history.append(bar.Volume)
            self._update_tqqq_obv()

        if data.Bars.ContainsKey(self.qqq):
            bar = data.Bars[self.qqq]
            self.qqq_close_history.append(bar.Close)
            self._update_qqq_rsi(bar.Close)
            self._update_qqq_sma150()

        if data.Bars.ContainsKey(self.spy):
            self.spy_close_history.append(data.Bars[self.spy].Close)

        if data.Bars.ContainsKey(self.smh):
            self.smh_close_history.append(data.Bars[self.smh].Close)

        # Update QQQ/SPY ratio
        if len(self.qqq_close_history) > 0 and len(self.spy_close_history) > 0:
            ratio = float(self.qqq_close_history[-1]) / float(self.spy_close_history[-1])
            self.qqq_spy_ratio_history.append(ratio)

    def _update_tqqq_obv(self):
        """Compute cumulative OBV for TQQQ."""
        if len(self.tqqq_close_history) < 2:
            self.tqqq_obv_history.append(0)
            return
        price_change = float(self.tqqq_close_history[-1]) - float(self.tqqq_close_history[-2])
        volume = float(self.tqqq_volume_history[-1])
        if price_change > 0:
            obv_delta = volume
        elif price_change < 0:
            obv_delta = -volume
        else:
            obv_delta = 0

        prev_obv = self.tqqq_obv_history[-1] if len(self.tqqq_obv_history) > 0 else 0
        self.tqqq_obv_history.append(prev_obv + obv_delta)

    def _update_qqq_rsi(self, current_close):
        """Compute QQQ RSI(14) using standard Wilder smoothing."""
        if self.qqq_prev_close is None:
            self.qqq_prev_close = current_close
            return

        change = float(current_close) - float(self.qqq_prev_close)
        gain = max(change, 0)
        loss = max(-change, 0)
        self.qqq_prev_close = current_close

        self.qqq_rsi_warmup_count += 1

        if self.qqq_rsi_warmup_count <= 14:
            self.qqq_gain_history.append(gain)
            self.qqq_loss_history.append(loss)
            if self.qqq_rsi_warmup_count == 14:
                self.qqq_avg_gain = sum(self.qqq_gain_history) / 14.0
                self.qqq_avg_loss = sum(self.qqq_loss_history) / 14.0
        else:
            # Wilder smoothing (same as pandas rolling(14).mean() for first, then EMA-like)
            # Note: pandas rolling(14).mean() uses simple rolling average, not Wilder.
            # To match pandas: use simple rolling mean of last 14 values
            self.qqq_gain_history.append(gain)
            self.qqq_loss_history.append(loss)

    def _get_qqq_rsi(self):
        """Get current QQQ RSI using simple 14-period rolling mean (matches pandas)."""
        if len(self.qqq_gain_history) < 14:
            return 50.0  # neutral during warmup
        # Simple rolling mean of last 14 gains/losses (matches pandas rolling(14).mean())
        gains = list(self.qqq_gain_history)[-14:]
        losses = list(self.qqq_loss_history)[-14:]
        avg_gain = sum(gains) / 14.0
        avg_loss = sum(losses) / 14.0
        if avg_loss == 0:
            return 100.0
        rs = avg_gain / avg_loss
        return 100.0 - (100.0 / (1.0 + rs))

    def _update_qqq_sma150(self):
        """Track QQQ SMA150 values for slope calculation."""
        if len(self.qqq_close_history) >= self.QQQ_SMA150_PERIOD:
            sma150 = sum(list(self.qqq_close_history)[-self.QQQ_SMA150_PERIOD:]) / self.QQQ_SMA150_PERIOD
            self.qqq_sma150_history.append(sma150)

    def _get_obv_above_sma(self, period):
        """Check if current OBV > N-period SMA of OBV."""
        if len(self.tqqq_obv_history) < period:
            return False
        obv_values = list(self.tqqq_obv_history)[-period:]
        sma = sum(obv_values) / period
        return self.tqqq_obv_history[-1] > sma

    def _get_qqq_leading_spy(self):
        """Check if QQQ/SPY ratio > its 50-day SMA."""
        if len(self.qqq_spy_ratio_history) < self.QQQ_SPY_RATIO_PERIOD:
            return False
        ratio_values = list(self.qqq_spy_ratio_history)[-self.QQQ_SPY_RATIO_PERIOD:]
        sma = sum(ratio_values) / self.QQQ_SPY_RATIO_PERIOD
        return self.qqq_spy_ratio_history[-1] > sma

    def _get_smh_mom3m_positive(self):
        """Check if SMH 63-day return > 0."""
        if len(self.smh_close_history) < self.SMH_MOM_PERIOD + 1:
            return False
        current = float(self.smh_close_history[-1])
        past = float(self.smh_close_history[-self.SMH_MOM_PERIOD - 1])
        if past == 0:
            return False
        return (current / past - 1) > 0

    def _get_qqq_sma150_slope_positive(self):
        """Check if QQQ SMA150 today > QQQ SMA150 10 days ago."""
        if len(self.qqq_sma150_history) < self.QQQ_SMA150_SLOPE_LOOKBACK + 1:
            return False
        current_sma = self.qqq_sma150_history[-1]
        past_sma = self.qqq_sma150_history[-self.QQQ_SMA150_SLOPE_LOOKBACK - 1]
        return current_sma > past_sma

    def Rebalance(self):
        """Daily end-of-day signal evaluation and position adjustment."""
        if self.IsWarmingUp:
            return

        self.days_elapsed += 1

        # ===== TQQQ SIGNAL =====
        obv_above_sma20 = self._get_obv_above_sma(self.OBV_SMA_SHORT)
        obv_above_sma50 = self._get_obv_above_sma(self.OBV_SMA_LONG)
        qqq_leading_spy = self._get_qqq_leading_spy()
        qqq_rsi = self._get_qqq_rsi()

        # TQQQ base stay-in: OBV>SMA20 AND QQQ_leading_SPY AND OBV>SMA50
        tqqq_stay_in = obv_above_sma20 and qqq_leading_spy and obv_above_sma50
        # Override: RSI < 30 (oversold bounce)
        tqqq_rsi_override = qqq_rsi < self.RSI_REENTRY_THRESHOLD
        # Final: stay_in OR override
        tqqq_in_market = tqqq_stay_in or tqqq_rsi_override

        # ===== SOXL SIGNAL =====
        qqq_rsi_below_75 = qqq_rsi < self.QQQ_RSI_OVERBOUGHT
        smh_mom3m_pos = self._get_smh_mom3m_positive()
        qqq_sma150_slope_pos = self._get_qqq_sma150_slope_positive()

        # SOXL stay-in: all four conditions
        soxl_in_market = (qqq_leading_spy and qqq_rsi_below_75 and
                          smh_mom3m_pos and qqq_sma150_slope_pos)

        # ===== EXECUTE TRADES =====
        self._execute_tqqq_allocation(tqqq_in_market)
        self._execute_soxl_allocation(soxl_in_market)

        # Log signals
        if tqqq_in_market != self.tqqq_in_market or soxl_in_market != self.soxl_in_market:
            self.Debug(f"{self.Time.date()} | TQQQ: {'IN' if tqqq_in_market else 'OUT'} "
                       f"(base={tqqq_stay_in}, rsi_override={tqqq_rsi_override}, RSI={qqq_rsi:.1f}) | "
                       f"SOXL: {'IN' if soxl_in_market else 'OUT'} "
                       f"(leading={qqq_leading_spy}, rsi<75={qqq_rsi_below_75}, "
                       f"smh_mom={smh_mom3m_pos}, slope={qqq_sma150_slope_pos})")

        self.tqqq_in_market = tqqq_in_market
        self.soxl_in_market = soxl_in_market

    def _execute_tqqq_allocation(self, in_market):
        """Allocate TQQQ portion: either TQQQ or 50% GLD + 50% SVXY."""
        alloc = self.TQQQ_ALLOC
        if in_market:
            self.SetHoldings(self.tqqq, alloc)
            # Liquidate defensive sleeve
            if self.Portfolio[self.gld].Invested and self._is_tqqq_sleeve_holder():
                # Only liquidate GLD/SVXY if they belong to TQQQ sleeve
                pass  # Handled below via target percentages
            self._set_tqqq_defensive(0, 0)
        else:
            # Defensive: 50% GLD + 50% SVXY (of the TQQQ allocation)
            if self.Portfolio[self.tqqq].Invested:
                self.Liquidate(self.tqqq)
            self._set_tqqq_defensive(alloc * 0.5, alloc * 0.5)

    def _execute_soxl_allocation(self, in_market):
        """Allocate SOXL portion: either SOXL or 100% GLD."""
        alloc = self.SOXL_ALLOC
        if in_market:
            self.SetHoldings(self.soxl, alloc)
            # Reduce GLD from SOXL portion (if any)
            self._adjust_gld_for_soxl(0)
        else:
            if self.Portfolio[self.soxl].Invested:
                self.Liquidate(self.soxl)
            self._adjust_gld_for_soxl(alloc)

    def _set_tqqq_defensive(self, gld_alloc, svxy_alloc):
        """Set the TQQQ defensive sleeve allocations."""
        # We track desired allocations and combine with SOXL's GLD needs
        self._tqqq_gld_target = gld_alloc
        self._tqqq_svxy_target = svxy_alloc
        self._apply_combined_targets()

    def _adjust_gld_for_soxl(self, gld_alloc):
        """Set the SOXL defensive sleeve GLD allocation."""
        self._soxl_gld_target = gld_alloc
        self._apply_combined_targets()

    def _apply_combined_targets(self):
        """Apply combined GLD + SVXY targets from both strategies."""
        tqqq_gld = getattr(self, '_tqqq_gld_target', 0)
        tqqq_svxy = getattr(self, '_tqqq_svxy_target', 0)
        soxl_gld = getattr(self, '_soxl_gld_target', 0)

        total_gld = tqqq_gld + soxl_gld
        total_svxy = tqqq_svxy

        if total_gld > 0:
            self.SetHoldings(self.gld, total_gld)
        elif self.Portfolio[self.gld].Invested:
            self.Liquidate(self.gld)

        if total_svxy > 0:
            self.SetHoldings(self.svxy, total_svxy)
        elif self.Portfolio[self.svxy].Invested:
            self.Liquidate(self.svxy)

    def _is_tqqq_sleeve_holder(self):
        return not self.tqqq_in_market

    def OnEndOfAlgorithm(self):
        self.Debug(f"Final Portfolio Value: ${self.Portfolio.TotalPortfolioValue:,.2f}")
        self.Debug(f"Days traded: {self.days_elapsed}")


# ==============================================================================
# ALTERNATIVE: Single-Strategy Version (run TQQQ or SOXL independently)
# Use this if you want to verify each strategy's CAGR/DD separately.
# ==============================================================================


class TQQQRegimeRotation(QCAlgorithm):
    """TQQQ-only strategy for isolated verification."""

    def Initialize(self):
        self.SetStartDate(2020, 6, 1)
        self.SetEndDate(2025, 5, 31)
        self.SetCash(100000)

        self.tqqq = self.AddEquity("TQQQ", Resolution.Daily).Symbol
        self.qqq = self.AddEquity("QQQ", Resolution.Daily).Symbol
        self.spy = self.AddEquity("SPY", Resolution.Daily).Symbol
        self.gld = self.AddEquity("GLD", Resolution.Daily).Symbol
        self.svxy = self.AddEquity("SVXY", Resolution.Daily).Symbol

        # Indicator buffers
        self.tqqq_close_hist = deque(maxlen=300)
        self.tqqq_volume_hist = deque(maxlen=300)
        self.tqqq_obv_hist = deque(maxlen=60)
        self.qqq_close_hist = deque(maxlen=200)
        self.spy_close_hist = deque(maxlen=200)
        self.qqq_spy_ratio_hist = deque(maxlen=60)
        self.qqq_gain_hist = deque(maxlen=20)
        self.qqq_loss_hist = deque(maxlen=20)
        self.qqq_prev_close = None
        self.qqq_rsi_count = 0

        self.in_market = True
        self.days_in = 0
        self.days_out = 0

        self.Schedule.On(
            self.DateRules.EveryDay("SPY"),
            self.TimeRules.BeforeMarketClose("SPY", 5),
            self.Rebalance
        )
        self.SetWarmUp(200, Resolution.Daily)

    def OnData(self, data):
        if data.Bars.ContainsKey(self.tqqq):
            bar = data.Bars[self.tqqq]
            self.tqqq_close_hist.append(float(bar.Close))
            self.tqqq_volume_hist.append(float(bar.Volume))
            self._update_obv()

        if data.Bars.ContainsKey(self.qqq):
            close = float(data.Bars[self.qqq].Close)
            self.qqq_close_hist.append(close)
            self._update_rsi(close)

        if data.Bars.ContainsKey(self.spy):
            self.spy_close_hist.append(float(data.Bars[self.spy].Close))

        if len(self.qqq_close_hist) > 0 and len(self.spy_close_hist) > 0:
            self.qqq_spy_ratio_hist.append(self.qqq_close_hist[-1] / self.spy_close_hist[-1])

    def _update_obv(self):
        if len(self.tqqq_close_hist) < 2:
            self.tqqq_obv_hist.append(0)
            return
        diff = self.tqqq_close_hist[-1] - self.tqqq_close_hist[-2]
        vol = self.tqqq_volume_hist[-1]
        delta = vol if diff > 0 else (-vol if diff < 0 else 0)
        prev = self.tqqq_obv_hist[-1] if self.tqqq_obv_hist else 0
        self.tqqq_obv_hist.append(prev + delta)

    def _update_rsi(self, close):
        if self.qqq_prev_close is None:
            self.qqq_prev_close = close
            return
        change = close - self.qqq_prev_close
        self.qqq_prev_close = close
        self.qqq_gain_hist.append(max(change, 0))
        self.qqq_loss_hist.append(max(-change, 0))
        self.qqq_rsi_count += 1

    def _get_rsi(self):
        if len(self.qqq_gain_hist) < 14:
            return 50.0
        gains = list(self.qqq_gain_hist)[-14:]
        losses = list(self.qqq_loss_hist)[-14:]
        avg_g = sum(gains) / 14.0
        avg_l = sum(losses) / 14.0
        if avg_l == 0:
            return 100.0
        return 100.0 - 100.0 / (1.0 + avg_g / avg_l)

    def _obv_above_sma(self, period):
        if len(self.tqqq_obv_hist) < period:
            return False
        vals = list(self.tqqq_obv_hist)[-period:]
        return self.tqqq_obv_hist[-1] > (sum(vals) / period)

    def _qqq_leading_spy(self):
        if len(self.qqq_spy_ratio_hist) < 50:
            return False
        vals = list(self.qqq_spy_ratio_hist)[-50:]
        return self.qqq_spy_ratio_hist[-1] > (sum(vals) / 50)

    def Rebalance(self):
        if self.IsWarmingUp:
            return

        obv20 = self._obv_above_sma(20)
        obv50 = self._obv_above_sma(50)
        leading = self._qqq_leading_spy()
        rsi = self._get_rsi()

        stay_in = obv20 and leading and obv50
        override = rsi < 30
        in_market = stay_in or override

        if in_market:
            self.SetHoldings(self.tqqq, 1.0)
            if self.Portfolio[self.gld].Invested:
                self.Liquidate(self.gld)
            if self.Portfolio[self.svxy].Invested:
                self.Liquidate(self.svxy)
            self.days_in += 1
        else:
            if self.Portfolio[self.tqqq].Invested:
                self.Liquidate(self.tqqq)
            self.SetHoldings(self.gld, 0.5)
            self.SetHoldings(self.svxy, 0.5)
            self.days_out += 1

        if in_market != self.in_market:
            self.Debug(f"{self.Time.date()} TQQQ {'ENTER' if in_market else 'EXIT'} | "
                       f"OBV20={obv20} OBV50={obv50} Leading={leading} RSI={rsi:.1f} Override={override}")
        self.in_market = in_market

    def OnEndOfAlgorithm(self):
        total = self.days_in + self.days_out
        tim = self.days_in / total * 100 if total > 0 else 0
        self.Debug(f"TQQQ Final: ${self.Portfolio.TotalPortfolioValue:,.2f} | "
                   f"TIM={tim:.1f}% ({self.days_in}/{total} days)")


class SOXLRegimeRotation(QCAlgorithm):
    """SOXL-only strategy for isolated verification."""

    def Initialize(self):
        self.SetStartDate(2020, 6, 1)
        self.SetEndDate(2025, 5, 31)
        self.SetCash(100000)

        self.soxl = self.AddEquity("SOXL", Resolution.Daily).Symbol
        self.qqq = self.AddEquity("QQQ", Resolution.Daily).Symbol
        self.spy = self.AddEquity("SPY", Resolution.Daily).Symbol
        self.smh = self.AddEquity("SMH", Resolution.Daily).Symbol
        self.gld = self.AddEquity("GLD", Resolution.Daily).Symbol

        # Indicator buffers
        self.qqq_close_hist = deque(maxlen=200)
        self.spy_close_hist = deque(maxlen=200)
        self.smh_close_hist = deque(maxlen=70)
        self.qqq_spy_ratio_hist = deque(maxlen=60)
        self.qqq_gain_hist = deque(maxlen=20)
        self.qqq_loss_hist = deque(maxlen=20)
        self.qqq_prev_close = None
        self.qqq_rsi_count = 0
        self.qqq_sma150_hist = deque(maxlen=15)

        self.in_market = True
        self.days_in = 0
        self.days_out = 0

        self.Schedule.On(
            self.DateRules.EveryDay("SPY"),
            self.TimeRules.BeforeMarketClose("SPY", 5),
            self.Rebalance
        )
        self.SetWarmUp(200, Resolution.Daily)

    def OnData(self, data):
        if data.Bars.ContainsKey(self.qqq):
            close = float(data.Bars[self.qqq].Close)
            self.qqq_close_hist.append(close)
            self._update_rsi(close)
            self._update_sma150()

        if data.Bars.ContainsKey(self.spy):
            self.spy_close_hist.append(float(data.Bars[self.spy].Close))

        if data.Bars.ContainsKey(self.smh):
            self.smh_close_hist.append(float(data.Bars[self.smh].Close))

        if len(self.qqq_close_hist) > 0 and len(self.spy_close_hist) > 0:
            self.qqq_spy_ratio_hist.append(self.qqq_close_hist[-1] / self.spy_close_hist[-1])

    def _update_rsi(self, close):
        if self.qqq_prev_close is None:
            self.qqq_prev_close = close
            return
        change = close - self.qqq_prev_close
        self.qqq_prev_close = close
        self.qqq_gain_hist.append(max(change, 0))
        self.qqq_loss_hist.append(max(-change, 0))
        self.qqq_rsi_count += 1

    def _get_rsi(self):
        if len(self.qqq_gain_hist) < 14:
            return 50.0
        gains = list(self.qqq_gain_hist)[-14:]
        losses = list(self.qqq_loss_hist)[-14:]
        avg_g = sum(gains) / 14.0
        avg_l = sum(losses) / 14.0
        if avg_l == 0:
            return 100.0
        return 100.0 - 100.0 / (1.0 + avg_g / avg_l)

    def _update_sma150(self):
        if len(self.qqq_close_hist) >= 150:
            sma = sum(list(self.qqq_close_hist)[-150:]) / 150.0
            self.qqq_sma150_hist.append(sma)

    def _qqq_leading_spy(self):
        if len(self.qqq_spy_ratio_hist) < 50:
            return False
        vals = list(self.qqq_spy_ratio_hist)[-50:]
        return self.qqq_spy_ratio_hist[-1] > (sum(vals) / 50)

    def _smh_mom3m_positive(self):
        if len(self.smh_close_hist) < 64:
            return False
        return self.smh_close_hist[-1] > self.smh_close_hist[-64]

    def _qqq_sma150_slope_positive(self):
        if len(self.qqq_sma150_hist) < 11:
            return False
        return self.qqq_sma150_hist[-1] > self.qqq_sma150_hist[-11]

    def Rebalance(self):
        if self.IsWarmingUp:
            return

        leading = self._qqq_leading_spy()
        rsi_ok = self._get_rsi() < 75
        smh_mom = self._smh_mom3m_positive()
        slope = self._qqq_sma150_slope_positive()

        in_market = leading and rsi_ok and smh_mom and slope

        if in_market:
            self.SetHoldings(self.soxl, 1.0)
            if self.Portfolio[self.gld].Invested:
                self.Liquidate(self.gld)
            self.days_in += 1
        else:
            if self.Portfolio[self.soxl].Invested:
                self.Liquidate(self.soxl)
            self.SetHoldings(self.gld, 1.0)
            self.days_out += 1

        if in_market != self.in_market:
            self.Debug(f"{self.Time.date()} SOXL {'ENTER' if in_market else 'EXIT'} | "
                       f"Leading={leading} RSI<75={rsi_ok} SMH_mom={smh_mom} Slope={slope}")
        self.in_market = in_market

    def OnEndOfAlgorithm(self):
        total = self.days_in + self.days_out
        tim = self.days_in / total * 100 if total > 0 else 0
        self.Debug(f"SOXL Final: ${self.Portfolio.TotalPortfolioValue:,.2f} | "
                   f"TIM={tim:.1f}% ({self.days_in}/{total} days)")