Overall Statistics
Total Orders
12381
Average Win
0.14%
Average Loss
-0.07%
Compounding Annual Return
71.659%
Drawdown
21.200%
Expectancy
0.761
Start Equity
100000
End Equity
1939781.53
Net Profit
1839.782%
Sharpe Ratio
2.034
Sortino Ratio
2.561
Probabilistic Sharpe Ratio
98.977%
Loss Rate
41%
Win Rate
59%
Profit-Loss Ratio
2.01
Alpha
0.38
Beta
0.986
Annual Standard Deviation
0.221
Annual Variance
0.049
Information Ratio
2.195
Tracking Error
0.173
Treynor Ratio
0.456
Total Fees
$18516.51
Estimated Strategy Capacity
$43000000.00
Lowest Capacity Asset
BTAL UZWBUH9JN52D
Portfolio Turnover
7.79%
Drawdown Recovery
105
# =============================================================================
# Growth-v4 — 4-sleeve deployable book, ONE daily account, fund-of-funds netting.
# =============================================================================
# Sleeves + weights (gross sums to ~1.0 -> never borrows):
#   gen263 momentum ............ 25%   (CHAMP_gen263_momentum_friction)
#   531 momentum-breadth ....... 25%   (S531 / #285 authoritative, BIL cash sweep)
#   A4 reversal ................ 25%   (S538 Alpha101_4 mcap-weighted, piggybacks univ)
#   529 / 504cg LETF engine .... 25%   (S529 QuadEnsemble + QQQ velocity crash guard)
#
# NETTING (fund-of-funds): each sleeve WRITES an in-bucket target dict summing
# <= 1.0 within its bucket. The harness scales each dict by its sleeve weight and
# sums into ONE net weight per symbol:
#   net[s] = 0.25*gen263[s] + 0.25*531[s] + 0.25*A4[s] + 0.25*504cg[s]
# Max gross = 0.25 + 0.25 + 0.25 + 0.25 = 1.00 <= 1.0  (never borrows).
#
# EXECUTION: decision on T-close -> MarketOnOpenOrder -> fills T+1 open ONLY
# (no set_holdings / market_order = no look-ahead). Sells (exits + reductions)
# are submitted before buys. Risk-off / empty branches set that sleeve's dict = {}
# (531 sweeps to its BIL hedge), never a book-wide liquidate.
#
# Derived from Growth-v2 by removing the kinfo regime sleeve; the gen263, 531, A4
# and 504cg sleeves + the netting/MOO execution harness are preserved verbatim.
# =============================================================================
from AlgorithmImports import *
from collections import defaultdict, deque
import numpy as np
import pandas as pd


# ---- shared fundamental universe (VERBATIM; serves gen263 + 531 + A4-subset) ----
class SectorTopUniverse(FundamentalUniverseSelectionModel):
    def __init__(self, algo, blacklist=None):
        self.algo = algo
        self.blacklist = set(blacklist or [])
        super().__init__(self._select)

    def _select(self, fundamentals):
        buckets = defaultdict(list)
        for f in fundamentals:
            if not f.has_fundamental_data:
                continue
            if f.symbol.Value in self.blacklist:
                continue
            if f.company_reference.primary_exchange_id not in ("NYS", "NAS", "ASE"):
                continue
            if f.price is None or f.price <= 5:
                continue
            if f.market_cap is None or f.market_cap < 5_000_000_000:
                continue
            sector = f.asset_classification.morningstar_sector_code
            if sector is None:
                continue
            buckets[sector].append(f)
        symbols = []
        for _, stocks in buckets.items():
            stocks.sort(key=lambda x: x.market_cap, reverse=True)
            symbols.extend(s.symbol for s in stocks[:100])
        return symbols


