Overall Statistics
Total Orders
6942
Average Win
0.13%
Average Loss
-0.05%
Compounding Annual Return
34.183%
Drawdown
22.900%
Expectancy
0.890
Start Equity
100000
End Equity
435003.58
Net Profit
335.004%
Sharpe Ratio
1.193
Sortino Ratio
1.495
Probabilistic Sharpe Ratio
78.679%
Loss Rate
44%
Win Rate
56%
Profit-Loss Ratio
2.39
Alpha
0.151
Beta
0.88
Annual Standard Deviation
0.168
Annual Variance
0.028
Information Ratio
1.273
Tracking Error
0.114
Treynor Ratio
0.228
Total Fees
$7247.99
Estimated Strategy Capacity
$19000000.00
Lowest Capacity Asset
DBC TFVSB03UY0DH
Portfolio Turnover
3.87%
Drawdown Recovery
137
# =============================================================================
# BOOK B "Balanced" (RECOMMENDED) — MERGED single-algorithm backtest.  [LOCKED 2026-06-17]
# Sleeves: A4 40% + 476 35% + 410 15% + kinfo 10%, ONE account, fund-of-funds netting.
#   net_w[sym] = 0.40*a4_w + 0.35*476_w + 0.15*410_w + 0.10*(0.98*kinfo_w)   (gross<=100%)
# Same proven netting harness as Book A (smoke-validated: type-4 MOO, T+1 open, both sleeves
# net correctly, gross<100%). A4 + 410 are simple history-based monthly sleeves added on top.
# A4 piggybacks 476's universe (global top-100-mcap is a subset of 476's sector-top-100 union),
# so no 2nd universe is needed. 410 adds UUP/DBC (SPY/IEF/GLD already present via kinfo).
# Curve-blend target (reconciliation): IS 30.3/27.0/1.86 | OOS 36.0/21.9/1.81 | FULL 31.7/27.0/1.81
#
# HOW TO RUN (on any QC account):
#   set WINDOW="IS" -> push -> backtest (2010-2020);  set WINDOW="OOS" -> 2021-2025.
#   DEBUG_FILLS=True surfaces (lean CLI table): Invalid, MaxVsOpen_MOO_pct (=slippage if filling at
#   open), MaxGross_pct (must stay <=~100% -> never borrows), nMOO/nLiq.
# =============================================================================
from AlgorithmImports import *
from collections import defaultdict, deque
from sklearn.covariance import LedoitWolf as LW
import numpy as np
import pandas as pd


