Overall Statistics
Total Orders
1025
Average Win
3.32%
Average Loss
-1.95%
Compounding Annual Return
129.039%
Drawdown
31.900%
Expectancy
0.598
Start Equity
100000
End Equity
14393116.93
Net Profit
14293.117%
Sharpe Ratio
2.008
Sortino Ratio
2.261
Probabilistic Sharpe Ratio
91.367%
Loss Rate
41%
Win Rate
59%
Profit-Loss Ratio
1.70
Alpha
0.875
Beta
0.656
Annual Standard Deviation
0.468
Annual Variance
0.219
Information Ratio
1.826
Tracking Error
0.461
Treynor Ratio
1.433
Total Fees
$36420.66
Estimated Strategy Capacity
$280000000.00
Lowest Capacity Asset
GLD T3SKPOF94JFP
Portfolio Turnover
25.27%
Drawdown Recovery
525
# ==============================================================================
# QuantConnect — K-of-N Regime Rotation (TQQQ + SOXL) with Inverse Defensive
# ==============================================================================
# CRITICAL FIXES:
#   1. VIX accessed via AddIndex (not AddEquity — VIX is a CBOE index, not equity)
#   2. VIX data accessed via data[symbol] (not data.Bars — indices aren't in Bars)
#   3. Defensive sleeve uses SQQQ/SOXS inverse ETFs for CAGR boost
#
# TQQQ: 6/8 K-of-N voting | SQQQ defensive | Target: CAGR>100%, DD<30%
#   Atoms: QQQ_leading_SPY(50), QQQ_beat_SPY_42d, SMH_mom63d>0,
#          QQQ_SMA200_slope10, QQQ_RSI5<75, VIX<SMA50, VIX_falling(20), QQQ_RSI5<70
#
# SOXL: 5/6 K-of-N voting | SOXS defensive | Target: CAGR>100%, DD<30%
#   Atoms: QQQ_leading_SPY(50), T>SMA150, VIX_falling(20), stoch_K<80,
#          SMH_mom63d>0, QQQ_SMA150_slope10
#
# ALL indicators are PRICE-BASED. No volume/OBV dependency.
# Inverse ETFs profit during bear markets when K-of-N correctly signals OUT.
# ==============================================================================

from AlgorithmImports import *
from collections import deque

