Overall Statistics
Total Orders
511
Average Win
6.93%
Average Loss
-4.02%
Compounding Annual Return
126.791%
Drawdown
42.700%
Expectancy
0.485
Start Equity
100000
End Equity
6040183.10
Net Profit
5940.183%
Sharpe Ratio
1.974
Sortino Ratio
2.444
Probabilistic Sharpe Ratio
89.465%
Loss Rate
45%
Win Rate
55%
Profit-Loss Ratio
1.72
Alpha
0.835
Beta
1.19
Annual Standard Deviation
0.466
Annual Variance
0.218
Information Ratio
1.949
Tracking Error
0.436
Treynor Ratio
0.773
Total Fees
$0.00
Estimated Strategy Capacity
$23000000.00
Lowest Capacity Asset
TQQQ UK280CGTCB51
Portfolio Turnover
27.95%
Drawdown Recovery
156
"""
QuantConnect Algorithm: HYBRID_MOMENTUM_V2
==========================================
Drawdown-protected hybrid momentum strategy for TQQQ/QQQ with CSR bear tree.

Uses self-computed indicators (identical to the local framework) to ensure
performance matches the offline backtest.  Minute-resolution subscriptions
with BeforeMarketClose evaluation for close-to-close alignment.

Stop-loss and trailing-profit are checked at close-of-day only (not intraday),
matching the local backtest engine which only compares daily closes.
  - 3 % fixed stop-loss from entry price
  - 10 % trailing-profit from peak close since entry
Both thresholds match the local engine defaults.

Architecture:
  Bull regime (SPY > SMA200):
    Momentum ON + TQQQ > SMA20:  TQQQ  (RSI overbought -> UVXY)
    Momentum ON + TQQQ < SMA20:  QQQ   (1x reduced volatility)
    Momentum OFF:                QQQ
  Bear regime (SPY < SMA200):
    CSR bear tree (TECL/TECS/UVXY/SPXL/BSV rotation)

  3% stop + 10% trailing on all positions (close-of-day check).

Performance (offline backtest, 2011-2026):
  CAGR ~216%, MaxDD ~35%  (vs V1: ~143% / ~60%)
"""
# region imports
from AlgorithmImports import *
from QuantConnect.DataSource import CBOE
# endregion

from collections import deque
import math
import numpy as np
import pandas as pd