# ---- 476 universe (VERBATIM from h476_CLEAN_verified.py); also serves A4 (subset by mcap) ----
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 BookB_Merged(QCAlgorithm):

    # ---- book weights (sum=1) ----
    W_A4 = 0.40
    W_476 = 0.35
    W_410 = 0.15
    W_KINFO = 0.10
    KINFO_BUFFER = 0.98

    WINDOW = "OOS"                # IS=2010-2020 | OOS=2021-2025 | DEBUG=short smoke window
    DEBUG_FILLS = True
    USE_VIX = True               # CBOE VIX feeds kinfo's vol-scaling. If your account errors on
                                 # /Data/alternative/cboe/vix.csv ("Stale file handle" / not subscribed),
                                 # set False -> kinfo runs with vix_scale=1.0 (minor deviation only).

    # ---- kinfo params (VERBATIM) ----
    W_0379 = 0.70
    W_0273 = 0.30
    REBAL_TOL = 0.08
    T_0379 = ["QQQ", "SPY", "TLT", "BSV", "GLD", "QLD", "PSQ", "SHV", "IEF", "QID", "SMH", "USD"]
    T_0273 = ["TQQQ", "SQQQ"]
    HISTORY_BARS = 1500
    STREAK_MIN = 40
    DIST_THRESHOLD = -0.08
    BOUNCE_ENTRY = 0.045
    MAX_SQQQ = 0.90

    # ---- 476 params (VERBATIM) ----
    lookbacks = [21, 63, 126, 189, 252]
    stock_count = 10
    max_weight = 0.20
    band_len = 189
    hist_len = 126
    adx_limit = 35
    adx_period = 14

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

    # ---- 410 params (VERBATIM) ----
    T410 = ["SPY", "IEF", "GLD", "UUP", "DBC"]
    target_vol = 0.10
    max_leverage = 1.0
    lookback410 = 90

    # =====================================================================
    def Initialize(self):
        if self.WINDOW == "IS":
            self.SetStartDate(2010, 1, 1); self.SetEndDate(2020, 12, 31)
        elif self.WINDOW == "OOS":
            self.SetStartDate(2021, 6, 28); self.SetEndDate(2026, 6, 30)
        else:
            self.SetStartDate(2014, 11, 1); self.SetEndDate(2016, 6, 30)
        self.SetCash(100_000)
        self.SetBrokerageModel(BrokerageName.INTERACTIVE_BROKERS_BROKERAGE, AccountType.MARGIN)
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.0

        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

        # ---- kinfo + 410 fixed-ETF subscriptions ----
        self.syms = {}
        for t in self.T_0379 + self.T_0273 + self.T410:
            if t in self.syms:
                continue
            self.syms[t] = self.add_equity(t, Resolution.DAILY).symbol
        self.kinfo_symset = set(self.syms.values())     # all fixed ETFs (kinfo + 410) -> NOT 476/A4 universe
        self.ixic = self.add_equity("ONEQ", Resolution.DAILY).symbol
        self.vix = None; self._has_vix = False
        if self.USE_VIX:
            try:
                self.vix = self.add_data(CBOE, "VIX", Resolution.DAILY).symbol
                self._has_vix = True
            except Exception:
                self.vix = None; self._has_vix = False
        self.kinfo_symset.add(self.ixic)
        if self.vix is not None:
            self.kinfo_symset.add(self.vix)
        self.sym410 = [self.syms[t] for t in self.T410]

        # ---- 476/A4 shared universe ----
        self.UniverseSettings.Resolution = Resolution.Daily
        self.UniverseSettings.DataNormalizationMode = DataNormalizationMode.TOTAL_RETURN
        self.UniverseSettings.Leverage = 4
        self.SetUniverseSelection(SectorTopUniverse(self, blacklist={"GME", "AMC"}))

        # 476 per-symbol state
        self.symbols = set()
        self.ma = {}; self.adx = {}; self.close_win = {}
        self.stretch_ema = {}; self.band_hist = {}; self.stretch_win = {}
        self.current_band_idx = {}
        self.BOTTOM_LEVELS = {0, 1, 2, 3, 4}
        self.max_stress_level = 0.0
        self.was_risk_off = False
        self.risk_off_date = None
        self.allow_universe = True

        # ---- sleeve target dicts + dirty flag ----
        self.tgt_kinfo = {}; self.tgt_476 = {}; self.tgt_a4 = {}; self.tgt_410 = {}
        self._kinfo_last = None
        self._dirty = False

        self.SetBenchmark("SPY")
        self.SetWarmUp(self.HISTORY_BARS, Resolution.Daily)

        # 476 + A4 monthly decision at month end (write target dicts). kinfo daily in OnData.
        self.Schedule.On(self.DateRules.MonthEnd("SPY"),
                         self.TimeRules.BeforeMarketClose("SPY", 5), self.Rebalance476)
        self.Schedule.On(self.DateRules.MonthEnd("SPY"),
                         self.TimeRules.BeforeMarketClose("SPY", 5), self.RebalanceA4)
        # 410 monthly at month start
        self.Schedule.On(self.DateRules.MonthStart("SPY"),
                         self.TimeRules.AfterMarketOpen("SPY", 30), self.Rebalance410)

    # =====================================================================
    # kinfo helpers + sleeves (VERBATIM)
    @staticmethod
    def _rsi_wilder(px, window):
        delta = px.diff(); gain = delta.clip(lower=0.0); loss = (-delta).clip(lower=0.0)
        ag = gain.ewm(alpha=1.0 / window, adjust=False).mean()
        al = loss.ewm(alpha=1.0 / window, adjust=False).mean()
        rs = ag / al.replace(0.0, np.nan)
        return (100.0 - 100.0 / (1.0 + rs)).fillna(50.0)

    @staticmethod
    def _rsi_sma_seed(series, period):
        delta = series.diff(); gain = delta.clip(lower=0.0).to_numpy(); loss = (-delta.clip(upper=0.0)).to_numpy()
        n = len(series); ag = np.full(n, np.nan); al = np.full(n, np.nan)
        if n > period:
            ag[period] = np.nanmean(gain[1:period + 1]); al[period] = np.nanmean(loss[1:period + 1])
            ip = 1.0 / period
            for i in range(period + 1, n):
                ag[i] = ag[i - 1] * (1 - ip) + gain[i] * ip
                al[i] = al[i - 1] * (1 - ip) + loss[i] * ip
        rs = np.where(al > 0, ag / np.where(al == 0, np.nan, al), np.nan)
        return pd.Series(100.0 - 100.0 / (1.0 + rs), index=series.index).fillna(50.0)

    @staticmethod
    def _top(tickers, rsi10):
        best = tickers[0]; bv = rsi10[best]
        for t in tickers[1:]:
            if rsi10[t] > bv: best, bv = t, rsi10[t]
        return best

    def _decide_0379(self, close):
        rsi_assets = ["QQQ", "SPY", "TLT", "BSV", "GLD", "PSQ", "IEF", "QLD", "USD", "SMH"]
        rsi10 = {t: float(self._rsi_sma_seed(close[t], 10).iloc[-1]) for t in rsi_assets}
        rsi60_spy = float(self._rsi_sma_seed(close["SPY"], 60).iloc[-1])
        spy = close["SPY"]; qqq = close["QQQ"]
        spy_ma200 = float(spy.rolling(200).mean().iloc[-1])
        spy_ma20 = float(spy.rolling(20).mean().iloc[-1])
        qqq_ma20 = float(qqq.rolling(20).mean().iloc[-1])
        spy_l = float(spy.iloc[-1]); qqq_l = float(qqq.iloc[-1])
        cr10 = float((qqq.iloc[-1] / qqq.iloc[-11] - 1.0) * 100.0)
        cr60 = float((qqq.iloc[-1] / qqq.iloc[-61] - 1.0) * 100.0)
        cr252 = float((qqq.iloc[-1] / qqq.iloc[-253] - 1.0) * 100.0)
        w = {}
        def add(t, x): w[t] = w.get(t, 0.0) + x
        if spy_l > spy_ma200:
            if rsi10["QQQ"] > 80.0: add("TLT", 1.0)
            elif rsi10["SPY"] > 80.0: add("TLT", 1.0)
            elif rsi60_spy > 60.0: add(self._top(["TLT", "BSV", "GLD"], rsi10), 1.0)
            else: add("QQQ", 1.0)
        else:
            if rsi10["QQQ"] < 30.0:
                add("QLD", 1.0)
            else:
                tlt_gt_psq = rsi10["TLT"] > rsi10["PSQ"]
                qqq_below = qqq_l < qqq_ma20
                if qqq_l > qqq_ma20:
                    if rsi10["PSQ"] < 30.0: add("PSQ", 0.50)
                    elif cr10 > 5.5: add("SHV", 0.50)
                    else: add("QQQ", 0.50)
                else:
                    add(self._top(["IEF", "PSQ"], rsi10), 0.50)
                if cr252 < -20.0:
                    if qqq_below:
                        if cr60 <= -12.0:
                            sib1 = "SPY" if spy_l > spy_ma20 else ("QQQ" if tlt_gt_psq else "PSQ")
                            sib2 = "QQQ" if tlt_gt_psq else "PSQ"
                            add(sib1, 0.25); add(sib2, 0.25)
                        else:
                            add("QLD" if tlt_gt_psq else "QID", 0.50)
                    else:
                        if rsi10["PSQ"] < 31.0: add("PSQ", 0.50)
                        elif cr10 > 5.5: add("PSQ", 0.50)
                        else: add(self._top(["QQQ", "SMH"], rsi10), 0.50)
                else:
                    if qqq_below:
                        add("QLD" if tlt_gt_psq else "QID", 0.50)
                    else:
                        if rsi10["PSQ"] < 31.0: add("QID", 0.50)
                        elif cr10 > 5.5: add("QID", 0.50)
                        else: add(self._top(["QLD", "USD"], rsi10), 0.50)
        return w

    def _decide_0273(self, df, vix_series):
        df = df.copy()
        df["qqq_sma_50"] = df["QQQ"].rolling(50).mean()
        df["qqq_sma_200"] = df["QQQ"].rolling(200).mean()
        df["qqq_dist_sma_50"] = (df["QQQ"] - df["qqq_sma_50"]) / df["qqq_sma_50"]
        df["qqq_dist_sma_200"] = (df["QQQ"] - df["qqq_sma_200"]) / df["qqq_sma_200"]
        df["qqq_sma_200_slope_20"] = (df["qqq_sma_200"] - df["qqq_sma_200"].shift(20)) / df["qqq_sma_200"].shift(20)
        df["qqq_roc_21"] = df["QQQ"].pct_change(21)
        df["ixic_sma_50"] = df["IXIC"].rolling(50).mean()
        df["ixic_sma_100"] = df["IXIC"].rolling(100).mean()
        df["ixic_sma_175"] = df["IXIC"].rolling(175).mean()
        df["ixic_sma_200"] = df["IXIC"].rolling(200).mean()
        df["ixic_sma_200_dist"] = (df["IXIC"] - df["ixic_sma_200"]) / df["ixic_sma_200"]
        df["ixic_above_sma_50"] = (df["IXIC"] > df["ixic_sma_50"]).astype(float)
        df["ixic_above_sma_100"] = (df["IXIC"] > df["ixic_sma_100"]).astype(float)
        df["ixic_above_sma_175"] = (df["IXIC"] > df["ixic_sma_175"]).astype(float)
        df["qqq_rsi_14"] = self._rsi_wilder(df["QQQ"], 14)
        df["tqqq_vol20"] = df["TQQQ"].pct_change().rolling(20).std() * np.sqrt(252)
        if vix_series is not None and not vix_series.empty:
            v = vix_series.reindex(df.index).ffill()
            df["vix_ratio_20"] = (v / v.rolling(20).mean()).fillna(1.0)
        else:
            df["vix_ratio_20"] = 1.0
        feat = df.iloc[-1]
        req = ["qqq_dist_sma_50", "qqq_dist_sma_200", "qqq_sma_200_slope_20", "qqq_roc_21",
               "tqqq_vol20", "qqq_rsi_14", "ixic_sma_200_dist", "ixic_above_sma_50",
               "ixic_above_sma_100", "ixic_above_sma_175", "vix_ratio_20"]
        if feat[req].isna().any():
            return {}
        above175 = bool(feat["ixic_above_sma_175"] > 0.5)
        above100 = bool(feat["ixic_above_sma_100"] > 0.5)
        above50 = bool(feat["ixic_above_sma_50"] > 0.5)
        rsi14 = float(feat["qqq_rsi_14"]); roc21 = float(feat["qqq_roc_21"])
        dist_200_ixic = float(feat["ixic_sma_200_dist"]); vix_ratio = float(feat["vix_ratio_20"])
        strong_bull = above175 and above50 and (rsi14 > 55.0) and (roc21 > 0.0)
        full_momentum = (rsi14 > 60.0) and (roc21 > 0.0)
        regime = 4
        if above100: regime = 3
        if above175 and (not strong_bull): regime = 2
        if above175 and strong_bull and (not full_momentum): regime = 5
        if above175 and strong_bull and full_momentum: regime = 1
        vix_scale = 1.0
        if vix_ratio >= 2.2: vix_scale = 0.38
        elif vix_ratio >= 1.5: vix_scale = 0.62
        elif vix_ratio >= 1.1: vix_scale = 0.88
        w_qqq, w_tqqq, w_sqqq = 0.0, 0.0, 0.0
        very_extended = (regime == 1) and (dist_200_ixic > 0.15)
        normal_bull = (regime == 1) and (not very_extended)
        if very_extended: w_tqqq = float(np.clip(0.84 * vix_scale, 0.35, 0.84))
        elif normal_bull: w_tqqq = float(np.clip(0.90 * vix_scale, 0.35, 0.90))
        elif regime == 5: w_tqqq = float(np.clip(0.60 * vix_scale, 0.28, 0.60))
        elif regime == 2: w_tqqq = float(np.clip(0.70 * vix_scale, 0.28, 0.70))
        elif regime == 4: w_qqq = 0.45
        elif regime == 3:
            w_tqqq = 0.62 if rsi14 > 55 else (0.48 if rsi14 > 45 else 0.34)
        t_vol = float(feat["tqqq_vol20"])
        if above175 and (t_vol < 0.60): w_tqqq = min(0.95, w_tqqq * 1.05)
        if above175 and (t_vol >= 0.80): w_tqqq = min(0.95, w_tqqq * 0.75)
        if very_extended: w_tqqq = min(0.95, w_tqqq * 0.92)
        now = df.iloc[-1]
        dist50 = float(now["qqq_dist_sma_50"]); dist200 = float(now["qqq_dist_sma_200"])
        slope200 = float(now["qqq_sma_200_slope_20"])
        if any(np.isnan([dist50, dist200, slope200])):
            return {}
        below_200 = (df["qqq_dist_sma_200"] < 0).astype(int).values
        streak = 0
        for b in below_200[:-1][::-1]:
            if b == 1: streak += 1
            else: break
        bear_gate = (streak >= self.STREAK_MIN) and (dist200 < dist50) and (dist200 < self.DIST_THRESHOLD) and (slope200 < 0.0)
        roc3 = float(df["QQQ"].pct_change(3).shift(1).iloc[-1])
        if bear_gate and (roc3 >= self.BOUNCE_ENTRY):
            w_qqq, w_tqqq, w_sqqq = 0.0, 0.0, self.MAX_SQQQ
        gross = w_qqq + w_tqqq + w_sqqq
        if gross > 1.0:
            w_qqq /= gross; w_tqqq /= gross; w_sqqq /= gross
        return {"QQQ": w_qqq, "TQQQ": w_tqqq, "SQQQ": w_sqqq}

    def _decide_kinfo(self):
        all_syms = [self.syms[t] for t in (self.T_0379 + self.T_0273)] + [self.ixic]
        hist = self.history(all_syms, self.HISTORY_BARS, Resolution.DAILY)
        if hist is None or hist.empty or "close" not in hist.columns:
            return None
        try:
            wide = hist["close"].unstack(level=0)
        except Exception:
            return None
        col = {}
        for t in (self.T_0379 + self.T_0273):
            s = self.syms[t]
            if s in wide.columns: col[t] = wide[s]
        if self.ixic in wide.columns: col["IXIC"] = wide[self.ixic]
        if any(t not in col for t in self.T_0379) or "IXIC" not in col:
            return None
        close = pd.DataFrame(col).dropna()
        if len(close) < 260:
            return None
        vix_series = None
        if self._has_vix and self.vix is not None:
            try:
                vh = self.history(self.vix, self.HISTORY_BARS, Resolution.DAILY)
                if vh is not None and not vh.empty:
                    s = vh["value"] if "value" in vh.columns else (vh["close"] if "close" in vh.columns else None)
                    if s is not None:
                        if isinstance(s.index, pd.MultiIndex):
                            s.index = s.index.get_level_values(-1)
                        s.index = pd.to_datetime(s.index).tz_localize(None)
                        vix_series = s[~pd.Index(s.index).duplicated(keep="last")].sort_index().astype(float)
            except Exception:
                vix_series = None
        w379 = self._decide_0379(close[self.T_0379])
        df0273 = close[["QQQ", "TQQQ", "SQQQ", "IXIC"]]
        w273 = self._decide_0273(df0273, vix_series)
        target = {}
        for t, x in w379.items():
            target[t] = target.get(t, 0.0) + self.W_0379 * x
        for t, x in w273.items():
            if x > 0:
                target[t] = target.get(t, 0.0) + self.W_0273 * x
        target = {t: round(w, 3) for t, w in target.items() if w > 0.005}
        if self._kinfo_last is not None:
            same = set(target) == set(self._kinfo_last)
            moves = [abs(target.get(t, 0.0) - self._kinfo_last.get(t, 0.0)) for t in set(target) | set(self._kinfo_last)]
            if same and (max(moves) if moves else 0.0) < self.REBAL_TOL:
                return None
        self._kinfo_last = target
        return target

    # =====================================================================
    def OnSecuritiesChanged(self, changes):
        for sec in changes.AddedSecurities:
            s = sec.Symbol
            if s in self.kinfo_symset:
                continue
            sec.SetFeeModel(InteractiveBrokersFeeModel())
            sec.SetSlippageModel(ConstantSlippageModel(0.001))
            self.symbols.add(s)
            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[s] = RollingWindow[int](self.hist_len)
            self.stretch_win[s] = RollingWindow[float](self.hist_len)
        for sec in changes.RemovedSecurities:
            s = sec.Symbol
            if s in self.kinfo_symset:
                continue
            self.symbols.discard(s)
            self.ma.pop(s, None); self.adx.pop(s, None); self.stretch_ema.pop(s, None)
            self.close_win.pop(s, None); self.band_hist.pop(s, None)
            self.current_band_idx.pop(s, None); self.stretch_win.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):
        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)
            self.stretch_win[s].Add(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

        kt = self._decide_kinfo()
        if kt is not None:
            self.tgt_kinfo = kt
            self._dirty = True

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

    # =====================================================================
    def Rebalance476(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_level = max(self.max_stress_level, bottom_frac)
        if bottom_frac >= 0.45:
            if not self.was_risk_off:
                self.risk_off_date = self.Time
            self.allow_universe = False; self.was_risk_off = True
        elif self.was_risk_off:
            denominator = max(self.max_stress_level, 0.10)
            improvement = (self.max_stress_level - bottom_frac) / denominator
            days_risk_off = (self.Time - self.risk_off_date).days if self.risk_off_date else 0
            if improvement >= 0.60 or bottom_frac < 0.15 or days_risk_off > 180:
                for s in self.symbols:
                    if s in self.band_hist:
                        self.band_hist[s] = RollingWindow[int](self.hist_len)
                self.allow_universe = True; self.was_risk_off = False
                self.max_stress_level = 0.0; self.risk_off_date = None
        else:
            self.allow_universe = True

        if not self.allow_universe:
            self.tgt_476 = {}; self._dirty = True
            return

        hist = self.History(list(self.symbols), max(self.lookbacks) + 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) + 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])
            if not self.ma[s].IsReady:
                continue
            price = self.Securities[s].Price
            ema = self.ma[s].Current.Value
            if price <= ema:
                continue
            fundamentals = self.Securities[s].Fundamentals
            if fundamentals is None or fundamentals.MarketCap < 5_000_000_000:
                continue
            if mom > 0:
                momentum[s] = mom
        if not momentum:
            self.tgt_476 = {}; self._dirty = True
            return
        top = sorted(momentum, key=momentum.get, reverse=True)[:self.stock_count]
        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[s].Add(idx)
            hist_idx = list(self.band_hist[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)
            if self.stretch_win[s].IsReady:
                stretch_list = list(self.stretch_win[s])
                current_stretch = stretch_list[0]; peak_stretch = max(stretch_list)
                if idx >= 10 and peak_stretch > 0:
                    if current_stretch < (peak_stretch * 0.80):
                        scale = min(scale, 0.2)
            scaled[s] = momentum[s] * scale
        if not scaled:
            self.tgt_476 = {}; self._dirty = True
            return
        total_scaled = sum(scaled.values())
        raw_weights = {s: v / total_scaled for s, v in scaled.items()}
        capped_weights = {s: min(self.max_weight, w) for s, w in raw_weights.items()}
        current_sum = sum(capped_weights.values())
        self.tgt_476 = {s: w / current_sum for s, w in capped_weights.items()} if current_sum > 0 else {}
        self._dirty = True

    # =====================================================================
    def RebalanceA4(self):
        # A4: 1-day cross-sectional low-reversal on the top-100-by-mcap subset of the universe.
        if self.IsWarmingUp or len(self.symbols) < 20:
            return
        # top-100 by market cap among universe members (== A4's standalone universe)
        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

    # =====================================================================
    def Rebalance410(self):
        # 410: Ledoit-Wolf mean-variance on 5 ETFs, downside-vol target 10%, leverage<=1.0.
        if self.IsWarmingUp:
            return
        prices = self.History(self.sym410, self.lookback410, Resolution.DAILY)
        if prices.empty or "close" not in prices.columns:
            return
        prices = prices["close"].unstack(level=0)
        returns = prices.pct_change().dropna()
        if returns.shape[0] < 20 or returns.shape[1] < len(self.sym410):
            return
        mu = returns.median() * 252
        try:
            cov = LW().fit(returns).covariance_ * 252
            weights = np.linalg.solve(cov, mu)
        except Exception:
            return
        weights = np.nan_to_num(weights)
        weights = pd.Series(weights, index=returns.columns)
        weights[weights < 0] = 0.0
        s = weights.abs().sum()
        if s > 0:
            weights /= s
        rp = returns @ weights
        rp = rp[rp < 0]
        port_vol = np.sqrt((rp ** 2).mean() * 252)
        leverage = 1.0
        if port_vol > 0:
            leverage = min(self.target_vol / port_vol, self.max_leverage)
        weights *= leverage
        self.tgt_410 = {sym: float(w) for sym, w in weights.items()
                        if w > 0.0005 and self.Securities[sym].IsTradable and self.Securities[sym].Price > 0}
        self._dirty = True

    # =====================================================================
    def _execute_net(self):
        net = {}
        for tk, w in self.tgt_kinfo.items():
            sym = self.syms.get(tk)
            if sym is None:
                continue
            net[sym] = net.get(sym, 0.0) + self.W_KINFO * self.KINFO_BUFFER * w
        for sym, w in self.tgt_476.items():
            net[sym] = net.get(sym, 0.0) + self.W_476 * 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.tgt_410.items():
            net[sym] = net.get(sym, 0.0) + self.W_410 * w
        net = {s: w for s, w in net.items() if w > 0.0005}

        equity = self.Portfolio.TotalPortfolioValue
        keep = set(net)
        for kvp in list(self.Portfolio):
            sym, h = kvp.Key, kvp.Value
            if sym in (self.ixic, self.vix):
                continue
            if h.Invested and sym not in keep and h.Quantity != 0:
                self.MarketOnOpenOrder(sym, -h.Quantity, tag=f"EXIT now={self.Time.date()}")
        def cur_w(sym):
            return (float(self.Portfolio[sym].HoldingsValue) / equity) if equity > 0 else 0.0
        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"ENTRY w={w:.3f} px={price:.2f} now={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}")