class GrowthV4(QCAlgorithm):

    # -------- book weights (sum = 1.0) --------
    W_263 = 0.25
    W_531 = 0.25
    W_A4 = 0.25
    W_504 = 0.25

    DEBUG_FILLS = True

    # -------- warmup (504cg long SMAs need deep history) --------
    HISTORY_BARS = 1500

    # -------- 504cg params (VERBATIM, S529 / S536) --------
    QUARTER = 0.25
    SVIX_LIVE = datetime(2022, 3, 30)
    UVIX_LIVE = datetime(2022, 3, 30)
    _T10_LATE = {"KMLM", "LABU", "QQQE", "VOOG", "VOOV"}
    _T11_LATE = {"KMLM"}
    CRASH_ENTRY = -0.10
    CRASH_EXIT = -0.04
    CRASH_LOOKBACK = 10
    CRASH_GROSS = 0.50

    # -------- shared band params (gen263 + 531) --------
    band_len = 189
    hist_len = 126
    adx_limit = 35
    adx_period = 14
    BOTTOM_LEVELS = {0, 1, 2, 3, 4}

    # -------- gen263 params (VERBATIM) --------
    lookbacks_263 = [21, 42, 63, 126, 189]
    mom_weights_263 = [0.25, 0.20, 0.20, 0.20, 0.15]
    stock_count_base = 10
    stock_count_choppy = 5
    choppy_threshold = 0.25
    max_weight_263 = 0.20

    # -------- 531 params (VERBATIM) --------
    lookbacks_531 = [21, 63, 126, 189, 252]
    stock_count_531 = 10
    max_weight_531 = 0.20

    # -------- A4 params (VERBATIM) --------
    A4_TOP_N = 100
    A4_TOP_K = 10
    A4_MAX_W = 0.30
    A4_WIN = 9

    # =====================================================================
    def Initialize(self):
        sy = int(self.get_parameter("start_year", "2010"))
        sm = int(self.get_parameter("start_month", "1"))
        sd = int(self.get_parameter("start_day", "1"))
        ey = int(self.get_parameter("end_year", "2026"))
        em = int(self.get_parameter("end_month", "6"))
        ed = int(self.get_parameter("end_day", "26"))
        self.SetStartDate(sy, sm, sd)
        self.SetEndDate(ey, em, ed)
        self.SetCash(100_000)
        self.SetBrokerageModel(BrokerageName.INTERACTIVE_BROKERS_BROKERAGE, AccountType.MARGIN)
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.0
        self.SetBenchmark("SPY")

        self._n_fills = 0
        self._max_moo = 0.0
        self._n_moo = 0
        self._max_liq = 0.0
        self._n_liq = 0
        self._n_inv = 0
        self._max_gross = 0.0

        res = Resolution.Daily

        # ---- fixed-ETF universe: 504cg engine + 531 hedge (BIL), each added once ----
        etf_504 = [
            "TQQQ", "TECL", "SOXL", "SQQQ", "UVXY", "SVIX", "SVXY",
            "XLK", "KMLM", "TLT",
            "QQQE", "VTV", "VOX", "VOOG", "VOOV", "XLP", "XLY", "FAS",
            "SPXL", "LABU",
            "SPY", "IOO", "VTV", "XLF",
            "TECS", "SOXS",
            "QQQ", "PSQ", "QLD", "BTAL", "BIL", "AGG", "SH", "BND", "IEF",
            "BSV",
            "SMH", "UVIX",
        ]
        all_tickers = etf_504 + ["BIL"]
        seen = set()
        unique = [t for t in all_tickers if not (t in seen or seen.add(t))]
        self.syms = {t: self.AddEquity(t, res).Symbol for t in unique}
        self._syms = self.syms  # alias so verbatim 504cg code (self._syms) works
        self.hedge_531 = self.syms["BIL"]

        self._fixed_etfs = set(self.syms.values())
        self._504cg_universe = set(self.syms.values())

        # ---- 504cg indicators (VERBATIM S536) ----
        def rsi10(t):
            return self.RSI(self._syms[t], 10, MovingAverageType.Wilders, res)

        self._t10_rsi = {t: rsi10(t) for t in [
            "QQQE", "VTV", "VOX", "TECL", "VOOG", "VOOV", "XLP",
            "TQQQ", "XLY", "FAS", "SPY",
            "SOXL", "SPXL", "LABU", "XLK", "KMLM",
        ]}
        self._t11_rsi10 = {t: rsi10(t) for t in [
            "SPY", "IOO", "TQQQ", "VTV", "XLF",
            "XLK", "KMLM", "PSQ", "BND", "QQQ", "IEF",
        ]}

        def rsi20(t):
            return self.RSI(self._syms[t], 20, MovingAverageType.Wilders, res)

        self._t11_rsi20 = {t: rsi20(t) for t in ["TLT", "PSQ", "AGG"]}
        self._t11_rsi60_sh = self.RSI(self._syms["SH"], 60, MovingAverageType.Wilders, res)
        self._t11_spy_sma200 = self.SMA(self._syms["SPY"], 200, res)
        self._t11_tqqq_sma20 = self.SMA(self._syms["TQQQ"], 20, res)
        self._t11_kmlm_sma20 = self.SMA(self._syms["KMLM"], 20, res)
        self._s2_tqqq_sma200 = self.SMA(self._syms["TQQQ"], 200, res)
        self._s2_tqqq_sma20 = self.SMA(self._syms["TQQQ"], 20, res)
        self._s2_tqqq_rsi10 = rsi10("TQQQ")
        self._s2_soxl_rsi10 = rsi10("SOXL")
        self._s2_sqqq_rsi10 = rsi10("SQQQ")
        self._s2_bsv_rsi10 = rsi10("BSV")
        self._s3_spy_sma202 = self.SMA(self._syms["SPY"], 202, res)
        self._s3_qqq_sma202 = self.SMA(self._syms["QQQ"], 202, res)
        self._s3_smh_sma202 = self.SMA(self._syms["SMH"], 202, res)
        self._s3_soxl_sma202 = self.SMA(self._syms["SOXL"], 202, res)

        def rsi8(t):
            return self.RSI(self._syms[t], 8, MovingAverageType.Wilders, res)

        def rsi15(t):
            return self.RSI(self._syms[t], 15, MovingAverageType.Wilders, res)

        self._s3_rsi_qqq8 = rsi8("QQQ")
        self._s3_rsi_smh8 = rsi8("SMH")
        self._s3_rsi_spy15 = rsi15("SPY")
        self._s3_rsi_qqq15 = rsi15("QQQ")
        self._s3_rsi_smh15 = rsi15("SMH")
        self._s3_rsi_soxl15 = rsi15("SOXL")

        self._qqq_window = RollingWindow[float](self.CRASH_LOOKBACK + 1)
        self._in_crash = False
        self._504cg_targets = {}
        self._504cg_last_label = ""

        # ---- shared fundamental universe (gen263 + 531 + A4) ----
        self.UniverseSettings.Resolution = Resolution.Daily
        self.UniverseSettings.DataNormalizationMode = DataNormalizationMode.TOTAL_RETURN
        self.SetUniverseSelection(SectorTopUniverse(self, blacklist={"GME", "AMC"}))

        # shared per-stock state (price-derived, read by both momentum sleeves)
        self.symbols = set()
        self.symbol_to_sector = {}
        self.ma = {}
        self.adx = {}
        self.stretch_max = {}
        self.close_win = {}
        self.stretch_ema = {}
        self.current_band_idx = {}
        # per-sleeve band-ceiling history (mutated independently by each sleeve)
        self.band_hist_263 = {}
        self.band_hist_531 = {}

        # gen263 breadth / brake state
        self.allow_universe_263 = True
        self.was_risk_off_263 = False
        self.max_stress_263 = 0.0
        self.prev_bottom_frac_263 = None
        self.portfolio_peak_263 = 100_000.0

        # 531 breadth state
        self.allow_universe_531 = True
        self.was_risk_off_531 = False
        self.max_stress_531 = 0.0

        # ---- sleeve target dicts + dispatch flag ----
        self.tgt_263 = {}
        self.tgt_531 = {}
        self.tgt_a4 = {}
        self._dirty = False

        self.SetWarmUp(self.HISTORY_BARS, Resolution.Daily)

        # gen263 + 531 + A4 monthly decisions at 15:55 -> set target dicts + dirty
        self.Schedule.On(self.DateRules.MonthEnd("SPY"),
                         self.TimeRules.BeforeMarketClose("SPY", 5), self.Rebalance263)
        self.Schedule.On(self.DateRules.MonthEnd("SPY"),
                         self.TimeRules.BeforeMarketClose("SPY", 5), self.Rebalance531)
        self.Schedule.On(self.DateRules.MonthEnd("SPY"),
                         self.TimeRules.BeforeMarketClose("SPY", 5), self.RebalanceA4)
        # 504cg is decided daily in OnData.

    # =====================================================================
    # 504cg sleeve — readiness (VERBATIM S536)
    @property
    def _t10_ready(self):
        core = [v for k, v in self._t10_rsi.items() if k not in self._T10_LATE]
        return not self.IsWarmingUp and all(r.IsReady for r in core)

    @property
    def _t11_ready(self):
        core10 = [v for k, v in self._t11_rsi10.items() if k not in self._T11_LATE]
        return (not self.IsWarmingUp
                and self._t11_spy_sma200.IsReady
                and self._t11_tqqq_sma20.IsReady
                and all(r.IsReady for r in core10)
                and all(r.IsReady for r in self._t11_rsi20.values())
                and self._t11_rsi60_sh.IsReady)

    @property
    def _s2_ready(self):
        return (not self.IsWarmingUp
                and self._s2_tqqq_sma200.IsReady
                and self._s2_tqqq_sma20.IsReady
                and self._s2_tqqq_rsi10.IsReady
                and self._s2_soxl_rsi10.IsReady
                and self._s2_sqqq_rsi10.IsReady
                and self._s2_bsv_rsi10.IsReady)

    @property
    def _s3_ready(self):
        return (not self.IsWarmingUp
                and self._s3_spy_sma202.IsReady
                and self._s3_qqq_sma202.IsReady
                and self._s3_smh_sma202.IsReady
                and self._s3_soxl_sma202.IsReady
                and self._s3_rsi_qqq8.IsReady
                and self._s3_rsi_smh8.IsReady
                and self._s3_rsi_spy15.IsReady
                and self._s3_rsi_qqq15.IsReady
                and self._s3_rsi_smh15.IsReady
                and self._s3_rsi_soxl15.IsReady)

    # 504cg T11 helpers (VERBATIM S536)
    def _t11_bond_baller(self, r10, r20, tqqq_px, tqqq_sma):
        if r20["TLT"] > r20["PSQ"]:
            return "QQQ"
        if tqqq_px > tqqq_sma:
            if r10["PSQ"] < 35:
                return "PSQ"
            if r20["AGG"] > self._t11_rsi60_sh.Current.Value:
                return "TQQQ"
            return "PSQ"
        else:
            if r10["IEF"] > r20["PSQ"]:
                return "PSQ"
            return "SQQQ"

    def _t11_feaver_bear(self, r10, r20, tqqq_px, tqqq_sma):
        hist = self.History(self._syms["QQQ"], 61, Resolution.DAILY)
        qqq_60d = 0.0
        if not hist.empty and len(hist) >= 61:
            c = hist["close"].values
            qqq_60d = (c[-1] / c[0] - 1) * 100
        if qqq_60d < -12:
            if r10["BND"] > r10["QQQ"]:
                return "QLD"
            return "BTAL"
        if tqqq_px > tqqq_sma:
            if r10["PSQ"] < 35:
                return "PSQ"
            if r20["AGG"] > self._t11_rsi60_sh.Current.Value:
                return "TQQQ"
            return "PSQ"
        else:
            if r10["IEF"] > r20["PSQ"]:
                return "PSQ"
            return "SQQQ"

    # 504cg sub-sleeve weight methods (each sums to QUARTER=0.25 of the 504cg bucket)
    def _t10_weights(self):
        if not self._t10_ready:
            return {}
        r = {t: self._t10_rsi[t].Current.Value for t in self._t10_rsi if self._t10_rsi[t].IsReady}
        if (r.get("QQQE", 0) > 79 or r.get("VTV", 0) > 79 or r.get("VOX", 0) > 79
                or r.get("TECL", 0) > 79 or r.get("VOOG", 0) > 79 or r.get("VOOV", 0) > 79
                or r.get("XLP", 0) > 75 or r.get("TQQQ", 0) > 79 or r.get("XLY", 0) > 80
                or r.get("FAS", 0) > 80 or r.get("SPY", 0) > 80):
            return {self._syms["UVXY"]: self.QUARTER}
        if r.get("TQQQ", 50) < 30:
            return {self._syms["TECL"]: self.QUARTER}
        if r.get("SOXL", 50) < 30:
            return {self._syms["SOXL"]: self.QUARTER}
        if r.get("SPXL", 50) < 30:
            return {self._syms["SPXL"]: self.QUARTER}
        if self._t10_rsi["LABU"].IsReady and r["LABU"] < 25:
            return {self._syms["LABU"]: self.QUARTER}
        vs = "SVIX" if self.Time >= self.SVIX_LIVE else "SVXY"
        kmlm_ready = self._t10_rsi["KMLM"].IsReady
        xlk_wins = (not kmlm_ready) or (r["XLK"] > r["KMLM"])
        if xlk_wins:
            return {self._syms["TECL"]: self.QUARTER / 3,
                    self._syms["SOXL"]: self.QUARTER / 3,
                    self._syms[vs]: self.QUARTER / 3}
        return {self._syms["SQQQ"]: self.QUARTER * 0.5,
                self._syms["TLT"]: self.QUARTER * 0.5}

    def _t11_weights(self):
        if not self._t11_ready:
            return {}
        r10 = {t: self._t11_rsi10[t].Current.Value for t in self._t11_rsi10 if self._t11_rsi10[t].IsReady}
        r20 = {t: self._t11_rsi20[t].Current.Value for t in self._t11_rsi20}
        spy_px = self.Securities[self._syms["SPY"]].Close
        tqqq_px = self.Securities[self._syms["TQQQ"]].Close
        kmlm_px = self.Securities[self._syms["KMLM"]].Close
        spy_sma = self._t11_spy_sma200.Current.Value
        tqqq_sma = self._t11_tqqq_sma20.Current.Value
        kmlm_sma = self._t11_kmlm_sma20.Current.Value
        ob79 = (r10["SPY"] > 79 or r10["IOO"] > 79 or r10["TQQQ"] > 79
                or r10["VTV"] > 79 or r10["XLF"] > 79)
        if ob79:
            ob81 = (r10["SPY"] > 81 or r10["IOO"] > 81 or r10["TQQQ"] > 81
                    or r10["VTV"] > 81 or r10["XLF"] > 81)
            if ob81:
                return {self._syms["UVXY"]: self.QUARTER}
            return {self._syms["UVXY"]: self.QUARTER / 3,
                    self._syms["BIL"]: self.QUARTER / 3,
                    self._syms["BTAL"]: self.QUARTER / 3}
        if r10["TQQQ"] < 30:
            return {self._syms["TQQQ"]: self.QUARTER}
        if r10["SPY"] < 30:
            return {self._syms["SPXL"]: self.QUARTER}
        if spy_px > spy_sma:
            kmlm_ready = self._t11_rsi10["KMLM"].IsReady and self._t11_kmlm_sma20.IsReady
            if not kmlm_ready or r10["XLK"] > r10["KMLM"]:
                return {self._syms["TECL"]: self.QUARTER / 3,
                        self._syms["SOXL"]: self.QUARTER / 3,
                        self._syms["TQQQ"]: self.QUARTER / 3}
            if kmlm_px < kmlm_sma:
                return {self._syms["TECL"]: self.QUARTER / 3,
                        self._syms["SOXL"]: self.QUARTER / 3,
                        self._syms["TQQQ"]: self.QUARTER / 3}
            return {self._syms["TECS"]: self.QUARTER / 3,
                    self._syms["SOXS"]: self.QUARTER / 3,
                    self._syms["SQQQ"]: self.QUARTER / 3}
        else:
            bb = self._t11_bond_baller(r10, r20, tqqq_px, tqqq_sma)
            fb = self._t11_feaver_bear(r10, r20, tqqq_px, tqqq_sma)
            w = {}
            w[self._syms[bb]] = w.get(self._syms[bb], 0) + self.QUARTER * 0.5
            w[self._syms[fb]] = w.get(self._syms[fb], 0) + self.QUARTER * 0.5
            return w

    def _s2_weights(self):
        if not self._s2_ready:
            return {}
        tqqq_price = self.Securities[self._syms["TQQQ"]].Close
        tqqq_rsi = self._s2_tqqq_rsi10.Current.Value
        soxl_rsi = self._s2_soxl_rsi10.Current.Value
        sqqq_rsi = self._s2_sqqq_rsi10.Current.Value
        bsv_rsi = self._s2_bsv_rsi10.Current.Value
        sma200 = self._s2_tqqq_sma200.Current.Value
        sma20 = self._s2_tqqq_sma20.Current.Value
        if tqqq_price > sma200:
            sym = self._syms["UVXY"] if tqqq_rsi > 79 else self._syms["TQQQ"]
            return {sym: self.QUARTER}
        if tqqq_rsi < 31:
            return {self._syms["TECL"]: self.QUARTER}
        if soxl_rsi < 30:
            return {self._syms["SOXL"]: self.QUARTER}
        if tqqq_price < sma20:
            sym = self._syms["SQQQ"] if sqqq_rsi > bsv_rsi else self._syms["BSV"]
            return {sym: self.QUARTER}
        return {self._syms["TQQQ"]: self.QUARTER}

    def _s3_weights(self):
        if not self._s3_ready:
            return {}
        spy_bull = self.Securities[self._syms["SPY"]].Price > self._s3_spy_sma202.Current.Value
        qqq_bull = self.Securities[self._syms["QQQ"]].Price > self._s3_qqq_sma202.Current.Value
        smh_bull = self.Securities[self._syms["SMH"]].Price > self._s3_smh_sma202.Current.Value
        soxl_bull = self.Securities[self._syms["SOXL"]].Price > self._s3_soxl_sma202.Current.Value
        bull = (int(spy_bull) + int(qqq_bull) + int(smh_bull) + int(soxl_bull)) >= 3
        overbought = (self._s3_rsi_spy15.Current.Value > 72 or
                      self._s3_rsi_qqq15.Current.Value > 72 or
                      self._s3_rsi_smh15.Current.Value > 72 or
                      self._s3_rsi_soxl15.Current.Value > 72)
        if bull:
            if overbought:
                vol = (self._syms["UVIX"]
                       if (self.Time >= self.UVIX_LIVE
                           and self.Securities[self._syms["UVIX"]].HasData
                           and self.Securities[self._syms["UVIX"]].Price > 0)
                       else self._syms["UVXY"])
                return {vol: self.QUARTER}
            return {self._syms["TQQQ"]: self.QUARTER * 0.5,
                    self._syms["SOXL"]: self.QUARTER * 0.5}
        if self._s3_rsi_qqq8.Current.Value < 29 or self._s3_rsi_smh8.Current.Value < 31:
            return {self._syms["SOXL"]: self.QUARTER}
        return {}

    def _504cg_rebalance(self):
        # Crash guard state machine (QQQ 10-day return gate; asymmetric hysteresis)
        if self._qqq_window.IsReady:
            qqq_ret = self._qqq_window[0] / self._qqq_window[self.CRASH_LOOKBACK] - 1.0
            if not self._in_crash and qqq_ret < self.CRASH_ENTRY:
                self._in_crash = True
            elif self._in_crash and qqq_ret > self.CRASH_EXIT:
                self._in_crash = False
        crash_gross = self.CRASH_GROSS if self._in_crash else 1.0

        w10 = self._t10_weights()
        w11 = self._t11_weights()
        w2 = self._s2_weights()
        w3 = self._s3_weights()

        def lbl(w):
            return "+".join(f"{round(wt / self.QUARTER * 100):.0f}%{s.Value}"
                            for s, wt in w.items()) if w else "CASH"

        new_label = f"T10={lbl(w10)}|T11={lbl(w11)}|S2={lbl(w2)}|S3={lbl(w3)}|cg={crash_gross}"
        if new_label == self._504cg_last_label:
            return False
        combined = {}
        for w in [w10, w11, w2, w3]:
            for sym, wt in w.items():
                combined[sym] = combined.get(sym, 0.0) + wt * crash_gross
        self._504cg_targets = combined
        self._504cg_last_label = new_label
        return True

    # =====================================================================
    def OnSecuritiesChanged(self, changes):
        for sec in changes.AddedSecurities:
            s = sec.Symbol
            if s in self._fixed_etfs:
                # fixed ETFs: leave brokerage-default fee/slippage (sources' behavior)
                continue
            sec.SetFeeModel(InteractiveBrokersFeeModel())
            sec.SetSlippageModel(ConstantSlippageModel(0.001))
            self.symbols.add(s)
            self.stretch_max[s] = 0.0
            self.ma[s] = self.EMA(s, self.band_len, Resolution.Daily)
            self.adx[s] = self.ADX(s, self.adx_period, Resolution.Daily)
            self.stretch_ema[s] = self.EMA(s, self.band_len, Resolution.Daily)
            self.close_win[s] = RollingWindow[float](self.band_len)
            self.band_hist_263[s] = RollingWindow[int](self.hist_len)
            self.band_hist_531[s] = RollingWindow[int](self.hist_len)
            try:
                sector = sec.Fundamentals.AssetClassification.MorningstarSectorCode
                if sector is not None and sector != 0:
                    self.symbol_to_sector[s] = sector
            except Exception:
                pass
        for sec in changes.RemovedSecurities:
            s = sec.Symbol
            if s in self._fixed_etfs:
                continue
            self.symbols.discard(s)
            self.ma.pop(s, None)
            self.adx.pop(s, None)
            self.stretch_max.pop(s, None)
            self.stretch_ema.pop(s, None)
            self.close_win.pop(s, None)
            self.band_hist_263.pop(s, None)
            self.band_hist_531.pop(s, None)
            self.current_band_idx.pop(s, None)
            self.symbol_to_sector.pop(s, None)

    def _band_index(self, price, bands):
        for i in range(len(bands) - 1):
            if bands[i] <= price < bands[i + 1]:
                return i
        return len(bands) - 2

    # =====================================================================
    def OnData(self, data):
        # 504cg crash-guard window (armed during warmup so guard is live at day 0)
        qqq_sym = self.syms["QQQ"]
        if data.Bars.ContainsKey(qqq_sym):
            self._qqq_window.Add(data.Bars[qqq_sym].Close)

        # shared per-stock band tracking (fixed Fibonacci bands)
        for s in list(self.symbols):
            if not data.ContainsKey(s):
                continue
            bar = data[s]
            if bar is None:
                continue
            close = bar.Close
            self.close_win[s].Add(close)
            if not self.close_win[s].IsReady or not self.ma[s].IsReady:
                continue
            dev = np.std(list(self.close_win[s]))
            if dev <= 0:
                continue
            mid = self.ma[s].Current.Value
            stretch = abs(close - mid) / dev
            self.stretch_ema[s].Update(self.Time, stretch)
            if stretch > self.stretch_max[s]:
                self.stretch_max[s] = stretch
            bands = [mid - dev * 1.618, mid - dev * 1.382, mid - dev, mid - dev * 0.809,
                     mid - dev * 0.5, mid - dev * 0.382, mid, mid + dev * 0.382, mid + dev * 0.5,
                     mid + dev * 0.809, mid + dev, mid + dev * 1.382, mid + dev * 1.618]
            self.current_band_idx[s] = self._band_index(close, bands)

        if self.IsWarmingUp:
            return

        if self._504cg_rebalance():
            self._dirty = True

        if self._dirty:
            self._execute_net()
            self._dirty = False

    # =====================================================================
    # gen263 momentum sleeve (VERBATIM decision logic; writes tgt_263)
    def Rebalance263(self):
        if self.IsWarmingUp:
            return
        idxs = list(self.current_band_idx.values())
        if len(idxs) < 50:
            return
        stock_bottom_frac = sum(i in self.BOTTOM_LEVELS for i in idxs) / len(idxs)
        sector_bottoms = defaultdict(list)
        for s, idx in self.current_band_idx.items():
            sector = self.symbol_to_sector.get(s)
            if sector is not None:
                sector_bottoms[sector].append(idx in self.BOTTOM_LEVELS)
        if sector_bottoms:
            sector_stress_count = sum(
                1 for flags in sector_bottoms.values()
                if len(flags) > 0 and sum(flags) / len(flags) > 0.50
            )
            sector_bottom_frac = sector_stress_count / len(sector_bottoms)
            bottom_frac = 0.5 * stock_bottom_frac + 0.5 * sector_bottom_frac
        else:
            bottom_frac = stock_bottom_frac
        stress_roc = 0.0
        if self.prev_bottom_frac_263 is not None:
            stress_roc = bottom_frac - self.prev_bottom_frac_263
        self.prev_bottom_frac_263 = bottom_frac
        self.max_stress_263 = max(self.max_stress_263, bottom_frac)
        # NOTE: fund-of-funds netting means TotalPortfolioValue is the WHOLE book;
        # gen263's equity-DD brake therefore reads book-wide DD (conservative deviation).
        current_value = self.Portfolio.TotalPortfolioValue
        self.portfolio_peak_263 = max(self.portfolio_peak_263, current_value)
        portfolio_dd = (self.portfolio_peak_263 - current_value) / self.portfolio_peak_263 if self.portfolio_peak_263 > 0 else 0.0
        if bottom_frac >= 0.45:
            self.allow_universe_263 = False
            self.was_risk_off_263 = True
        elif self.was_risk_off_263:
            denominator = max(self.max_stress_263, 0.10)
            improvement = (self.max_stress_263 - bottom_frac) / denominator
            if improvement >= 0.60 or bottom_frac < 0.15:
                for s in self.symbols:
                    if s in self.band_hist_263:
                        self.band_hist_263[s] = RollingWindow[int](self.hist_len)
                self.allow_universe_263 = True
                self.was_risk_off_263 = False
                self.max_stress_263 = 0.0
                self.prev_bottom_frac_263 = None
                self.portfolio_peak_263 = current_value
                self.in_recovery = True
                self.recovery_months = 0
        else:
            self.allow_universe_263 = True
        if not self.allow_universe_263:
            self.tgt_263 = {}
            self._dirty = True
            return
        stock_count = self.stock_count_choppy if bottom_frac >= self.choppy_threshold else self.stock_count_base
        hist = self.History(list(self.symbols), max(self.lookbacks_263) + 1, Resolution.Daily)
        if hist.empty:
            return
        closes = hist["close"].unstack(0)
        momentum = {}
        consistency_map = {}
        for s in self.symbols:
            if s not in closes:
                continue
            px = closes[s]
            if len(px) < max(self.lookbacks_263) + 1:
                continue
            if not self.adx[s].IsReady or self.adx[s].Current.Value > self.adx_limit:
                continue
            lb_returns = [px.iloc[-1] / px.iloc[-lb - 1] - 1 for lb in self.lookbacks_263]
            mom = sum(w * r for w, r in zip(self.mom_weights_263, lb_returns))
            if not self.ma[s].IsReady:
                continue
            price = self.Securities[s].Price
            ema = self.ma[s].Current.Value
            if price <= ema:
                continue
            if mom > 0:
                momentum[s] = mom
                n_positive = sum(1 for r in lb_returns if r > 0)
                consistency_map[s] = n_positive / len(self.lookbacks_263)
        if not momentum:
            self.tgt_263 = {}
            self._dirty = True
            return
        top = sorted(momentum, key=momentum.get, reverse=True)[:stock_count]
        scaled = {}
        vol_20d = {}
        for s in top:
            if not self.ma[s].IsReady or not self.stretch_ema[s].IsReady:
                continue
            dev = np.std(list(self.close_win[s]))
            if dev <= 0:
                continue
            mid = self.ma[s].Current.Value
            lm = self.stretch_ema[s].Current.Value
            lm2 = lm / 2.0
            lm3 = lm2 * 0.38196601
            lm4 = lm * 1.38196601
            lm5 = lm * 1.61803399
            lm6 = (lm + lm2) / 2.0
            bands = [mid - dev * lm5, mid - dev * lm4, mid - dev * lm, mid - dev * lm6,
                     mid - dev * lm2, mid - dev * lm3, mid, mid + dev * lm3, mid + dev * lm2,
                     mid + dev * lm6, mid + dev * lm, mid + dev * lm4, mid + dev * lm5]
            price = self.Securities[s].Price
            idx = self._band_index(price, bands)
            self.band_hist_263[s].Add(idx)
            hist_idx = list(self.band_hist_263[s])
            historical_high = max(hist_idx) if hist_idx else idx
            if historical_high <= 0:
                scale = 1.0
            elif idx >= historical_high:
                scale = 0.0
            else:
                scale = max(0.2, 1.0 - idx / historical_high)
            current_stretch = self.stretch_ema[s].Current.Value
            peak_stretch = self.stretch_max.get(s, 0.0)
            if idx >= 10 and peak_stretch > 0:
                if current_stretch < (peak_stretch * 0.80):
                    scale = 0.2
            consistency = consistency_map.get(s, 0.6)
            consist_factor = 0.6 + 0.4 * consistency
            scaled[s] = momentum[s] * scale * consist_factor
            px = closes[s]
            if len(px) >= 21:
                vol_20d[s] = float(px.pct_change().iloc[-20:].std())
            else:
                vol_20d[s] = 0.015
        if not scaled:
            self.tgt_263 = {}
            self._dirty = True
            return
        min_stress = 0.15
        max_stress = 0.45
        target_exposure = float(np.interp(bottom_frac, [min_stress, max_stress], [1.0, 0.0]))
        if stress_roc >= 0.15:
            target_exposure *= 0.70
        if portfolio_dd > 0.07 and stress_roc > 0.03:
            dd_brake = max(0.25, 1.0 - (portfolio_dd - 0.07) / 0.22)
            target_exposure = min(target_exposure, dd_brake)
        target_exposure = float(round(max(0.0, min(1.0, target_exposure)), 2))
        total_scaled = sum(scaled.values())
        raw_weights = {s: v / total_scaled for s, v in scaled.items()}
        capped_weights = {s: min(self.max_weight_263, w) for s, w in raw_weights.items()}
        for s in list(capped_weights.keys()):
            vol = vol_20d.get(s, 0.015)
            if vol > 0:
                vol_cap_factor = min(1.0, 0.015 / vol)
                capped_weights[s] = min(capped_weights[s], self.max_weight_263 * vol_cap_factor)
        current_sum = sum(capped_weights.values())
        final_weights = {}
        if current_sum > 0:
            for s, w in capped_weights.items():
                final_weights[s] = (w / current_sum) * target_exposure
        self.tgt_263 = {s: w for s, w in final_weights.items() if w > 0.0}
        self._dirty = True

    # =====================================================================
    # 531 momentum-breadth sleeve (VERBATIM decision logic; writes tgt_531 incl. BIL)
    def Rebalance531(self):
        if self.IsWarmingUp:
            return
        idxs = list(self.current_band_idx.values())
        if len(idxs) < 50:
            return
        bottom_frac = sum(i in self.BOTTOM_LEVELS for i in idxs) / len(idxs)
        self.max_stress_531 = max(self.max_stress_531, bottom_frac)
        if bottom_frac >= 0.45:
            self.allow_universe_531 = False
            self.was_risk_off_531 = True
        elif self.was_risk_off_531:
            denominator = max(self.max_stress_531, 0.10)
            improvement = (self.max_stress_531 - bottom_frac) / denominator
            if improvement >= 0.60 or bottom_frac < 0.15:
                for s in self.symbols:
                    if s in self.band_hist_531:
                        self.band_hist_531[s] = RollingWindow[int](self.hist_len)
                self.allow_universe_531 = True
                self.was_risk_off_531 = False
                self.max_stress_531 = 0.0
        else:
            self.allow_universe_531 = True
        if not self.allow_universe_531:
            self.tgt_531 = {self.hedge_531: 1.0}
            self._dirty = True
            return
        hist = self.History(list(self.symbols), max(self.lookbacks_531) + 1, Resolution.Daily)
        if hist.empty:
            return
        closes = hist["close"].unstack(0)
        momentum = {}
        for s in self.symbols:
            if s not in closes:
                continue
            px = closes[s]
            if len(px) < max(self.lookbacks_531) + 1:
                continue
            if not self.adx[s].IsReady or self.adx[s].Current.Value > self.adx_limit:
                continue
            mom = np.mean([px.iloc[-1] / px.iloc[-lb - 1] - 1 for lb in self.lookbacks_531])
            if not self.ma[s].IsReady:
                continue
            price = self.Securities[s].Price
            ema = self.ma[s].Current.Value
            if price <= ema:
                continue
            if mom > 0:
                momentum[s] = mom
        if not momentum:
            self.tgt_531 = {self.hedge_531: 1.0}
            self._dirty = True
            return
        top = sorted(momentum, key=momentum.get, reverse=True)[:self.stock_count_531]
        scaled = {}
        for s in top:
            if not self.ma[s].IsReady or not self.stretch_ema[s].IsReady:
                continue
            dev = np.std(list(self.close_win[s]))
            if dev <= 0:
                continue
            mid = self.ma[s].Current.Value
            lm = self.stretch_ema[s].Current.Value
            lm2 = lm / 2.0
            lm3 = lm2 * 0.38196601
            lm4 = lm * 1.38196601
            lm5 = lm * 1.61803399
            lm6 = (lm + lm2) / 2.0
            bands = [mid - dev * lm5, mid - dev * lm4, mid - dev * lm, mid - dev * lm6,
                     mid - dev * lm2, mid - dev * lm3, mid, mid + dev * lm3, mid + dev * lm2,
                     mid + dev * lm6, mid + dev * lm, mid + dev * lm4, mid + dev * lm5]
            price = self.Securities[s].Price
            idx = self._band_index(price, bands)
            self.band_hist_531[s].Add(idx)
            hist_idx = list(self.band_hist_531[s])
            historical_high = max(hist_idx) if hist_idx else idx
            if historical_high <= 0:
                scale = 1.0
            elif idx >= historical_high:
                scale = 0.0
            else:
                scale = max(0.2, 1.0 - idx / historical_high)
            current_stretch = self.stretch_ema[s].Current.Value
            peak_stretch = self.stretch_max.get(s, 0.0)
            if idx >= 10 and peak_stretch > 0 and current_stretch < (peak_stretch * 0.80):
                scale = 0.2
            scaled[s] = (momentum[s] * self.adx[s].Current.Value) * scale
        if not scaled:
            self.tgt_531 = {self.hedge_531: 1.0}
            self._dirty = True
            return
        min_stress = 0.15
        max_stress = 0.45
        target_exposure = float(round(np.interp(bottom_frac, [min_stress, max_stress], [1.0, 0.0]), 2))
        total_scaled = sum(scaled.values())
        raw_weights = {s: v / total_scaled for s, v in scaled.items()}
        capped_weights = {s: min(self.max_weight_531, w) for s, w in raw_weights.items()}
        current_sum = sum(capped_weights.values())
        final_weights = {}
        if current_sum > 0:
            for s, w in capped_weights.items():
                final_weights[s] = (w / current_sum) * target_exposure
        # sweep unallocated capital into the BIL yield hedge (within this sleeve's bucket)
        hedge_allocation = round(1.0 - target_exposure, 2)
        if hedge_allocation > 0:
            final_weights[self.hedge_531] = hedge_allocation
        self.tgt_531 = final_weights
        self._dirty = True

    # =====================================================================
    # A4 reversal sleeve (VERBATIM BOOK_B embedding; top-100-mcap subset; writes tgt_a4)
    def RebalanceA4(self):
        if self.IsWarmingUp or len(self.symbols) < 20:
            return
        cand = []
        for s in self.symbols:
            f = self.Securities[s].Fundamentals
            if f is None or f.MarketCap is None:
                continue
            cand.append((s, float(f.MarketCap)))
        if len(cand) < 20:
            return
        cand.sort(key=lambda x: x[1], reverse=True)
        a4_syms = [s for s, _ in cand[:self.A4_TOP_N]]
        hist = self.History(a4_syms, self.A4_WIN + 2, Resolution.Daily)
        if hist.empty or "low" not in hist.columns:
            return
        lows = hist["low"].unstack(level=0)
        cs_rank = lows.rank(axis=1, pct=True)
        ts_rank = cs_rank.rolling(self.A4_WIN).apply(lambda x: x.iloc[-1] / len(x) if len(x) > 0 else 0).iloc[-1]
        score = (-ts_rank).dropna()
        if score.empty:
            return
        top = score.nlargest(self.A4_TOP_K).index.tolist()
        if not top:
            return
        mcaps = {}
        for s in top:
            f = self.Securities[s].Fundamentals
            if f is None or f.MarketCap is None:
                continue
            mcaps[s] = float(f.MarketCap)
        if not mcaps:
            return
        total = sum(mcaps.values())
        target = {s: min(self.A4_MAX_W, mcaps[s] / total) for s in mcaps}
        sw = sum(target.values())
        self.tgt_a4 = {s: v / sw for s, v in target.items()} if sw > 0 else {}
        self._dirty = True

    # =====================================================================
    # Fund-of-funds netting + T+1 MOO execution (sells before buys)
    def _execute_net(self):
        net = {}
        for sym, w in self.tgt_263.items():
            net[sym] = net.get(sym, 0.0) + self.W_263 * w
        for sym, w in self.tgt_531.items():
            net[sym] = net.get(sym, 0.0) + self.W_531 * w
        for sym, w in self.tgt_a4.items():
            net[sym] = net.get(sym, 0.0) + self.W_A4 * w
        for sym, w in self._504cg_targets.items():
            net[sym] = net.get(sym, 0.0) + self.W_504 * w
        net = {s: w for s, w in net.items() if w > 0.0005}

        equity = self.Portfolio.TotalPortfolioValue
        keep = set(net)
        # EXIT de-targeted holdings first (sells before buys)
        for kvp in list(self.Portfolio):
            sym, h = kvp.Key, kvp.Value
            if h.Invested and sym not in keep and h.Quantity != 0:
                self.MarketOnOpenOrder(sym, -h.Quantity, tag=f"EXIT {self.Time.date()}")

        def cur_w(sym):
            return (float(self.Portfolio[sym].HoldingsValue) / equity) if equity > 0 else 0.0

        # reductions (negative delta) sort ahead of increases -> sells before buys
        for sym, w in sorted(net.items(), key=lambda kv: kv[1] - cur_w(kv[0])):
            if not self.Securities.ContainsKey(sym):
                continue
            price = float(self.Securities[sym].Price)
            if price <= 0:
                continue
            tq = int(equity * w / price)
            cur = self.Portfolio[sym].Quantity if self.Portfolio.ContainsKey(sym) else 0
            d = tq - cur
            if d != 0:
                self.MarketOnOpenOrder(sym, d, tag=f"NET w={w:.3f} {self.Time.date()}")

    # =====================================================================
    def OnOrderEvent(self, oe):
        if not self.DEBUG_FILLS:
            return
        if oe.Status == OrderStatus.INVALID:
            self._n_inv += 1
            self.SetRuntimeStatistic("Invalid", str(self._n_inv))
            msg = str(oe.Message)[:50] if oe.Message else "?"
            self.SetRuntimeStatistic("InvMsg", f"{oe.Symbol.Value}:{msg}")
            return
        if oe.Status != OrderStatus.FILLED or oe.FillQuantity == 0:
            return
        sec = self.Securities[oe.Symbol]
        fp = float(oe.FillPrice)
        o = float(sec.Open)
        dev = abs(fp - o) / o if o > 0 else 0.0
        try:
            od = self.Transactions.GetOrderById(oe.OrderId)
            ot = int(od.Type) if od is not None else -1
        except Exception:
            ot = -1
        if ot == 4:
            self._max_moo = max(self._max_moo, dev)
            self._n_moo += 1
        else:
            self._max_liq = max(self._max_liq, dev)
            self._n_liq += 1
        self._n_fills += 1
        gross = sum(abs(p.HoldingsValue) for p in self.Portfolio.Values) / self.Portfolio.TotalPortfolioValue
        self._max_gross = max(self._max_gross, gross)
        self.SetRuntimeStatistic("Fills", str(self._n_fills))
        self.SetRuntimeStatistic("MaxVsOpen_MOO_pct", f"{self._max_moo * 100:.4f}")
        self.SetRuntimeStatistic("nMOO", str(self._n_moo))
        self.SetRuntimeStatistic("nLiq", str(self._n_liq))
        self.SetRuntimeStatistic("MaxGross_pct", f"{self._max_gross * 100:.1f}")