class SOXLKofNStrategy(QCAlgorithm):
    """SOXL 5-of-6 K-of-N voting with SOXS inverse defensive sleeve."""

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

        self.soxl = self.AddEquity("SOXL", Resolution.Daily).Symbol
        self.soxs = self.AddEquity("SOXS", 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
        # VIX is a CBOE INDEX, not an equity — must use AddIndex
        self.vix = self.AddIndex("VIX", Resolution.Daily).Symbol

        # History buffers
        self.soxl_hist = deque(maxlen=160)
        self.qqq_hist = deque(maxlen=160)
        self.spy_hist = deque(maxlen=55)
        self.smh_hist = deque(maxlen=70)
        self.vix_hist = deque(maxlen=25)
        self.qqq_spy_ratio_hist = deque(maxlen=55)
        self.qqq_sma150_hist = deque(maxlen=15)

        self.K_THRESHOLD = 5  # Need 5 of 6 atoms true
        self.in_market = True
        self.days_in = 0
        self.days_out = 0

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

    def OnData(self, data):
        if data.Bars.ContainsKey(self.soxl):
            self.soxl_hist.append(float(data.Bars[self.soxl].Close))
        if data.Bars.ContainsKey(self.qqq):
            self.qqq_hist.append(float(data.Bars[self.qqq].Close))
            self._update_sma150()
        if data.Bars.ContainsKey(self.spy):
            self.spy_hist.append(float(data.Bars[self.spy].Close))
        if data.Bars.ContainsKey(self.smh):
            self.smh_hist.append(float(data.Bars[self.smh].Close))
        # VIX is an INDEX — access via data[symbol], NOT data.Bars
        if data.ContainsKey(self.vix) and data[self.vix] is not None:
            self.vix_hist.append(float(data[self.vix].Close))
        if len(self.qqq_hist) > 0 and len(self.spy_hist) > 0:
            self.qqq_spy_ratio_hist.append(self.qqq_hist[-1] / self.spy_hist[-1])

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

    def _qqq_leading_spy(self):
        """QQQ/SPY ratio > 50-day SMA of ratio."""
        if len(self.qqq_spy_ratio_hist) < 50:
            return False
        vals = list(self.qqq_spy_ratio_hist)[-50:]
        return vals[-1] > sum(vals) / 50.0

    def _soxl_above_sma150(self):
        """SOXL close > 150-day SMA of SOXL close."""
        if len(self.soxl_hist) < 150:
            return False
        vals = list(self.soxl_hist)[-150:]
        return vals[-1] > sum(vals) / 150.0

    def _vix_falling(self):
        """VIX < 20-day SMA of VIX."""
        if len(self.vix_hist) < 20:
            return False
        vals = list(self.vix_hist)[-20:]
        return vals[-1] < sum(vals) / 20.0

    def _stoch_k_below_80(self):
        """QQQ 14-day stochastic %K < 80."""
        if len(self.qqq_hist) < 14:
            return True
        vals = list(self.qqq_hist)[-14:]
        low14 = min(vals)
        high14 = max(vals)
        if high14 == low14:
            return True
        stoch_k = 100.0 * (vals[-1] - low14) / (high14 - low14)
        return stoch_k < 80.0

    def _smh_mom63d_pos(self):
        """SMH 63-day return > 0."""
        if len(self.smh_hist) < 64:
            return False
        return self.smh_hist[-1] > self.smh_hist[-64]

    def _qqq_sma150_slope10(self):
        """QQQ 150-day SMA today > QQQ 150-day SMA 10 days ago."""
        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

        # Compute all 6 atoms
        score = 0
        score += int(self._qqq_leading_spy())       # Atom 1
        score += int(self._soxl_above_sma150())     # Atom 2: T>SMA150
        score += int(self._vix_falling())           # Atom 3
        score += int(self._stoch_k_below_80())      # Atom 4
        score += int(self._smh_mom63d_pos())        # Atom 5
        score += int(self._qqq_sma150_slope10())    # Atom 6

        # TIERED REGIME:
        #   score >= 5: BULLISH → 100% SOXL
        #   score 3-4:  MILD OUT → 100% GLD (no inverse — avoids whipsaw)
        #   score <= 2: STRONG BEAR → 20% SOXS + 80% GLD (confirmed downturn)
        in_market = score >= self.K_THRESHOLD

        if in_market:
            # BULLISH: 100% SOXL
            self.SetHoldings(self.soxl, 1.0)
            if self.Portfolio[self.soxs].Invested:
                self.Liquidate(self.soxs)
            if self.Portfolio[self.gld].Invested:
                self.Liquidate(self.gld)
            self.days_in += 1
        elif score <= 2:
            # STRONG BEAR: only deploy SOXS when firmly bearish
            if self.Portfolio[self.soxl].Invested:
                self.Liquidate(self.soxl)
            self.SetHoldings(self.soxs, 0.20)
            self.SetHoldings(self.gld, 0.80)
            self.days_out += 1
        else:
            # MILD OUT (score 3-4): safe haven only, no inverse
            if self.Portfolio[self.soxl].Invested:
                self.Liquidate(self.soxl)
            if self.Portfolio[self.soxs].Invested:
                self.Liquidate(self.soxs)
            self.SetHoldings(self.gld, 1.0)
            self.days_out += 1

        if in_market != self.in_market:
            self.Debug(f"{self.Time.date()} SOXL {'IN' if in_market else 'OUT'} score={score}/6")
        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})")