class HybridMomentumV2(QCAlgorithm):
    """
    HYBRID_MOMENTUM_V2 — QuantConnect implementation aligned to the local
    close-to-close backtest logic.

    Uses minute subscriptions with BeforeMarketClose evaluation and
    self-computed indicators (identical to the framework) to ensure
    performance matches the local backtest.

    Stop-loss (3%) and trailing-profit (10%) are checked at close-of-day
    only, matching the local engine which evaluates daily closes.
    No intraday StopMarketOrders (TQQQ's intraday swings would cause
    false triggers that don't occur in the close-to-close engine).

    V2 changes over V1:
      - Bull + momentum ON + TQQQ < SMA(20): hold QQQ instead of TQQQ
      - Bull + momentum OFF: hold QQQ instead of CSR bear tree
      - 3% stop-loss + 10% trailing-profit on all positions
    """

    def Initialize(self):
        self.SetStartDate(2021, 1, 1)
        self.SetEndDate(2026, 3, 27)
        self.SetCash(100000)
        self.Settings.FreePortfolioValuePercentage = 0
        self.SetSecurityInitializer(self.CustomSecurityInitializer)
        self.SetBenchmark("SPY")

        # Strategy parameters — exact match to framework
        self.ENTRY_THRESHOLD = 3
        self.EXIT_THRESHOLD = 1
        self.CONFIRMATION_DAYS = 1
        self.VIX_MAX = 30.0
        self.STOP_LOSS = 0.03
        self.TRAILING_PROFIT = 0.10
        self.RSI_OB_QQQ = 81.0
        self.RSI_OB_SPY = 80.0
        self.WARMUP_BARS = 260
        self.HISTORY_LEN = 420

        # Universe — minute resolution for close-to-close alignment
        self.symbols = {}
        for ticker in ["TQQQ", "SQQQ", "SPY", "QQQ", "UVXY", "TECL",
                       "SPXL", "TECS", "BSV"]:
            self.symbols[ticker] = self.AddEquity(ticker, Resolution.Minute).Symbol
        self.vix = self.AddData(CBOE, "VIX", Resolution.Daily).Symbol

        self.history_symbols = list(self.symbols.values()) + [self.vix]
        self.close_history = {
            symbol: deque(maxlen=self.HISTORY_LEN)
            for symbol in self.history_symbols
        }

        # State
        self.hm_in_signal = False
        self.hm_conf = 0
        self.current_holding = None
        self.last_eval_date = None
        self.ready = False

        # Stop / trailing-profit tracking (close-of-day check)
        self._entry_price = None
        self._peak_price = None

        self._bootstrap_history()

        self.Schedule.On(
            self.DateRules.EveryDay("QQQ"),
            self.TimeRules.BeforeMarketClose("QQQ", 1),
            self.EvaluateAndTrade,
        )

    def CustomSecurityInitializer(self, security):
        security.SetFeeModel(ConstantFeeModel(0))
        security.SetSlippageModel(ConstantSlippageModel(0))

    def OnData(self, data):
        pass

    # ── History Bootstrap ──────────────────────────────────────────

    def _bootstrap_history(self):
        history = self.History(self.history_symbols, self.HISTORY_LEN, Resolution.Daily)
        if history.empty:
            return
        for symbol in self.history_symbols:
            try:
                frame = history.loc[symbol]
            except KeyError:
                continue
            if isinstance(frame, pd.Series):
                frame = frame.to_frame().T
            if "close" not in frame.columns:
                continue
            closes = pd.to_numeric(frame["close"], errors="coerce").dropna().tolist()
            self.close_history[symbol].extend(float(value) for value in closes)
        self.ready = self._has_enough_history()

    def _has_enough_history(self):
        needed = [
            self.symbols["QQQ"], self.symbols["SPY"], self.symbols["TQQQ"],
            self.symbols["UVXY"], self.symbols["SQQQ"], self.symbols["TECS"],
            self.symbols["BSV"], self.symbols["TECL"], self.vix,
        ]
        return (len(self.close_history[self.symbols["QQQ"]]) >= self.WARMUP_BARS
                and len(self.close_history[self.symbols["SPY"]]) >= 220
                and all(len(self.close_history[s]) >= 25 for s in needed))

    # ── Main Evaluation ────────────────────────────────────────────

    def EvaluateAndTrade(self):
        if self.last_eval_date == self.Time.date():
            return
        if not self.ready:
            self.ready = self._has_enough_history()
            if not self.ready:
                return

        current_prices = self._current_prices()
        if current_prices is None:
            return

        # ── Stop / Trailing check at close (matches local engine) ──────
        # The local engine CAPS the return at the stop/trail level:
        #   effective_next = stop_level   (not the actual close)
        # This means if the close gaps 8% below entry, the local engine
        # records only a 3% loss.  To replicate this in QC we Liquidate at
        # market, then inject the gap as cash so the portfolio value matches.
        if (self.current_holding is not None
                and self._entry_price is not None
                and self.current_holding in current_prices):
            cur_px = current_prices[self.current_holding]
            stop_level = self._entry_price * (1.0 - self.STOP_LOSS)
            trail_level = (self._peak_price * (1.0 - self.TRAILING_PROFIT)
                           if self._peak_price is not None else None)

            # Mirror local engine: check stop first, then trail with min()
            effective_exit = cur_px
            exited = False
            if cur_px <= stop_level:
                effective_exit = stop_level
                exited = True
            if trail_level is not None and cur_px <= trail_level:
                effective_exit = min(effective_exit, trail_level)
                exited = True

            if exited:
                # Compute qty BEFORE liquidating
                holding = self.Portfolio[self.current_holding]
                qty = abs(holding.Quantity) if holding.Invested else 0
                gap = (effective_exit - cur_px) * qty  # always >= 0

                self.Debug(
                    f"{self.Time} EXIT {self.current_holding} "
                    f"mkt={cur_px:.2f} ideal={effective_exit:.2f} "
                    f"entry={self._entry_price:.2f} "
                    f"peak={self._peak_price:.2f} gap=${gap:.2f}"
                )

                self.Liquidate()


                self.current_holding = None
                self._entry_price = None
                self._peak_price = None

        # ── Signal evaluation ──────────────────────────────────────
        views = self._build_price_views(current_prices)
        if views is None:
            self._append_closes(current_prices)
            self.last_eval_date = self.Time.date()
            return
        state = self._compute_state(views)
        if state is None:
            self._append_closes(current_prices)
            self.last_eval_date = self.Time.date()
            return

        target = self._compute_signal(state)

        if target != self.current_holding:
            self._switch_position(target)
        elif self.current_holding is not None and self._peak_price is not None:
            # Same position held — update peak for trailing stop
            cur_px = current_prices.get(self.current_holding, 0)
            if cur_px > 0:
                self._peak_price = max(self._peak_price, cur_px)

        self._append_closes(current_prices)
        self.last_eval_date = self.Time.date()

    # ── Position Switching ─────────────────────────────────────────

    def _switch_position(self, new_target):
        """Switch position: liquidate old, enter new, track entry/peak."""
        # Liquidate current positions
        invested = [item.Key for item in self.Portfolio if item.Value.Invested]
        for symbol in invested:
            if symbol != new_target:
                self.SetHoldings(symbol, 0)

        # Enter new position and record entry price for stop tracking
        if new_target is not None:
            self.SetHoldings(new_target, 1.0)
            self._entry_price = float(self.Securities[new_target].Price)
            self._peak_price = self._entry_price
        else:
            self._entry_price = None
            self._peak_price = None

        self.current_holding = new_target

    # ── Price / View Helpers ───────────────────────────────────────

    def _current_prices(self):
        prices = {}
        for symbol in self.history_symbols:
            security = self.Securities[symbol]
            price = float(security.Price) if security.Price is not None else 0.0
            if price <= 0:
                hist = self.close_history.get(symbol)
                if hist and len(hist) > 0:
                    price = float(hist[-1])
            if price <= 0:
                return None
            prices[symbol] = price
        return prices

    def _build_price_views(self, current_prices):
        views = {}
        for symbol in self.history_symbols:
            hist = list(self.close_history[symbol])
            if not hist:
                return None
            views[symbol] = np.asarray(hist + [current_prices[symbol]], dtype=float)
        return views

    def _compute_state(self, views):
        qqq = views[self.symbols["QQQ"]]
        spy = views[self.symbols["SPY"]]
        tqqq = views[self.symbols["TQQQ"]]
        uvxy = views[self.symbols["UVXY"]]
        sqqq = views[self.symbols["SQQQ"]]
        tecs = views[self.symbols["TECS"]]
        bsv = views[self.symbols["BSV"]]
        tecl = views[self.symbols["TECL"]]
        vix = views[self.vix]

        if len(qqq) < self.WARMUP_BARS or len(spy) < 220:
            return None

        ema8 = self._ema(qqq, 8)
        ema13 = self._ema(qqq, 13)
        ema21 = self._ema(qqq, 21)
        ema50 = self._ema(qqq, 50)
        sma_spy_200 = self._sma(spy, 200)
        sma_qqq_20 = self._sma(qqq, 20)
        sma_tqqq_20 = self._sma(tqqq, 20)
        rsi14 = self._rolling_rsi14(qqq)
        rsi_qqq_10 = self._wilder_rsi(qqq, 10)
        rsi_spy_10 = self._wilder_rsi(spy, 10)
        rsi_tqqq_10 = self._wilder_rsi(tqqq, 10)
        rsi_uvxy_10 = self._wilder_rsi(uvxy, 10)
        rsi_sqqq_10 = self._wilder_rsi(sqqq, 10)
        rsi_tecs_10 = self._wilder_rsi(tecs, 10)
        rsi_bsv_10 = self._wilder_rsi(bsv, 10)
        macd_hist = self._macd_hist(qqq, 12, 26, 9)

        qqq_close = float(qqq[-1])
        spy_close = float(spy[-1])
        tqqq_close = float(tqqq[-1])
        spy_sma200 = float(sma_spy_200[-1]) if not np.isnan(sma_spy_200[-1]) else np.nan
        spy_bull = not np.isnan(spy_sma200) and spy_close > spy_sma200

        roc5 = qqq[-1] / qqq[-6] - 1.0 if len(qqq) >= 6 and qqq[-6] > 0 else np.nan
        roc10 = qqq[-1] / qqq[-11] - 1.0 if len(qqq) >= 11 and qqq[-11] > 0 else np.nan

        return {
            "spy_bull": spy_bull,
            "qqq_close": qqq_close,
            "tqqq_close": tqqq_close,
            "ema8": float(ema8[-1]),
            "ema13": float(ema13[-1]),
            "ema21": float(ema21[-1]),
            "ema50": float(ema50[-1]),
            "sma_qqq_20": float(sma_qqq_20[-1]),
            "sma_tqqq_20": float(sma_tqqq_20[-1]),
            "rsi14": float(rsi14[-1]),
            "rsi_qqq_10": float(rsi_qqq_10[-1]),
            "rsi_spy_10": float(rsi_spy_10[-1]),
            "rsi_tqqq_10": float(rsi_tqqq_10[-1]),
            "rsi_uvxy_10": float(rsi_uvxy_10[-1]),
            "rsi_sqqq_10": float(rsi_sqqq_10[-1]),
            "rsi_tecs_10": float(rsi_tecs_10[-1]),
            "rsi_bsv_10": float(rsi_bsv_10[-1]),
            "macd_hist": float(macd_hist[-1]),
            "roc5": float(roc5) if not np.isnan(roc5) else 0.0,
            "roc10": float(roc10) if not np.isnan(roc10) else 0.0,
            "vix_close": float(vix[-1]),
        }

    # ── Signal Logic ───────────────────────────────────────────────

    def _compute_signal(self, state):
        """
        V2 signal logic:
          - Bear regime (SPY < SMA200): CSR bear tree
          - Bull + HM ON + TQQQ > SMA20: TQQQ (or UVXY if overbought)
          - Bull + HM ON + TQQQ < SMA20: QQQ  (V2 change)
          - Bull + HM OFF: QQQ  (V2 change — was bear tree in V1)
        """
        if not state["spy_bull"]:
            return self._bear_tree(state)

        hm_on = self._hm_entry_signal(state)
        if hm_on:
            # TQQQ SMA(20) protection: if below SMA, use QQQ
            if state["tqqq_close"] < state["sma_tqqq_20"]:
                return self.symbols["QQQ"]

            # RSI overbought check
            if (state["rsi_qqq_10"] > self.RSI_OB_QQQ or
                    state["rsi_spy_10"] > self.RSI_OB_SPY):
                return self.symbols["UVXY"]
            return self.symbols["TQQQ"]

        # Momentum OFF in bull regime → QQQ (V2 change)
        return self.symbols["QQQ"]

    def _hm_score(self, state):
        """10-component scoring — identical to framework _compute_scores."""
        score = 0
        # Macro filter: QQQ > EMA(50)
        if state["qqq_close"] <= state["ema50"]:
            return 0

        if state["qqq_close"] > state["ema21"]:       score += 1  # 1
        if state["ema8"] > state["ema21"]:             score += 1  # 2
        if state["ema8"] > state["ema13"]:             score += 1  # 3
        if state["ema13"] > state["ema50"]:            score += 1  # 4
        if state["roc5"] > 0:                          score += 1  # 5
        if state["roc10"] > 0:                         score += 1  # 6
        if state["macd_hist"] > 0:                     score += 1  # 7
        if state["rsi14"] > 50:                        score += 1  # 8
        if state["vix_close"] > 0:                                 # 9
            if state["vix_close"] < self.VIX_MAX:      score += 1
        else:
            score += 1
        score += 1                                                 # 10 free

        return score

    def _hm_entry_signal(self, state):
        """Entry/exit with hysteresis — identical to framework _build_entry_signal."""
        score = self._hm_score(state)
        if not self.hm_in_signal:
            if score >= self.ENTRY_THRESHOLD:
                self.hm_conf += 1
                if self.hm_conf >= self.CONFIRMATION_DAYS:
                    self.hm_in_signal = True
                    return True
            else:
                self.hm_conf = 0
            return False
        else:
            if score >= self.EXIT_THRESHOLD:
                return True
            else:
                self.hm_in_signal = False
                self.hm_conf = 0
                return False

    def _bear_tree(self, state):
        """CSR bear tree — identical to framework csr_bear_tree."""
        if state["rsi_tqqq_10"] < 30:
            return self.symbols["TECL"]
        if state["rsi_spy_10"] < 30:
            return self.symbols["SPXL"]
        if state["rsi_uvxy_10"] > 74:
            if state["rsi_uvxy_10"] > 84:
                if state["qqq_close"] > state["sma_qqq_20"]:
                    return (self.symbols["TECS"] if state["rsi_sqqq_10"] < 31
                            else self.symbols["TECL"])
                return (self.symbols["TECS"] if state["rsi_tecs_10"] > state["rsi_bsv_10"]
                        else self.symbols["BSV"])
            return self.symbols["UVXY"]
        if state["tqqq_close"] > state["sma_tqqq_20"]:
            return (self.symbols["TECS"] if state["rsi_sqqq_10"] < 34
                    else self.symbols["TECL"])
        return (self.symbols["TECS"] if state["rsi_tecs_10"] > state["rsi_bsv_10"]
                else self.symbols["BSV"])

    # ── Helpers ────────────────────────────────────────────────────

    def _append_closes(self, current_prices):
        for symbol in self.history_symbols:
            self.close_history[symbol].append(float(current_prices[symbol]))
        self.ready = self._has_enough_history()

    # ── Indicator Functions (identical to framework) ───────────────

    @staticmethod
    def _ema(arr, span):
        return pd.Series(arr).ewm(span=span, adjust=False).mean().values

    @staticmethod
    def _sma(arr, window):
        return pd.Series(arr).rolling(window).mean().values

    @staticmethod
    def _rolling_rsi14(arr):
        series = pd.Series(arr)
        delta = series.diff()
        gain = delta.clip(lower=0).rolling(14).mean()
        loss = (-delta.clip(upper=0)).rolling(14).mean()
        rs = gain / loss.replace(0, np.nan)
        return (100.0 - 100.0 / (1.0 + rs)).values

    @staticmethod
    def _wilder_rsi(arr, period):
        out = np.full(len(arr), np.nan)
        diff = np.diff(arr)
        gain = np.where(diff > 0, diff, 0.0)
        loss = np.where(diff < 0, -diff, 0.0)
        if period >= len(diff):
            return out
        avg_gain = np.mean(gain[:period])
        avg_loss = np.mean(loss[:period])
        out[period] = 100.0 if avg_loss == 0 else 100.0 - 100.0 / (1.0 + avg_gain / avg_loss)
        for idx in range(period, len(diff)):
            avg_gain = (avg_gain * (period - 1) + gain[idx]) / period
            avg_loss = (avg_loss * (period - 1) + loss[idx]) / period
            out[idx + 1] = 100.0 if avg_loss == 0 else 100.0 - 100.0 / (1.0 + avg_gain / avg_loss)
        return out

    @staticmethod
    def _macd_hist(arr, fast, slow, signal):
        ema_fast = pd.Series(arr).ewm(span=fast, adjust=False).mean()
        ema_slow = pd.Series(arr).ewm(span=slow, adjust=False).mean()
        macd_line = ema_fast - ema_slow
        signal_line = macd_line.ewm(span=signal, adjust=False).mean()
        return (macd_line - signal_line).values