Overall Statistics
Total Orders
1076
Average Win
2.16%
Average Loss
-0.98%
Compounding Annual Return
35.911%
Drawdown
27.200%
Expectancy
1.092
Start Equity
100000
End Equity
10006063.02
Net Profit
9906.063%
Sharpe Ratio
1.19
Sortino Ratio
1.444
Probabilistic Sharpe Ratio
58.611%
Loss Rate
35%
Win Rate
65%
Profit-Loss Ratio
2.20
Alpha
0.18
Beta
0.716
Annual Standard Deviation
0.203
Annual Variance
0.041
Information Ratio
0.868
Tracking Error
0.18
Treynor Ratio
0.337
Total Fees
$28035.47
Estimated Strategy Capacity
$14000000.00
Lowest Capacity Asset
FNV UZREM0FP9D9H
Portfolio Turnover
2.33%
Drawdown Recovery
686
"""
Three-Sleeve Hybrid Strategy -- v1.4.4
??????????????????????????????????????
MODE LOGIC -- binary switch:
  S3+S1 bull (strong bull): S3=80% momentum, S1=20% fixed hedge (75/25 BRK.B/NEM), S2=0%
  S1+S2  (not bull)       : S1=40-100% BRK.B/NEM, S2=0-40% equity, S3=0%

Strong bull gate -- ALL five required:
  1. SPY > 200-day SMA    4. VIX < 80th-pct (300-bar window)
  2. SPY > 50-day SMA     5. VIX < 25 (hard ceiling)
  3. SPY 20-day return > 0

Regime (S1+S2 mode): R1/R4/R6 stress -> S2 off; R2/R3/R5 calm -> S2 on

Schedules (anchored to SPY/NYSE in both live and backtest):
  CheckSignal  : Daily      BMC-120  (~14:00 ET / 19:00 London)
  TrainModel   : MonthStart BMC-150
  RebalanceS2  : MonthStart BMC-90
  RebalanceS3  : MonthEnd   BMC-30
  DailySnapshot: Daily      BMC-1

Live instruments (UK -- no PRIIPs issues, all US individual stocks):
  S1 hedge : BRK.B (Berkshire B -- S&P proxy, ~0.95 correlation)
             NEM   (Newmont Mining -- gold proxy, ~0.80 gold correlation)
  S2/S3    : US-listed equities (value+momentum / large-cap momentum)
  Signals  : SPY/GLD/HYG/LQD/IEF/SHY (read-only, never traded in live)

Log tags: [GATE] [SWITCH] [STATE] [S1] [S2] [S3] [SNAP] [INIT] [END]
"""

from AlgorithmImports import *
from collections import defaultdict, deque
import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.preprocessing import StandardScaler

LABEL_HORIZON  = 21
SAFETY_BUFFER  = 10
TRAIN_VAL_GAP  = 126
MIN_TRAIN_ROWS = 100
ML_THRESHOLD   = 0.65
MIN_VIX_BARS   = 50
MIN_SPY_BARS   = 260
MIN_AUX_BARS   = 60
DIP_DEEP_THRESHOLD   = -0.08
DIP_SHALLOW_SPY_W    = 0.60
DIP_SHALLOW_SPY_W_ML = 0.75
DIP_DEEP_SPY_W       = 0.85
DIP_DEEP_SPY_W_ML    = 1.00
S3_BULL_BUDGET    = 0.80   # S3 allocation in strong bull mode
S1_BULL_BUDGET    = 0.20   # S1 macro hedge in strong bull mode
S1_BULL_SPY_FRAC  = 0.75   # fixed SPY fraction of S1 hedge in bull mode
S1_BULL_GLD_FRAC  = 0.25   # fixed GLD fraction of S1 hedge in bull mode


class ThreeSleeveHybrid(QCAlgorithm):

    def Initialize(self):
        self.SetStartDate(2021, 1, 1)
        self.SetEndDate(2026, 1, 1)
        self.SetCash(100_000)
        self.SetBrokerageModel(BrokerageName.InteractiveBrokersBrokerage, AccountType.Margin)

        if self.LiveMode:
            # UK PRIIPs: use US-listed individual stocks as S1 hedge -- no restrictions:
            #   BRK.B (Berkshire B) -- broad market proxy, ~0.95 S&P correlation
            #   NEM  (Newmont)  -- largest gold miner, ~0.80 gold price correlation
            spy_ticker, gld_ticker = "BRK.B", "NEM"
            hyg_ticker, lqd_ticker = "HYG",  "LQD"
            ief_ticker, shy_ticker = "IEF",  "SHY"
            use_rsp = False
        else:
            spy_ticker, gld_ticker = "SPY",  "GLD"
            hyg_ticker, lqd_ticker = "HYG",  "LQD"
            ief_ticker, shy_ticker = "IEF",  "SHY"
            use_rsp = True

        self.spy = self.AddEquity(spy_ticker, Resolution.Daily).Symbol
        self.gld = self.AddEquity(gld_ticker, Resolution.Daily).Symbol
        self.vix   = self.AddData(CBOE, "VIX",   Resolution.Daily).Symbol
        self.vix3m = self.AddData(CBOE, "VIX3M", Resolution.Daily).Symbol
        self.hyg   = self.AddEquity(hyg_ticker, Resolution.Daily).Symbol
        self.lqd   = self.AddEquity(lqd_ticker, Resolution.Daily).Symbol
        self.rsp   = self.AddEquity("RSP", Resolution.Daily).Symbol if use_rsp else None
        self.ief   = self.AddEquity(ief_ticker, Resolution.Daily).Symbol
        self.shy   = self.AddEquity(shy_ticker, Resolution.Daily).Symbol

        if self.LiveMode:
            # Regime signals always use SPY/GLD history (read-only, never traded)
            self.spy_hist = self.AddEquity("SPY", Resolution.Daily).Symbol
            self.gld_hist = self.AddEquity("GLD", Resolution.Daily).Symbol
            self.hyg_hist = self.hyg
            self.lqd_hist = self.lqd
            self.ief_hist = self.ief
            self.shy_hist = self.shy
            # S1 hedge executes via BRK/B and NEM -- fully automated, no PRIIPs issues
            self.spy_hedge = self.spy   # BRK/B
            self.gld_hedge = self.gld   # NEM
            self.Log("[INIT] Live S1 hedge: BRK.B (market proxy) + NEM (gold proxy)")
        else:
            self.spy_hist = self.spy;  self.gld_hist = self.gld
            self.hyg_hist = self.hyg;  self.lqd_hist = self.lqd
            self.ief_hist = self.ief;  self.shy_hist = self.shy
            self.spy_hedge = self.spy   # SPY in backtest
            self.gld_hedge = self.gld   # GLD in backtest

        self.SetBenchmark("SPY")   # SPY always available as benchmark read-only
        self.Log(f"[INIT] mode={'Live' if self.LiveMode else 'Backtest'} spy={spy_ticker}")

        self.model   = RandomForestClassifier(n_estimators=200, max_depth=6,
                                              min_samples_leaf=20, random_state=42)
        self.scaler  = StandardScaler()
        self.trained = False

        self.s1_spy_weight    = 0.0
        self.s1_gld_weight    = 0.0
        self._sleeves_active  = True
        self._s3_bull_market  = False
        self.s2_sleeve_budget = 0.0
        self._initial_deploy_done = False

        self.S2_MAX_POSITION_WEIGHT = 0.20
        self.S2_MAX_POSITIONS       = 10
        self.S2_MIN_HISTORY_DAYS    = 5
        self.S2_MOMENTUM_LOOKBACK   = 63
        self.S2_MOMENTUM_MIN_RETURN = 0.0
        self._s2_candidates: set  = set()
        self._s2_added_date: dict = {}
        self._s2_momentum:   dict = {}

        self._s3_candidates: set  = set()
        self.s3_lookbacks         = [21, 63, 126, 189, 252]
        self.s3_stock_count       = 10
        self.s3_band_len          = 189
        self.s3_hist_len          = 126
        self.s3_adx_limit         = 35
        self.s3_adx_period        = 14
        self.s3_rebal_threshold   = 0.015
        self.s3_symbols     = set()
        self.s3_ma          = {}
        self.s3_adx         = {}
        self.s3_close_win   = {}
        self.s3_stretch_ema = {}
        self.s3_band_hist   = {}
        self.s3_stretch_win = {}
        self.s3_band_idx    = {}
        self.s3_BOTTOM_LEVELS = {0, 1, 2, 3, 4}
        self.s3_allow         = True
        self.s3_was_risk_off  = False
        self.s3_risk_off_date = None
        self.s3_max_stress    = 0.0

        self._hwm        = 0.0
        self._prev_value = None
        self._daily_rets = deque(maxlen=252)

        self.UniverseSettings.Resolution            = Resolution.Daily
        self.UniverseSettings.DataNormalizationMode = DataNormalizationMode.Adjusted
        self.UniverseSettings.FillDataBeforeStart   = True
        self._universe_blacklist    = {"GME", "AMC"}
        self._last_universe_result  = []    # Monday gap fallback cache
        self._last_s2_candidates    = set()
        self._last_s3_candidates    = set()
        self.AddUniverse(self.MergedUniverseSelection)

        # Schedule anchor: spy_hist = SPY in both live and backtest.
        # SPY NYSE hours ensure CheckSignal fires during US market session.
        # In live: BMC-120 = ~14:00 ET = 19:00 London (US market open, orders fill same day).
        anchor = self.spy_hist
        self.Schedule.On(self.DateRules.MonthStart(anchor),
                         self.TimeRules.BeforeMarketClose(anchor, 150), self.TrainModel)
        self.Schedule.On(self.DateRules.EveryDay(anchor),
                         self.TimeRules.BeforeMarketClose(anchor, 120), self.CheckSignal)
        self.Schedule.On(self.DateRules.MonthStart(anchor),
                         self.TimeRules.BeforeMarketClose(anchor, 90), self.RebalanceSleeve2)
        self.Schedule.On(self.DateRules.MonthEnd(anchor),
                         self.TimeRules.BeforeMarketClose(anchor, 30), self.RebalanceSleeve3)
        self.Schedule.On(self.DateRules.EveryDay(anchor),
                         self.TimeRules.BeforeMarketClose(anchor, 1), self._DailySnapshot)
        self.Log(f"[INIT] Schedule anchor=SPY/NYSE BMC-120 (~14:00 ET)")
        self.SetWarmUp(300)

    # ?? Logging helpers ???????????????????????????????????????????????????????

    def _log_gate(self, spy, sma50, sma200, ret20, vix, vix80, bull):
        def t(v): return "PASS" if v else "FAIL"
        self.Log(
            f"[GATE] {self.Time:%Y-%m-%d} "
            f"C1(>200MA):{t(spy>sma200)} C2(>50MA):{t(spy>sma50)} "
            f"C3(20d>0):{t(ret20>0)}({ret20:+.2%}) "
            f"C4(VIX<80pct):{t(vix<vix80)}({vix:.1f}<{vix80:.1f}) "
            f"C5(VIX<25):{t(vix<25)} => {'BULL' if bull else 'BEAR'}"
        )

    def _log_state(self, tag):
        eq = self.Portfolio.TotalPortfolioValue
        if eq <= 0: return
        macro = {self.spy_hedge, self.gld_hedge}
        # Exclusive buckets: S1 first, then S2, then S3 for anything not already claimed.
        # Dual-listed stocks (in both _s2_candidates and s3_symbols) are counted once in S2.
        s1k = {kvp.Key for kvp in self.Portfolio if kvp.Value.Invested and kvp.Key in macro}
        s2k = {kvp.Key for kvp in self.Portfolio if kvp.Value.Invested and kvp.Key in self._s2_candidates}
        s3k = {kvp.Key for kvp in self.Portfolio if kvp.Value.Invested
               and kvp.Key in self.s3_symbols and kvp.Key not in s2k}
        s1v = sum(self.Portfolio[k].HoldingsValue for k in s1k)
        s2v = sum(self.Portfolio[k].HoldingsValue for k in s2k)
        s3v = sum(self.Portfolio[k].HoldingsValue for k in s3k)
        self.Log(
            f"[STATE] {tag} {self.Time:%Y-%m-%d} Eq={eq:,.0f} "
            f"S1={s1v/eq:.1%} S2={s2v/eq:.1%} S3={s3v/eq:.1%} Cash={self.Portfolio.Cash/eq:.1%}"
        )
        if s1k: self.Log(f"[STATE]  S1: {' '.join(f'{k.Value}({self.Portfolio[k].HoldingsValue/eq:.1%})' for k in s1k)}")
        if s2k: self.Log(f"[STATE]  S2: {' '.join(f'{k.Value}({self.Portfolio[k].HoldingsValue/eq:.1%})' for k in s2k)}")
        if s3k: self.Log(f"[STATE]  S3: {' '.join(f'{k.Value}({self.Portfolio[k].HoldingsValue/eq:.1%})' for k in s3k)}")

    def _log_budgets(self, tag):
        self.Log(
            f"[STATE] budgets/{tag} mode={'S3+S1' if self._s3_bull_market else 'S1+S2'} "
            f"spy={self.s1_spy_weight:.3f} gld={self.s1_gld_weight:.3f} "
            f"S2={self.s2_sleeve_budget:.3f} "
            f"S3={S3_BULL_BUDGET if self._s3_bull_market else 0.0:.3f} "
            f"S2active={self._sleeves_active}"
        )

    # ?? Universe ??????????????????????????????????????????????????????????????

    def _uni_get_float(self, f, paths):
        for p in paths:
            try:
                obj = f
                for part in p.split('.'): obj = getattr(obj, part)
                if isinstance(obj, (float,int)) and np.isfinite(obj): return float(obj)
                if hasattr(obj,'Value'):
                    val = obj.Value
                    if isinstance(val,(float,int)) and np.isfinite(val): return float(val)
                val = float(obj)
                if np.isfinite(val): return val
            except: continue
        return float('nan')

    def _uni_is_finite(self, v):
        try: return v is not None and np.isfinite(float(v))
        except: return False

    def MergedUniverseSelection(self, fundamentals):
        s2_candidates = []
        s3_buckets    = defaultdict(list)
        for f in fundamentals:
            if not f.has_fundamental_data: continue
            if f.symbol.Value in self._universe_blacklist: continue
            exchange = f.company_reference.primary_exchange_id
            price    = f.price
            mktcap   = f.market_cap
            if (exchange in ("NYS","NAS","ASE") and price and price > 5
                    and mktcap and mktcap >= 5_000_000_000
                    and getattr(f,'DollarVolume',0) >= 50_000_000):   # $50M ADV floor
                sector = f.asset_classification.morningstar_sector_code
                if sector: s3_buckets[sector].append(f)
            if not price or price <= 5: continue
            if getattr(f,'DollarVolume',0) <= 10_000_000: continue
            pe  = self._uni_get_float(f,["ValuationRatios.PERatio","ValuationRatios.PriceEarningsRatio"])
            dte = self._uni_get_float(f,["OperationRatios.DebtToEquity","OperationRatios.TotalDebtEquityRatio"])
            dy  = self._uni_get_float(f,["ValuationRatios.TrailingDividendYield","ValuationRatios.ForwardDividendYield"])
            roi = self._uni_get_float(f,["OperationRatios.ROIC","ProfitabilityRatios.ROIC",
                                         "ProfitabilityRatios.ReturnOnInvestedCapital",
                                         "ProfitabilityRatios.ReturnOnInvestment"])
            if not all(self._uni_is_finite(v) for v in [pe,dte,dy,roi]): continue
            if pe<5 or pe>18 or dte>=1.0 or dy<=0.01 or roi<=0.12: continue
            s2_candidates.append((f.symbol, float(roi)))
        s2_sym = [x[0] for x in sorted(s2_candidates,key=lambda x:x[1],reverse=True)[:20]]
        s3_sym = []
        for _,stocks in s3_buckets.items():
            stocks.sort(key=lambda x:x.market_cap,reverse=True)
            s3_sym.extend(s.symbol for s in stocks[:100])

        # Never return an empty universe — Monday morning fundamental data feed
        # sometimes returns no results. Fall back to the last known selection to
        # prevent OnSecuritiesChanged from removing all symbols and creating orphans.
        result = list(set(s2_sym)|set(s3_sym))
        if not result and hasattr(self,'_last_universe_result') and self._last_universe_result:
            self.Log("[UNI] Empty universe — using last known selection to prevent Monday gap")
            self._s2_candidates = self._last_s2_candidates
            self._s3_candidates = self._last_s3_candidates
            return self._last_universe_result

        self._s2_candidates = set(s2_sym)
        self._s3_candidates = set(s3_sym)
        # Cache for Monday gap fallback
        self._last_universe_result  = result
        self._last_s2_candidates    = set(s2_sym)
        self._last_s3_candidates    = set(s3_sym)
        self.Log(f"[STATE] Universe S2={len(s2_sym)} S3={len(s3_sym)} union={len(result)}")
        return result

    def OnSecuritiesChanged(self, changes: SecurityChanges):
        macro = {self.spy,self.gld,self.hyg,self.lqd,self.ief,self.shy}
        if self.rsp: macro.add(self.rsp)
        added_s2,added_s3,rem_s2,rem_s3 = [],[],[],[]
        for sec in changes.RemovedSecurities:
            s = sec.Symbol
            if s in macro: continue
            self._s2_momentum.pop(s,None); self._s2_added_date.pop(s,None)
            if s in self._s2_candidates: rem_s2.append(s.Value)
            self.s3_symbols.discard(s)
            for d in [self.s3_ma,self.s3_adx,self.s3_stretch_ema,self.s3_close_win,
                      self.s3_band_hist,self.s3_band_idx,self.s3_stretch_win]: d.pop(s,None)
            if s in self._s3_candidates: rem_s3.append(s.Value)
        for sec in changes.AddedSecurities:
            s = sec.Symbol
            if s in macro: continue
            sec.SetFeeModel(InteractiveBrokersFeeModel())
            self._s2_added_date[s] = self.Time
            self._s2_momentum[s]   = self.ROC(s,self.S2_MOMENTUM_LOOKBACK,Resolution.Daily)
            if s in self._s2_candidates: added_s2.append(s.Value)
            if s in self._s3_candidates:
                self.s3_symbols.add(s)
                self.s3_ma[s]          = self.EMA(s,self.s3_band_len,Resolution.Daily)
                self.s3_adx[s]         = self.ADX(s,self.s3_adx_period,Resolution.Daily)
                self.s3_stretch_ema[s] = self.EMA(s,self.s3_band_len,Resolution.Daily)
                self.s3_close_win[s]   = RollingWindow[float](self.s3_band_len)
                self.s3_band_hist[s]   = RollingWindow[int](self.s3_hist_len)
                self.s3_stretch_win[s] = RollingWindow[float](self.s3_hist_len)
                # In live: warm up indicators immediately to prevent skip={'adx': 768}
                # after Monday universe reset. Not needed in backtest (SetWarmUp handles it).
                if self.LiveMode:
                    try:
                        self.WarmUpIndicator(s, self.s3_ma[s],          Resolution.Daily)
                        self.WarmUpIndicator(s, self.s3_adx[s],         Resolution.Daily)
                        self.WarmUpIndicator(s, self.s3_stretch_ema[s], Resolution.Daily)
                    except: pass
                added_s3.append(s.Value)
        if added_s2 or rem_s2:
            self.Log(f"[STATE] UniChange S2 +{len(added_s2)}/-{len(rem_s2)} pool={len(self._s2_candidates)}")
        if added_s3 or rem_s3:
            self.Log(f"[STATE] UniChange S3 +{len(added_s3)}/-{len(rem_s3)} active={len(self.s3_symbols)}")

    # ?? OnData ????????????????????????????????????????????????????????????????

    def OnData(self, data: Slice):
        for s in list(self.s3_symbols):
            if not data.ContainsKey(s): continue
            bar = data[s]
            if bar is None: continue
            close = bar.Close
            self.s3_close_win[s].Add(close)
            if not self.s3_close_win[s].IsReady or not self.s3_ma[s].IsReady: continue
            dev = np.std(list(self.s3_close_win[s]))
            if dev <= 0: continue
            mid     = self.s3_ma[s].Current.Value
            stretch = abs(close - mid) / dev
            self.s3_stretch_ema[s].Update(self.Time, stretch)
            self.s3_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.s3_band_idx[s] = self._s3_band_index(close, bands)

    def _s3_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

    # ?? Daily snapshot ????????????????????????????????????????????????????????

    def _DailySnapshot(self):
        if self.IsWarmingUp: return
        eq        = self.Portfolio.TotalPortfolioValue
        self._hwm = max(self._hwm, eq)
        dd        = (eq-self._hwm)/self._hwm if self._hwm>0 else 0.0
        dr        = (eq-self._prev_value)/self._prev_value if self._prev_value else 0.0
        self._prev_value = eq
        self._daily_rets.append(dr)
        sh = ""
        if len(self._daily_rets) >= 20:
            r = np.array(self._daily_rets)
            sig = np.std(r)*np.sqrt(252)
            sh = f" Sh={np.mean(r)*252/sig if sig>0 else 0:+.2f}"
        macro = {self.spy_hedge, self.gld_hedge}
        s1v = sum(kvp.Value.HoldingsValue for kvp in self.Portfolio if kvp.Value.Invested and kvp.Key in macro)
        s2v = sum(kvp.Value.HoldingsValue for kvp in self.Portfolio if kvp.Value.Invested and kvp.Key in self._s2_candidates)
        s3v = sum(kvp.Value.HoldingsValue for kvp in self.Portfolio if kvp.Value.Invested and kvp.Key in self.s3_symbols)
        mode = "S3+S1  " if self._s3_bull_market else "S1+S2  "
        snap = (
            f"[SNAP] {self.Time:%Y-%m-%d} Eq={eq:,.0f} DD={dd:.2%} D={dr:+.2%}{sh} "
            f"[{mode}] S1={s1v/eq:.1%} S2={s2v/eq:.1%} S3={s3v/eq:.1%} Cash={self.Portfolio.Cash/eq:.1%}"
        )
        self.Log(snap)

        # ?? EOD email alert (live only) ???????????????????????????????????????
        if self.LiveMode:
            # Build position table
            positions = sorted(
                [(kvp.Key.Value, kvp.Value.HoldingsValue, kvp.Value.UnrealizedProfitPercent)
                 for kvp in self.Portfolio if kvp.Value.Invested],
                key=lambda x: -x[1]
            )
            pos_lines = "\n".join(
                f"  {sym:<8} GBP {val/1.27:>8,.0f}  {pct:>+.1%}"
                for sym, val, pct in positions
            )
            subject = (
                f"{'[UP]' if dr >= 0 else '[DN]'} Strategy EOD {self.Time:%d %b %Y} | "
                f"{dr:+.2%} | GBP{eq/1.27:,.0f}"
            )
            body = (
                f"Three-Sleeve Hybrid -- Daily Report\n"
                f"{'='*40}\n"
                f"Date:        {self.Time:%A %d %B %Y}\n"
                f"Mode:        {'BULL (S3+S1)' if self._s3_bull_market else 'BEAR (S1+S2)'}\n"
                f"\n"
                f"Portfolio:   GBP {eq/1.27:>10,.0f}\n"
                f"Day P&L:     {dr:>+10.2%}\n"
                f"Drawdown:    {dd:>+10.2%}\n"
                f"\n"
                f"Allocation:\n"
                f"  S1 Hedge:  {s1v/eq:>6.1%}\n"
                f"  S2 Value:  {s2v/eq:>6.1%}\n"
                f"  S3 Momentum:{s3v/eq:>5.1%}\n"
                f"  Cash:      {self.Portfolio.Cash/eq:>6.1%}\n"
                f"\n"
                f"Positions:\n{pos_lines}\n"
                f"\n"
                f"Gate: SPY={self.Securities[self.spy_hist].Price:.2f} "
                f"vs 50MA -- {'BULL' if self._s3_bull_market else 'BEAR'}\n"
            )
            self.Notify.Email(
                "YOUR_EMAIL@gmail.com",   # ? replace with your email
                subject,
                body
            )

    # ?? History helpers ???????????????????????????????????????????????????????

    def _extract_closes(self, df, symbol):
        if df is None or df.empty: return None
        if isinstance(df.index, pd.MultiIndex):
            for key in (symbol, symbol.Value if hasattr(symbol,'Value') else None):
                if key is None: continue
                try:
                    c = df.xs(key,level=0)['close'].values
                    if len(c)>0: return c
                except: pass
        if 'close' in df.columns:
            c = df['close'].values
            if len(c)>0: return c
        return None

    def _get_closes(self, symbol, n_bars, is_custom=False):
        nm = symbol.Value if hasattr(symbol,'Value') else str(symbol)
        try:
            if is_custom:
                df = self.History(CBOE, symbol, self.Time-timedelta(days=n_bars*2), self.Time, Resolution.Daily)
                c  = self._extract_closes(df, symbol)
                if c is not None: return c
                self.Log(f"_get_closes CBOE [{nm}]: empty"); return None
            df = self.History([symbol], n_bars, Resolution.Daily)
            c  = self._extract_closes(df, symbol)
            if c is not None: return c
            df = self.History([symbol], self.Time-timedelta(days=n_bars*2), self.Time, Resolution.Daily)
            c  = self._extract_closes(df, symbol)
            if c is not None: return c
            self.Log(f"_get_closes [{nm}]: both attempts empty"); return None
        except Exception as e:
            self.Log(f"_get_closes error [{nm}]: {e}"); return None

    def _get_cboe_closes(self, symbol, days=4000, min_bars=1):
        nm = symbol.Value if hasattr(symbol,'Value') else str(symbol)
        for att, mult in enumerate((1,2), start=1):
            try:
                df = self.History(CBOE, symbol, self.Time-timedelta(days=days*mult), self.Time, Resolution.Daily)
                if df is None or df.empty: self.Log(f"_get_cboe [{nm}]: empty att={att}"); continue
                c = df['close'].values
                if len(c)>=min_bars: return c
            except Exception as e:
                self.Log(f"_get_cboe error [{nm}]: {e}"); return None
        self.Log(f"_get_cboe [{nm}]: failed"); return None

    # ?? Sleeve 1 features & training ?????????????????????????????????????????

    def GetFeatures(self, vix_c, spy_c, vix3m_closes=None, hyg_closes=None,
                    lqd_closes=None, rsp_closes=None, ief_closes=None, shy_closes=None):
        if len(vix_c)<MIN_VIX_BARS or len(spy_c)<MIN_SPY_BARS: return None
        try:
            cv=vix_c[-1]; sc=spy_c[-1]
            vs20=np.mean(vix_c[-20:]); vs50=np.mean(vix_c[-50:]); vstd=np.std(vix_c[-20:])
            vz=(cv-vs20)/vstd if vstd>0 else 0.0; vpr=float(np.sum(vix_c<cv))/len(vix_c)
            ss50=np.mean(spy_c[-50:]); ss200=np.mean(spy_c[-200:])
            s5=spy_c[-1]/spy_c[-5]-1; s10=spy_c[-1]/spy_c[-10]-1; s20=spy_c[-1]/spy_c[-20]-1
            svol=np.std(np.diff(spy_c[-21:])/spy_c[-21:-1])
            s60=spy_c[-1]/spy_c[-60]-1; s120=spy_c[-1]/spy_c[-120]-1; s252=spy_c[-1]/spy_c[-252]-1
            vtr=vt5=0.0
            if vix3m_closes is not None and len(vix3m_closes)>=5 and vix3m_closes[-1]>0:
                vtr=cv/vix3m_closes[-1]; vt5=(cv/vix_c[-5])-(vix3m_closes[-1]/vix3m_closes[-5])
            cr=c5=c20=0.0
            if (hyg_closes is not None and lqd_closes is not None
                    and len(hyg_closes)>=MIN_AUX_BARS and len(lqd_closes)>=MIN_AUX_BARS and lqd_closes[-1]>0):
                cr=hyg_closes[-1]/lqd_closes[-1]
                c5=(hyg_closes[-1]/hyg_closes[-5])-(lqd_closes[-1]/lqd_closes[-5])
                c20=(hyg_closes[-1]/hyg_closes[-20])-(lqd_closes[-1]/lqd_closes[-20])
            br=b5=b20=0.0
            if rsp_closes is not None and len(rsp_closes)>=MIN_AUX_BARS and sc>0:
                br=rsp_closes[-1]/sc
                b5=(rsp_closes[-1]/rsp_closes[-5])-(spy_c[-1]/spy_c[-5])
                b20=(rsp_closes[-1]/rsp_closes[-20])-(spy_c[-1]/spy_c[-20])
            cu20=cu60=0.0
            if (ief_closes is not None and shy_closes is not None
                    and len(ief_closes)>=MIN_AUX_BARS and len(shy_closes)>=MIN_AUX_BARS):
                cu20=(ief_closes[-1]/ief_closes[-20])-(shy_closes[-1]/shy_closes[-20])
                cu60=(ief_closes[-1]/ief_closes[-60])-(shy_closes[-1]/shy_closes[-60])
            return [cv,vz,vpr,cv/vs20,cv/vs50,s5,s10,s20,sc/ss50,sc/ss200,
                    svol*np.sqrt(252),s60,s120,s252,vtr,vt5,cr,c5,c20,br,b5,b20,cu20,cu60]
        except Exception as e:
            self.Log(f"GetFeatures error: {e}"); return None

    def TrainModel(self):
        if self.IsWarmingUp: return
        try: self._TrainModelInner()
        except Exception as e: self.Log(f"[S1] TrainModel error: {e}")

    def _TrainModelInner(self):
        vix_c=self._get_cboe_closes(self.vix,4000,MIN_VIX_BARS)
        spy_c=self._get_closes(self.spy_hist,4000)
        if vix_c is None or spy_c is None: self.Log("[S1] TrainModel: missing history"); return
        self.Log(f"[S1] TrainModel SPY {spy_c[0]:.2f}->{spy_c[-1]:.2f} bars={len(spy_c)}")
        vix3m_c=self._get_cboe_closes(self.vix3m,4000,5)
        hyg_c=self._get_closes(self.hyg_hist,4000); lqd_c=self._get_closes(self.lqd_hist,4000)
        rsp_c=self._get_closes(self.rsp,4000) if self.rsp else None
        ief_c=self._get_closes(self.ief_hist,4000); shy_c=self._get_closes(self.shy_hist,4000)
        lc=len(spy_c)-LABEL_HORIZON-SAFETY_BUFFER
        if lc<MIN_SPY_BARS+MIN_TRAIN_ROWS: self.Log("[S1] TrainModel: insufficient data"); return
        te=lc-TRAIN_VAL_GAP
        if te-MIN_SPY_BARS<MIN_TRAIN_ROWS: self.Log("[S1] TrainModel: window too small"); return
        idx=list(range(MIN_SPY_BARS,lc))
        fr=[spy_c[i+LABEL_HORIZON]/spy_c[i]-1 for i in idx]; med=np.median(fr)
        Xa,ya=[],[]
        for ii,i in enumerate(idx):
            ft=self.GetFeatures(vix_c[:i],spy_c[:i],
                vix3m_closes=vix3m_c[:i] if vix3m_c is not None else None,
                hyg_closes=hyg_c[:i] if hyg_c is not None else None,
                lqd_closes=lqd_c[:i] if lqd_c is not None else None,
                rsp_closes=rsp_c[:i] if rsp_c is not None else None,
                ief_closes=ief_c[:i] if ief_c is not None else None,
                shy_closes=shy_c[:i] if shy_c is not None else None)
            if ft is not None: Xa.append(ft); ya.append(1 if fr[ii]>med else 0)
        if len(Xa)<MIN_TRAIN_ROWS+20: self.Log(f"[S1] TrainModel: too few samples ({len(Xa)})"); return
        Xa=np.array(Xa); ya=np.array(ya); r1=float(np.mean(ya))
        if r1>0.95 or r1<0.05: self.Log(f"[S1] TrainModel: degenerate ({r1:.3f})"); self.trained=False; return
        sp=te-MIN_SPY_BARS
        Xtr,ytr=Xa[:sp],ya[:sp]; Xva,yva=Xa[sp:],ya[sp:]
        if len(Xtr)<MIN_TRAIN_ROWS: self.Log("[S1] TrainModel: not enough rows"); return
        self.scaler.fit(Xtr); self.model.fit(self.scaler.transform(Xtr),ytr); self.trained=True
        if len(Xva)>0:
            acc=self.model.score(self.scaler.transform(Xva),yva)
            self.Log(f"[S1] TrainModel acc={acc:.3f} base={np.mean(yva):.3f} edge={acc-np.mean(yva):+.3f}")
        names=["vix_level","vix_zscore","vix_pct_rank","vix_vs_sma20","vix_vs_sma50",
               "spy_5d","spy_10d","spy_20d","spy_vs_sma50","spy_vs_sma200","spy_vol",
               "spy_60d","spy_120d","spy_252d","vix_term_ratio","vix_term_5d",
               "credit_ratio","credit_5d","credit_20d","breadth_ratio","breadth_5d",
               "breadth_20d","curve_20d","curve_60d"]
        top=sorted(zip(names,self.model.feature_importances_),key=lambda x:-x[1])[:5]
        self.Log("[S1] Features: "+" | ".join(f"{n}={v:.3f}" for n,v in top))

    # ?? CheckSignal ???????????????????????????????????????????????????????????

    def CheckSignal(self):
        if self.IsWarmingUp: return
        try: self._CheckSignalInner()
        except Exception as e: self.Log(f"[S1] CheckSignal error: {e}")

    def _CheckSignalInner(self):
        spy_c=self._get_closes(self.spy_hist,270)
        vix_c=self._get_closes(self.vix,300,is_custom=True)
        if spy_c is None or vix_c is None: self.Log("[S1] CheckSignal: missing history"); return
        if len(vix_c)<MIN_VIX_BARS or len(spy_c)<MIN_SPY_BARS:
            self.Log(f"[S1] CheckSignal: bars vix={len(vix_c)} spy={len(spy_c)}"); return

        vix3m_c=self._get_closes(self.vix3m,10,is_custom=True)
        hyg_c=self._get_closes(self.hyg_hist,MIN_AUX_BARS)
        lqd_c=self._get_closes(self.lqd_hist,MIN_AUX_BARS)
        rsp_c=self._get_closes(self.rsp,MIN_AUX_BARS) if self.rsp else None
        ief_c=self._get_closes(self.ief_hist,MIN_AUX_BARS)
        shy_c=self._get_closes(self.shy_hist,MIN_AUX_BARS)

        cv=vix_c[-1]; vsma=np.mean(vix_c[-20:]); v80=np.percentile(vix_c,80)
        sc=spy_c[-1]; s50=np.mean(spy_c[-50:]); s200=np.mean(spy_c[-200:])
        r5=spy_c[-1]/spy_c[-5]-1; r10=spy_c[-1]/spy_c[-10]-1; r20=spy_c[-1]/spy_c[-20]-1

        ml=False
        if self.trained:
            ft=self.GetFeatures(vix_c,spy_c,vix3m_closes=vix3m_c,hyg_closes=hyg_c,
                                lqd_closes=lqd_c,rsp_closes=rsp_c,ief_closes=ief_c,shy_closes=shy_c)
            if ft is not None:
                try:
                    p=self.model.predict_proba(self.scaler.transform([ft]))[0]
                    ml=(p[1] if len(p)==2 else 0.5)>ML_THRESHOLD
                except Exception as e: self.Log(f"[S1] ML error: {e}")

        rs=rg=0.0; sa=True
        if cv>v80 and r5<-0.03:
            rs=(DIP_DEEP_SPY_W_ML if ml else DIP_DEEP_SPY_W) if r10<=DIP_DEEP_THRESHOLD else (DIP_SHALLOW_SPY_W_ML if ml else DIP_SHALLOW_SPY_W)
            rg=max(0.0,1.0-rs); sa=False; rn="R1-dip"
        elif cv<13 and sc>s50*1.05:
            rs=0.40; rg=0.20; rn="R2-lowvol"
        elif 20<cv<vsma:
            rs=0.85 if ml else 0.70; rg=0.10; rn="R3-recovery"
        elif cv>vsma*1.2:
            rs=0.30; rg=0.20; sa=False; rn="R4-stress"
        elif sc>s200:
            rs=0.70 if ml else 0.60; rg=0.15; rn="R5-trend"
        else:
            rs=0.30; rg=0.20; sa=False; rn="R6-below200"

        bull=(sc>s200 and sc>s50 and r20>0.0 and cv<v80 and cv<25)
        self._log_gate(sc,s50,s200,r20,cv,v80,bull)

        prev=self._s3_bull_market
        self._s3_bull_market=bull; self._sleeves_active=sa

        if bull and not prev:
            self.Log(f"[SWITCH] S1+S2->S3+S1(80/20) {self.Time:%Y-%m-%d} spy={sc:.2f} 50MA={s50:.2f} 200MA={s200:.2f} 20d={r20:+.2%} VIX={cv:.1f}")
            self._log_state("PRE-SWITCH->S3+S1")
        elif not bull and prev:
            reason=("VIX>25" if cv>=25 else "VIX>80pct" if cv>=v80
                    else "SPY<50MA" if sc<=s50 else "SPY<200MA" if sc<=s200 else "20d<0")
            self.Log(f"[SWITCH] S3+S1->S1+S2 {self.Time:%Y-%m-%d} reason={reason} spy={sc:.2f} VIX={cv:.1f}")
            self._log_state("PRE-SWITCH->S1+S2")

        if bull:
            # Bull mode: S3=80%, S1=20% (BRK.B 15% + NEM 5%), S2=0%
            self.s1_spy_weight    = S1_BULL_BUDGET * S1_BULL_SPY_FRAC   # 0.15 BRK.B
            self.s1_gld_weight    = S1_BULL_BUDGET * S1_BULL_GLD_FRAC   # 0.05 NEM
            self.s2_sleeve_budget = 0.0
            self._log_budgets("S3+S1-BULL")
            # Backtest: reapply S1 every CheckSignal to maintain exact weights.
            # Live: only on transition or >2% drift to avoid noisy orders.
            if bull and not prev:
                self._liquidate_sleeve2()
                self._safe_set_macro()
            elif not self.LiveMode:
                self._safe_set_macro()
            else:
                tv = self.Portfolio.TotalPortfolioValue
                s1_actual = sum(self.Portfolio[sym].HoldingsValue/tv
                                for sym in [self.spy_hedge, self.gld_hedge]
                                if sym in self.Portfolio and self.Portfolio[sym].Invested) if tv > 0 else 0
                s1_target = self.s1_spy_weight + self.s1_gld_weight
                if abs(s1_actual - s1_target) > 0.02:
                    self.Log(f"[S1] Drift {s1_actual:.1%} vs target {s1_target:.1%} -- reapplying")
                    self._safe_set_macro()
            if bull and not prev:
                tv = self.Portfolio.TotalPortfolioValue
                macro = {self.spy_hedge, self.gld_hedge}
                s3_check = (sum(self.Portfolio[kvp.Key].HoldingsValue/tv
                                for kvp in self.Portfolio
                                if kvp.Value.Invested and kvp.Key not in macro)
                            if tv > 0 else 0.0)
                if not self.LiveMode or s3_check < 0.10:
                    # Backtest always uses full rebalance to maintain ADX-weighted momentum.
                    # Live uses full rebalance only when genuinely empty (avoids churn).
                    self.Log("[S3] CheckSignal: fresh bull entry -- full rebalance")
                    self.RebalanceSleeve3()
                else:
                    # Live redeploy into existing positions -- top up only to avoid churn
                    self.Log(f"[S3] CheckSignal: fresh bull entry (invested {s3_check:.1%}) -- topping up")
                    self._topup_s3()
                self._log_state("POST-DEPLOY-S3")
            elif self._sleeve3_is_empty():
                self.Log("[S3] CheckSignal: empty -- seeding")
                self.RebalanceSleeve3()
                self._log_state("POST-DEPLOY-S3")
            else:
                # Check if S3 is meaningfully underdeployed.
                # If so, run a lightweight seed (buys only, no sells) to top up
                # missing positions using the last known target weights.
                # Full momentum reshuffling is reserved for the monthly RebalanceSleeve3
                # to avoid sell-then-buy cash exhaustion from T+2 settlement.
                tv = self.Portfolio.TotalPortfolioValue
                # Count all non-S1 invested positions including orphans
                macro = {self.spy_hedge, self.gld_hedge}
                s3_actual = (
                    sum(self.Portfolio[kvp.Key].HoldingsValue / tv
                        for kvp in self.Portfolio
                        if kvp.Value.Invested and kvp.Key not in macro)
                    if tv > 0 else 0.0
                )
                if s3_actual < S3_BULL_BUDGET - 0.05:
                    self.Log(
                        f"[S3] CheckSignal: underdeployed "
                        f"({s3_actual:.1%} vs {S3_BULL_BUDGET:.0%} target) -- topping up"
                    )
                    self._topup_s3()
                    self._log_state("POST-DEPLOY-S3")
                else:
                    self.Log(f"[S3] CheckSignal: invested ({s3_actual:.1%})")
                    self._log_state("S3 steady")
        else:
            self.s1_spy_weight=rs; self.s1_gld_weight=rg
            self.s2_sleeve_budget=max(0.0,1.0-rs-rg)
            self._log_budgets(f"S1+S2/{rn}")
            # Genuine S3->S1+S2 transition: force-close all S3 positions including
            # dual-listed stocks. Steady-state daily calls use default transition=False.
            if not bull and prev:
                self._liquidate_sleeve3(transition=True)
            else:
                self._liquidate_sleeve3()
            res=any(self.Portfolio[s].Invested for s in self.s3_symbols if s in self.Portfolio)
            self.Log(f"[SWITCH] S3 post-liq residual={res}")
            self._safe_set_macro()
            self.Log(f"[S1] {rn} vix={cv:.1f} v80={v80:.1f} spy_w={rs:.3f} gld_w={rg:.3f} S2={self.s2_sleeve_budget:.3f} ml={ml}")
            if not sa:
                self._liquidate_sleeve2(); self.Log(f"[S2] OFF ({rn})")
            else:
                if self._sleeve2_is_empty():
                    self.Log("[S2] empty -- seeding"); self.RebalanceSleeve2()
                    self._log_state("POST-DEPLOY-S2")
                else:
                    self.Log(f"[S2] invested budget={self.s2_sleeve_budget:.3f}")
            if not bull and prev: self._log_state("POST-SWITCH-S3->S1+S2 final")

    # ?? Macro helpers ?????????????????????????????????????????????????????????
    def _safe_set_macro(self):
        """S1 hedge: BRK.B (15%) + NEM (5%) in live. SPY/GLD in backtest.
        Skips execution outside market hours to prevent MOO order pile-up."""
        # Same market hours guard as S2/S3 -- prevents MOO conversion and
        # cumulative pending order cash reservation issues from IB.
        if not self.Securities[self.spy_hist].Exchange.DateTimeIsOpen(self.Time):
            self.Log("[S1] Market closed -- deferring S1 hedge to next session")
            return
        for sym, wt in [(self.spy_hedge, self.s1_spy_weight),
                        (self.gld_hedge, self.s1_gld_weight)]:
            if wt <= 0: continue
            try:
                # 95% buffer: accounts for GBP/USD FX conversion overhead
                buffered_wt = wt * 0.95 if self.LiveMode else wt
                qty = int(self.CalculateOrderQuantity(sym, buffered_wt))
                if qty == 0:
                    self.Log(f"[S1] {sym.Value} qty=0 at wt={wt:.3f} -- skipping")
                    continue
                self.MarketOrder(sym, qty)
                self.Log(f"[S1] ORDER {sym.Value}={wt:.3f}(buf={buffered_wt:.3f}) qty={qty:+d} px={self.Securities[sym].Price:.2f}")
            except Exception as e:
                self.Log(f"[S1] {sym.Value} error: {e}")

    def _liquidate_sleeve1(self):
        liq=[]
        for sym in [self.spy_hedge, self.gld_hedge]:
            if sym in self.Portfolio and self.Portfolio[sym].Invested:
                qty = self.Portfolio[sym].Quantity
                if qty != 0:
                    self.MarketOrder(sym, -qty)
                    liq.append(f"{sym.Value}({qty} shares, ${self.Portfolio[sym].HoldingsValue:,.0f})")
        self.Log(f"[S1] LIQ: {' '.join(liq) if liq else 'nothing'}")

    # ?? Sleeve 2 ??????????????????????????????????????????????????????????????

    def _sleeve2_is_empty(self):
        return not any(s in self.Portfolio and self.Portfolio[s].Invested for s in self._s2_candidates)

    def _liquidate_sleeve2(self):
        liq=[]
        for sym in list(self._s2_candidates):
            # In bull mode preserve positions that are also S3 candidates --
            # they belong to S3 and must not be swept by the S2 liquidation loop.
            if self._s3_bull_market and sym in self.s3_symbols:
                continue
            if sym in self.Securities and self.Portfolio[sym].Invested:
                liq.append(f"{sym.Value}(${self.Portfolio[sym].HoldingsValue:,.0f})")
                self.Liquidate(sym)
        self.Log(f"[S2] LIQ: {' '.join(liq) if liq else 'nothing'}")

    def RebalanceSleeve2(self):
        if self.IsWarmingUp: return
        # Skip execution outside market hours -- daily resolution MarketOrders
        # get converted to MOO by QC, which IB then rejects at the next open.
        if not self.Securities[self.spy_hist].Exchange.DateTimeIsOpen(self.Time):
            self.Log("[S2] Market closed -- deferring to next session"); return
        try: self._RebalanceSleeve2Inner()
        except Exception as e: self.Log(f"[S2] error: {e}")

    def _RebalanceSleeve2Inner(self):
        # S2 is OFF in bull mode (S1=20% BRK.B/NEM takes the hedge slot).
        # S2 runs only in S1+S2 mode at cash_sleeve_weight budget.
        if self._s3_bull_market:
            self._liquidate_sleeve2(); self.Log("[S2] BLOCKED -- bull mode (S1 hedge active)"); return
        if not self._sleeves_active or not self._s2_candidates:
            self._liquidate_sleeve2()
            self.Log(f"[S2] OFF sa={self._sleeves_active} cand={len(self._s2_candidates)}"); return
        now=self.Time; cands=[]; sk={"ns":0,"np":0,"tn":0,"nr":0,"nm":0}
        for sym in self._s2_candidates:
            if sym not in self.Securities: sk["ns"]+=1; continue
            sec=self.Securities[sym]
            if not sec.HasData or sec.Price<=0 or not sec.IsTradable: sk["np"]+=1; continue
            ad=self._s2_added_date.get(sym)
            if ad and (now-ad).days<self.S2_MIN_HISTORY_DAYS: sk["tn"]+=1; continue
            roc=self._s2_momentum.get(sym)
            if roc is None or not roc.IsReady: sk["nr"]+=1; continue
            if float(roc.Current.Value)<self.S2_MOMENTUM_MIN_RETURN: sk["nm"]+=1; continue
            cands.append((sym,float(roc.Current.Value)))
        self.Log(f"[S2] Filter pool={len(self._s2_candidates)} qual={len(cands)} skip={sk}")
        cands=sorted(cands,key=lambda x:-x[1])[:self.S2_MAX_POSITIONS]
        if not cands: self._liquidate_sleeve2(); self.Log("[S2] No cands -- liquidated"); return
        n=len(cands)
        pp=min(self.s2_sleeve_budget/n, self.S2_MAX_POSITION_WEIGHT*self.s2_sleeve_budget)
        self.Log(f"[S2] REBAL n={n} budget={self.s2_sleeve_budget:.3f} per_pos={pp:.3f}")
        self.Log(f"  {'Sym':<8} {'Wt%':>6} {'ROC63':>8}")
        for sym,rv in cands:
            qty = int(self.CalculateOrderQuantity(sym, pp))
            if qty == 0:
                self.Log(f"  {sym.Value:<8} SKIP (qty=0 at pp={pp:.3f})")
                continue
            self.Log(f"  {sym.Value:<8} {pp*100:>5.1f}% {rv*100:>+7.2f}% qty={qty:+d}")
            self.MarketOrder(sym, qty)
        tgt={sym for sym,_ in cands}
        for sym in self._s2_candidates:
            if sym not in tgt and sym in self.Portfolio and self.Portfolio[sym].Invested:
                self.Log(f"[S2] CLOSE stale {sym.Value}"); self.Liquidate(sym)
        eq=self.Portfolio.TotalPortfolioValue
        if eq>0:
            act=sum(self.Portfolio[s].HoldingsValue/eq for s in tgt if s in self.Portfolio and self.Portfolio[s].Invested)
            self.Log(f"[S2] Post-rebal target={self.s2_sleeve_budget:.3f} actual={act:.3f} d={act-self.s2_sleeve_budget:+.3f}")

    # ?? Sleeve 3 ??????????????????????????????????????????????????????????????

    def _s3_tickers(self):
        """Ticker strings for all S3 symbols -- robust check after universe resets."""
        return {s.Value for s in self.s3_symbols}

    def _sleeve3_is_empty(self):
        """Use ticker string matching -- Symbol objects may differ after Monday universe reset."""
        tickers = self._s3_tickers()
        return not any(kvp.Value.Invested and kvp.Key.Value in tickers
                       for kvp in self.Portfolio)

    def _liquidate_sleeve3(self, transition=False):
        liq=[]
        for sym in list(self.s3_symbols):
            # Steady-state guard (transition=False): skip symbols that are also S2
            # candidates -- they are legitimate S2 positions and S3 has no authority
            # over them while S1+S2 mode is active. Only a genuine S3->S1+S2
            # transition (transition=True) should force-close everything.
            if not transition and sym in self._s2_candidates:
                continue
            if sym in self.Securities and self.Portfolio[sym].Invested:
                liq.append(f"{sym.Value}(${self.Portfolio[sym].HoldingsValue:,.0f})")
                self.Liquidate(sym)
        self.Log(f"[S3] LIQ: {' '.join(liq) if liq else 'nothing'}")

    def _topup_s3(self):
        """Daily top-up -- buys only, no sells, no momentum reshuffle.
        Handles two cases:
          1. Empty slots (0%) -- finds best momentum candidates to fill them
          2. Undersized slots (<5% when target 8%) -- ADDs to existing positions"""
        if not self.Securities[self.spy_hist].Exchange.DateTimeIsOpen(self.Time):
            self.Log("[S3] Market closed -- deferring top-up"); return
        tv = self.Portfolio.TotalPortfolioValue
        if tv <= 0: return

        target_wt  = S3_BULL_BUDGET / self.s3_stock_count   # 8%
        undersize_threshold = target_wt * 0.70               # below 5.6% = needs topping up

        # ?? Case 1: ADD to undersized existing positions ??????????????????????
        adds = []
        for s in self.s3_symbols:
            if s not in self.Portfolio or not self.Portfolio[s].Invested: continue
            cur_wt = self.Portfolio[s].HoldingsValue / tv
            if cur_wt < undersize_threshold:
                adds.append((s, cur_wt))

        if adds:
            self.Log(f"[S3] Top-up: adding to {len(adds)} undersized positions")
            for s, cur_wt in sorted(adds, key=lambda x: x[1]):  # smallest first
                buffered_tg = target_wt * 0.95 if self.LiveMode else target_wt
                qty = int(self.CalculateOrderQuantity(s, buffered_tg))
                if qty <= 0: continue
                px = self.Securities[s].Price
                self.Log(f"[S3] TOP-UP ADD {s.Value} {cur_wt:.1%}->{target_wt:.1%} qty={qty:+d} px={px:.2f}")
                self.MarketOrder(s, qty)

        # ?? Case 2: Fill empty slots ??????????????????????????????????????????
        # Use ticker string matching for robustness after universe reset
        tickers = self._s3_tickers()
        held_tickers = {kvp.Key.Value for kvp in self.Portfolio
                        if kvp.Value.Invested and kvp.Key.Value in tickers}
        slots_needed = self.s3_stock_count - len(held_tickers)
        if slots_needed <= 0:
            if not adds: self.Log(f"[S3] Top-up: all {len(held_tickers)} positions at target")
            return

        a3 = list(self.s3_symbols)
        if not a3: return
        hist = self.History(a3, max(self.s3_lookbacks)+1, Resolution.Daily)
        if hist.empty: return
        cl = hist["close"].unstack(0)
        min_pos_val = (tv * S3_BULL_BUDGET / self.s3_stock_count) if tv > 0 else 0
        candidates = {}
        for s in a3:
            if s.Value in held_tickers: continue
            if s not in cl: continue
            px = cl[s]
            if len(px) < max(self.s3_lookbacks)+1: continue
            if not self.s3_adx[s].IsReady or self.s3_adx[s].Current.Value > self.s3_adx_limit: continue
            adx_val = self.s3_adx[s].Current.Value
            mv = np.mean([px.iloc[-1]/px.iloc[-lb-1]-1 for lb in self.s3_lookbacks])
            if not self.s3_ma[s].IsReady: continue
            if self.Securities[s].Price <= self.s3_ma[s].Current.Value: continue
            fn = self.Securities[s].Fundamentals
            if fn is None or fn.MarketCap < 5_000_000_000: continue
            if self.Securities[s].Price > min_pos_val: continue
            if mv > 0: candidates[s] = mv * adx_val

        if not candidates:
            self.Log("[S3] Top-up: no candidates for empty slots"); return

        top = sorted(candidates, key=candidates.get, reverse=True)[:slots_needed]
        self.Log(f"[S3] Top-up: filling {len(top)} empty slots at {target_wt:.1%} each")
        for s in top:
            buffered_wt = target_wt * 0.95 if self.LiveMode else target_wt
            qty = int(self.CalculateOrderQuantity(s, buffered_wt))
            if qty <= 0: continue
            px = self.Securities[s].Price
            self.Log(f"[S3] TOP-UP BUY {s.Value} 0%->{target_wt:.1%} qty={qty:+d} px={px:.2f}")
            self.MarketOrder(s, qty)

    def RebalanceSleeve3(self):
        if self.IsWarmingUp: return
        # Skip execution outside market hours -- daily resolution MarketOrders
        # get converted to MOO by QC, which IB then rejects at the next open.
        if not self.Securities[self.spy_hist].Exchange.DateTimeIsOpen(self.Time):
            self.Log("[S3] Market closed -- deferring to next session"); return
        try: self._RebalanceSleeve3Inner()
        except Exception as e: self.Log(f"[S3] error: {e}")

    def _RebalanceSleeve3Inner(self):
        if not self._s3_bull_market:
            self._liquidate_sleeve3(transition=True); self.Log(f"[S3] BLOCKED bull={self._s3_bull_market}"); return
        ds=self.Time.strftime("%Y-%m-%d")
        idxs=list(self.s3_band_idx.values())
        if len(idxs)<50:
            if len(self.s3_symbols)>=50:
                # Symbols subscribed but OnData hasn't populated band indices yet
                # (first deploy after universe loads). Skip breadth, proceed to
                # momentum -- breadth will be available on next rebalance.
                self.Log(
                    f"[S3] Band indices not yet populated ({len(idxs)} of "
                    f"{len(self.s3_symbols)}) -- skipping breadth, running momentum only"
                )
                bf=0.0; self.s3_allow=True
            else:
                self.Log(f"[S3] Universe too small ({len(idxs)})"); return
        else:
            bf=sum(i in self.s3_BOTTOM_LEVELS for i in idxs)/len(idxs)
        self.s3_max_stress=max(self.s3_max_stress,bf)
        if bf>=0.40:
            if not self.s3_was_risk_off: self.s3_risk_off_date=self.Time; self.Log(f"[S3] RISK-OFF {ds} stress={bf:.1%}")
            self.s3_allow=False; self.s3_was_risk_off=True
        elif self.s3_was_risk_off:
            denom=max(self.s3_max_stress,0.10); imp=(self.s3_max_stress-bf)/denom
            doff=(self.Time-self.s3_risk_off_date).days if self.s3_risk_off_date else 0
            self.Log(f"[S3] RISK-OFF check {ds} stress={bf:.1%} imp={imp:.1%} days={doff}")
            if imp>=0.60 or bf<0.15 or doff>180:
                trig="60pct" if imp>=0.60 else "stress<15" if bf<0.15 else "180d"
                self.Log(f"[S3] RECOVERY {ds} trigger={trig}")
                for s in self.s3_symbols:
                    if s in self.s3_band_hist: self.s3_band_hist[s]=RollingWindow[int](self.s3_hist_len)
                self.s3_allow=True; self.s3_was_risk_off=False
                self.s3_max_stress=0.0; self.s3_risk_off_date=None
            else:
                self.Log(f"[S3] RISK-OFF {ds} stress={bf:.1%} imp={imp:.1%} days={doff}")
        else:
            self.s3_allow=True
        if not self.s3_allow: self._liquidate_sleeve3(); return

        a3=list(self.s3_symbols)
        if not a3: return
        hist=self.History(a3,max(self.s3_lookbacks)+1,Resolution.Daily)
        if hist.empty: self.Log("[S3] History empty"); return
        cl=hist["close"].unstack(0); mom={}; sk={"adx":0,"ema":0,"mc":0,"neg":0,"nh":0,"lot":0}
        tv=self.Portfolio.TotalPortfolioValue
        min_pos_val = (tv * S3_BULL_BUDGET / self.s3_stock_count) if tv > 0 else 0
        for s in a3:
            if s not in cl: sk["nh"]+=1; continue
            px=cl[s]
            if len(px)<max(self.s3_lookbacks)+1: sk["nh"]+=1; continue
            if not self.s3_adx[s].IsReady or self.s3_adx[s].Current.Value>self.s3_adx_limit: sk["adx"]+=1; continue
            adx_val = self.s3_adx[s].Current.Value
            mv=np.mean([px.iloc[-1]/px.iloc[-lb-1]-1 for lb in self.s3_lookbacks])
            if not self.s3_ma[s].IsReady: continue
            if self.Securities[s].Price<=self.s3_ma[s].Current.Value: sk["ema"]+=1; continue
            fn=self.Securities[s].Fundamentals
            if fn is None or fn.MarketCap<5_000_000_000: sk["mc"]+=1; continue
            if self.Securities[s].Price > min_pos_val: sk["lot"]+=1; continue
            if mv>0: mom[s]=mv*adx_val   # stronger trend ? higher allocation weight
            else: sk["neg"]+=1
        self.Log(f"[S3] MOMENTUM {ds} uni={len(a3)} qual={len(mom)} skip={sk}")
        if not mom: self.Log("[S3] No momentum -- liquidating"); self._liquidate_sleeve3(); return

        top=sorted(mom,key=mom.get,reverse=True)[:self.s3_stock_count]
        sc2={}; sm={}
        for s in top:
            if not self.s3_ma[s].IsReady or not self.s3_stretch_ema[s].IsReady: continue
            dev=np.std(list(self.s3_close_win[s]))
            if dev<=0: continue
            mid=self.s3_ma[s].Current.Value; lm=self.s3_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]
            px2=self.Securities[s].Price; bi=self._s3_band_index(px2,bands)
            self.s3_band_hist[s].Add(bi)
            hi2=list(self.s3_band_hist[s]); hh=max(hi2) if hi2 else bi
            scale=1.0 if hh<=0 else (0.0 if bi>=hh else max(0.15,1.0-bi/hh))
            ex=False
            if self.s3_stretch_win[s].IsReady:
                sl=list(self.s3_stretch_win[s]); cs=sl[0]; ps=max(sl)
                if bi>=10 and ps>0 and cs<ps*0.80:
                    scale=min(scale,0.15); ex=True
                    self.Log(f"[S3] EXHAUST {s.Value} band={bi} str={cs:.2f}/pk={ps:.2f} sc->{scale:.2f}")
            sc2[s]=mom[s]*scale; sm[s]=(scale,bi,ex)
        if not sc2: self.Log("[S3] Band sizing zero"); self._liquidate_sleeve3(); return

        ts=sum(sc2.values()); rw={s:v/ts for s,v in sc2.items()}
        cw={s:min(0.20,w) for s,w in rw.items()}; cs=sum(cw.values())
        sw={s:(w/cs)*S3_BULL_BUDGET for s,w in cw.items()} if cs>0 else {}

        eq=self.Portfolio.TotalPortfolioValue
        self.Log(f"[S3] REBAL {ds} Eq={eq:,.0f} stress={bf:.1%} pos={len(sw)} budget={S3_BULL_BUDGET:.0%}")
        self.Log(f"  {'Sym':<8} {'Wt%':>6} {'Mom%':>7} {'Scale':>6} {'Band':>4}")
        for s,w in sorted(sw.items(),key=lambda x:-x[1]):
            sc3,bi2,ex2=sm.get(s,(1.0,0,False))
            self.Log(f"  {s.Value:<8} {w*100:>5.1f}% {mom[s]*100:>+6.2f}% {sc3:>6.3f} {bi2:>4}{'EXHAUST' if ex2 else ''}")

        tv=self.Portfolio.TotalPortfolioValue
        if tv<=0: return
        cw2={kvp.Key:kvp.Value.HoldingsValue/tv for kvp in self.Portfolio
             if kvp.Value.Invested and kvp.Key in self.s3_symbols}

        # Orphan cleanup: sell any invested position that is not in the new
        # target (sw), not in s3_symbols, and not S1. These accumulate when
        # positions are bought then the strategy redeploys before the universe
        # re-recognises them. Month-end rebalance is the natural cleanup point.
        macro_tickers = {self.spy_hedge.Value, self.gld_hedge.Value}
        s3_target_tickers = {s.Value for s in sw}
        s3_known_tickers  = self._s3_tickers()
        for kvp in list(self.Portfolio):
            sym = kvp.Key
            if not kvp.Value.Invested: continue
            if sym.Value in macro_tickers: continue          # S1 -- keep
            if sym.Value in s3_target_tickers: continue      # in new target -- keep
            if sym.Value in s3_known_tickers: continue       # known S3 -- rebalance handles it
            # Orphan: not in target, not known to strategy
            self.Log(f"[S3] CLOSE orphan {sym.Value} ({kvp.Value.HoldingsValue/tv:.1%})")
            self.MarketOrder(sym, -kvp.Value.Quantity)

        trades=[]
        # Build full trade list then sort: sells first (-qty) so cash is
        # freed before buys execute, avoiding insufficient settled cash rejections.
        trade_list=[]
        for s in set(list(cw2)+list(sw)):
            tg=sw.get(s,0.0); cu=cw2.get(s,0.0); dl=tg-cu
            if abs(dl)<=self.s3_rebal_threshold: continue
            qty=int(self.CalculateOrderQuantity(s, tg))
            if qty==0: continue
            rn2="BUY" if cu==0 and tg>0 else "CLOSE" if tg==0 else "ADD" if dl>0 else "TRIM"
            px3=self.Securities[s].Price if s in self.Securities else 0
            trade_list.append((s, qty, rn2, px3, cu, tg, dl))
        # Sells (negative qty) first, then buys
        trade_list.sort(key=lambda x: x[1])
        for s, qty, rn2, px3, cu, tg, dl in trade_list:
            # Apply 95% buffer on all orders in live to cover GBP/USD overhead
            if self.LiveMode:
                buffered_tg = tg * 0.95 if tg > 0 else tg
                qty = int(self.CalculateOrderQuantity(s, buffered_tg))
                if qty == 0: continue
            self.Log(f"[S3] {rn2} {s.Value} {cu*100:.1f}%->{tg*100:.1f}% (d{dl*100:+.1f}%) px={px3:.2f} qty={qty:+d}")
            self.MarketOrder(s, qty); trades.append(s.Value)
        if not trades: self.Log(f"[S3] No trades needed {ds}")

        eq=self.Portfolio.TotalPortfolioValue
        if eq>0:
            s3a=sum(self.Portfolio[s].HoldingsValue/eq for s in sw if s in self.Portfolio and self.Portfolio[s].Invested)
            s1a=sum(self.Portfolio[sym].HoldingsValue/eq for sym in [self.spy_hedge,self.gld_hedge]
                    if sym in self.Portfolio and self.Portfolio[sym].Invested)
            expected_s1=self.s1_spy_weight+self.s1_gld_weight
            self.Log(f"[S3] Post-rebal S3={s3a:.1%} S1={s1a:.1%}(exp={expected_s1:.1%}) cash={self.Portfolio.Cash/eq:.1%}")
            if abs(s1a-expected_s1)>0.05:
                self.Log(f"[S3] WARNING S1 drift={s1a-expected_s1:+.1%} -- reapplying macro")
                self._safe_set_macro()

    # ?? Warmup / End ??????????????????????????????????????????????????????????

    def OnWarmupFinished(self):
        self.Log("[INIT] Warmup complete")
        self._log_state("PRE-INIT")

        # Guard: if universe hasn't populated yet defer to first CheckSignal
        if len(self.s3_symbols) == 0 and len(self._s2_candidates) == 0:
            self.Log("[INIT] Universe not yet populated -- deferring to first CheckSignal")
            self._initial_deploy_done = True
            return

        # Infer prior mode from existing positions to avoid spurious "fresh bull entry"
        # on every redeploy. If ?5 S3 positions exist we were already in bull mode.
        # This prevents the full momentum reshuffle that causes churn on redeploy.
        if self.Portfolio.TotalHoldingsValue != 0:
            macro = {self.spy_hedge, self.gld_hedge}
            managed = macro | self._s2_candidates | self.s3_symbols
            s3_invested = sum(1 for s in self.s3_symbols
                              if s in self.Portfolio and self.Portfolio[s].Invested)
            if s3_invested >= 5:
                self._s3_bull_market = True
                self.Log(f"[INIT] {s3_invested} S3 positions found -- inferring prior BULL mode (no fresh entry)")
            kept, unknown = [], []
            for kvp in self.Portfolio:
                if not kvp.Value.Invested: continue
                if kvp.Key in managed: kept.append(kvp.Key.Value)
                else: unknown.append(kvp.Key.Value)
            if kept:    self.Log(f"[INIT] Existing managed positions: {kept}")
            if unknown:
                self.Log(f"[INIT] Existing unclassified positions (keeping): {unknown}")
                # Fix 2: Force-add orphan positions into s3_symbols so the strategy
                # manages them. Initialise indicators with WarmUpIndicator in live.
                # At month-end rebalance they will either be kept (if in top-10)
                # or cleanly sold by the orphan cleanup block.
                for kvp in self.Portfolio:
                    sym = kvp.Key
                    if not kvp.Value.Invested: continue
                    if sym.Value not in unknown: continue
                    macro = {self.spy_hedge.Value, self.gld_hedge.Value}
                    if sym.Value in macro: continue
                    self.s3_symbols.add(sym)
                    self._s3_candidates.add(sym)
                    if sym not in self.s3_ma:
                        self.s3_ma[sym]          = self.EMA(sym, self.s3_band_len, Resolution.Daily)
                        self.s3_adx[sym]         = self.ADX(sym, self.s3_adx_period, Resolution.Daily)
                        self.s3_stretch_ema[sym] = self.EMA(sym, self.s3_band_len, Resolution.Daily)
                        self.s3_close_win[sym]   = RollingWindow[float](self.s3_band_len)
                        self.s3_band_hist[sym]   = RollingWindow[int](self.s3_hist_len)
                        self.s3_stretch_win[sym] = RollingWindow[float](self.s3_hist_len)
                        if self.LiveMode:
                            try:
                                self.WarmUpIndicator(sym, self.s3_ma[sym],          Resolution.Daily)
                                self.WarmUpIndicator(sym, self.s3_adx[sym],         Resolution.Daily)
                                self.WarmUpIndicator(sym, self.s3_stretch_ema[sym], Resolution.Daily)
                            except: pass
                    self.Log(f"[INIT] Orphan {sym.Value} added to s3_symbols -- will be managed")

        self._initial_deploy_done = True
        self.CheckSignal()
        self._log_state("POST-INIT")

    def OnEndOfAlgorithm(self):
        eq=self.Portfolio.TotalPortfolioValue
        self.Log(f"[END] Eq={eq:,.2f} Ret={(eq/100_000-1)*100:+.2f}%")
        self._log_state("END"); self._log_budgets("END")


class CBOE(PythonData):
    def GetSource(self, config, date, isLive):
        urls={"VIX":"https://cdn.cboe.com/api/global/us_indices/daily_prices/VIX_History.csv",
              "VIX3M":"https://cdn.cboe.com/api/global/us_indices/daily_prices/VIX3M_History.csv"}
        return SubscriptionDataSource(urls.get(config.Symbol.Value,urls["VIX"]),SubscriptionTransportMedium.RemoteFile)

    def Reader(self, config, line, date, isLive):
        if not (line.strip() and line[0].isdigit()): return None
        cols=line.split(',')
        try:
            obj=CBOE(); obj.Symbol=config.Symbol
            obj.Time=datetime.strptime(cols[0],"%m/%d/%Y"); obj.Value=float(cols[4])
            obj["close"]=float(cols[4]); obj["open"]=float(cols[1])
            obj["high"]=float(cols[2]);  obj["low"]=float(cols[3])
            return obj
        except: return None
"""
Momentum and Historical Band Ceiling Sizing Algorithm (v3 - Surgical Fixes)

Changes from v1 (minimal, critical only):
1. Real fees + slippage (InteractiveBrokersFeeModel + 10bps)
2. Delta-based execution (no more full liquidate-then-rebuy)
3. stretch_max replaced with rolling stretch_win (fixes survivorship bias)
4. Exhaustion scaling bug fixed (min() instead of override)
5. 180-day hard timeout on risk-off regime (prevents getting permanently stuck)
6. Live market cap check at rebalance time (fixes BBIO-type universe leak)

Everything else — Fibonacci bands, ADX filter, breadth logic, momentum scoring,
universe construction — is identical to v1.
"""

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

# ====================================================
# Sector-Neutral Large-Cap Universe (unchanged from v1)
# ====================================================
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


# ====================================================
# Main Algorithm
# ====================================================
class StockOnlyMomentumV3(QCAlgorithm):

    def Initialize(self):
        self.SetStartDate(2021, 1, 1)
        self.SetEndDate(2026, 1, 1)
        self.SetCash(100_000)

        # --------------------
        # Momentum parameters (unchanged)
        # --------------------
        self.lookbacks = [21, 63, 126, 189, 252]
        self.stock_count = 10
        self.max_weight = 0.20

        # --------------------
        # Band parameters (unchanged)
        # --------------------
        self.band_len = 189
        self.hist_len = 126

        self.UniverseSettings.Resolution = Resolution.Daily
        self.UniverseSettings.DataNormalizationMode = DataNormalizationMode.TOTAL_RETURN

        # -------- BREADTH STATE (unchanged) --------
        self.allow_universe = True
        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   # FIX 5: track entry date for hard timeout

        self.SetUniverseSelection(
            SectorTopUniverse(self, blacklist={"GME", "AMC"})
        )

        self.symbols = set()

        self.adx_limit = 35
        self.adx_period = 14

        # Per-symbol state
        self.ma = {}
        self.adx = {}
        self.close_win = {}
        self.stretch_ema = {}
        self.band_hist = {}
        self.stretch_win = {}   # FIX 3: rolling window replaces stretch_max dict

        # Delta execution threshold
        self.rebalance_threshold = 0.02  # FIX 2: only trade if drift > 2%

        self.SetWarmUp(300)

        self.Schedule.On(
            self.DateRules.MonthEnd("SPY"),
            self.TimeRules.BeforeMarketClose("SPY", 5),
            self.Rebalance
        )

    # --------------------------------------------------
    def OnSecuritiesChanged(self, changes):
        for sec in changes.AddedSecurities:
            # FIX 1: Realistic fees and slippage
            sec.SetFeeModel(InteractiveBrokersFeeModel())
            sec.SetSlippageModel(ConstantSlippageModel(0.001))  # 10bps round-trip

            s = sec.Symbol
            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)  # FIX 3

        for sec in changes.RemovedSecurities:
            s = sec.Symbol
            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)   # FIX 3

    # --------------------------------------------------
    def OnData(self, data):
        """Unchanged from v1 — uses fixed Fibonacci bands for breadth tracking."""
        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)

            # FIX 3: rolling stretch window instead of lifetime peak
            self.stretch_win[s].Add(stretch)

            # Fibonacci bands (unchanged from v1)
            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
            ]

            idx = self._band_index(close, bands)
            self.current_band_idx[s] = idx

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

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

        # -------- UNIVERSE-WIDE BREADTH (unchanged logic) --------
        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)

        # -------- BREADTH REGIME --------
        if bottom_frac >= 0.45:
            if not self.was_risk_off:
                self.risk_off_date = self.Time   # FIX 5: record entry date
            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

            # FIX 5: hard 180-day timeout — never stuck risk-off indefinitely
            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:
                self.Debug(f"RECOVERY: stress={bottom_frac:.1%}, days_off={days_risk_off}. Resetting ceilings.")
                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._execute_targets({})   # FIX 2: delta execution even for full exit
            self.Debug(f"RISK-OFF @ {self.Time.strftime('%Y-%m-%d')}: stress={bottom_frac:.1%}")
            return

        # -------- MOMENTUM SCORING (unchanged) --------
        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

            # FIX 6: live market cap check — universe selection can go stale
            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._execute_targets({})
            return

        top = sorted(momentum, key=momentum.get, reverse=True)[:self.stock_count]

        # -------- BAND SIZING (unchanged from v1) --------
        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)

            # FIX 3 + FIX 4: exhaustion check using rolling stretch_win + min() bug fix
            if self.stretch_win[s].IsReady:
                stretch_list = list(self.stretch_win[s])
                current_stretch = stretch_list[0]       # most recent value
                peak_stretch = max(stretch_list)        # rolling peak, not lifetime peak

                if idx >= 10 and peak_stretch > 0:
                    if current_stretch < (peak_stretch * 0.80):
                        scale = min(scale, 0.2)         # FIX 4: min() not override
                        self.Debug(f"EXHAUSTION: Scaling down {s.Value}")

            scaled[s] = momentum[s] * scale

        # -------- WEIGHTING (unchanged) --------
        if not scaled:
            self._execute_targets({})
            self.Debug("No assets to trade.")
            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())
        if current_sum > 0:
            final_weights = {s: w / current_sum for s, w in capped_weights.items()}
        else:
            final_weights = {}

        # FIX 2: delta execution
        self._execute_targets(final_weights)

        output = ", ".join([f"{s.Value}: {w*100:.1f}%" for s, w in final_weights.items() if w > 0])
        if output:
            self.Debug(f"Weights @ {self.Time.strftime('%Y-%m-%d')}: {output}")

    # --------------------------------------------------
    def _execute_targets(self, target_weights):
        """
        FIX 2: Delta-based execution.
        Only trades positions where drift from target exceeds 2%.
        Positions not in target_weights are closed (target = 0).
        Replaces the costly Liquidate()-then-SetHoldings() pattern.
        """
        total_value = self.Portfolio.TotalPortfolioValue
        if total_value <= 0:
            return

        current_weights = {}
        for kvp in self.Portfolio:
            s = kvp.Key
            holding = kvp.Value
            if holding.Invested:
                current_weights[s] = holding.HoldingsValue / total_value

        all_symbols = set(list(current_weights.keys()) + list(target_weights.keys()))

        for s in all_symbols:
            target  = target_weights.get(s, 0.0)
            current = current_weights.get(s, 0.0)
            if abs(target - current) > self.rebalance_threshold:
                self.SetHoldings(s, target)
"""
S3 Momentum + Gold Sleeve + UPRO Crash Hedge
=============================================
Capital allocation:
  60% S3 momentum positions (sector-neutral large-cap)
  40% Gold sleeve — GLD in backtest, NEM in live
  Cash account — no margin, no shorting

Hedge overlay:
  When crash conditions fire, gold sleeve is swapped to UPRO (3x SPY long)
  in a single SetHoldings call. On hedge exit, UPRO swapped back to gold.
  S3 sleeve runs completely independently and is never touched by hedge logic.

S3 risk-off:
  When breadth stress triggers risk-off, S3 positions are liquidated.
  Gold sleeve is unaffected — remains at 40% allocation.
  Cash from S3 liquidation sits idle (earns IB interest in live).

Hedge entry (all must be true simultaneously):
  - Portfolio DD from HWM > 10%
  - SPY below 200-day SMA
  - Breadth stress >= 35% for 3 consecutive days
  - Not in 30-day cooldown after previous exit

Hedge exit (first condition wins):
  - SPY recrosses 200MA
  - Breadth stress drops below 25%
  - Stress drops below 3-day rolling mean
  - 30 days max hold

Gold instrument:
  - Backtest: FNV (Franco-Nevada Corporation)
  - Live: FNV (Franco-Nevada Corporation)

Brokerage: InteractiveBrokers Cash account (no margin)

EMAIL: replace YOUR_EMAIL@gmail.com before deploying live
"""

from AlgorithmImports import *
from datetime import date
from collections import defaultdict, deque
import numpy as np

# -- Operational constants --------------------------------------------------
STRESS_AMBER        = 0.35
STRESS_RED          = 0.45
RECOVERY_THRESHOLD  = 0.60
FREE_CASH_PCT       = 0.025
YOUR_EMAIL          = "tinusanjeev@gmail.com"

# -- Capital allocation -----------------------------------------------------
S3_BUDGET           = 0.60   # S3 momentum sleeve
GOLD_BUDGET         = 0.40   # Gold / hedge sleeve

# -- Drawdown circuit breaker -----------------------------------------------
DD_THRESHOLD        = 0.15
DD_FLOOR            = 0.50

# -- Hedge parameters -------------------------------------------------------
HEDGE_ENABLED       = True  # Set True to enable UPRO crash hedge, False to run gold-only
HEDGE_TOLERANCE     = 0.02
SPY_HEDGE_MAX       = 0.90
SPY_HEDGE_STEP      = 0.30
STRESS_CRASH        = 0.35
DD_CRASH            = -0.10
STRESS_PERSIST_D    = 3
COOLDOWN_DAYS       = 30

# -- Fix 3: rolling window size for stretch ceiling -------------------------
STRETCH_WIN_LEN     = 126


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 not f.price or f.price <= 5: continue
            if not f.market_cap or f.market_cap < 5_000_000_000: continue
            sector = f.asset_classification.morningstar_sector_code
            if sector: 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 StockOnlyMomentum(QCAlgorithm):

    def Initialize(self):
        self.SetStartDate(2021, 1, 1)
        self.SetEndDate(2026, 1, 1)
        self.SetCash(100_000)
        self.SetBrokerageModel(
            BrokerageName.InteractiveBrokersBrokerage, AccountType.Cash)  # Cash account
        self.Settings.FreePortfolioValuePercentage = FREE_CASH_PCT

        # -- Schedule anchor ------------------------------------------------
        self.anchor = self.AddEquity("SPY", Resolution.Daily).Symbol
        self.SetBenchmark("SPY")
        self.SetSecurityInitializer(
            lambda s: (s.SetFeeModel(InteractiveBrokersFeeModel()),
                       s.SetFillModel(ImmediateFillModel()),
                       s.SetSlippageModel(ConstantSlippageModel(0.001))))  # Deploy book: 10bps slippage

        # -- Gold sleeve ----------------------------------------------------
        # FNV (Franco-Nevada royalty model) used in both backtest and live
        gold_ticker        = "FNV"
        self.gold          = self.AddEquity(gold_ticker, Resolution.Daily).Symbol
        self.hedge_instrument = self.AddEquity("UPRO", Resolution.Daily).Symbol

        # -- Momentum parameters --------------------------------------------
        self.lookbacks   = [21, 63, 126, 189, 252]
        self.stock_count = 10
        self.max_weight  = 0.20

        # -- Band parameters ------------------------------------------------
        self.band_len = 189
        self.hist_len = 126

        self.UniverseSettings.Resolution            = Resolution.Daily
        self.UniverseSettings.DataNormalizationMode = DataNormalizationMode.TotalReturn

        # -- Breadth / regime state -----------------------------------------
        self.allow_universe   = True
        self.was_risk_off     = False
        self.risk_off_date    = None
        self.max_stress       = 0.0
        self.current_band_idx: dict = {}
        self.BOTTOM_LEVELS    = {0, 1, 2, 3, 4}

        # -- Per-symbol indicators ------------------------------------------
        self.symbols:      set  = set()
        self.ma:           dict = {}
        self.adx:          dict = {}
        # FIX 3: stretch_max dict removed — replaced by stretch_win RollingWindow
        self.close_win:    dict = {}
        self.stretch_ema:  dict = {}
        self.stretch_win:  dict = {}
        self.band_hist:    dict = {}

        self.adx_limit  = 35
        self.adx_period = 14

        # -- Daily snapshot state -------------------------------------------
        self._hwm        = 0.0
        self._prev_value = None
        self._daily_rets = deque(maxlen=252)

        # -- Hedge state ----------------------------------------------------
        self._hedge_active       = False
        self._hedge_entry_price  = None
        self._hedge_entry_date   = None
        self._hedge_entry_stress = None
        self._hedge_entry_dd     = None
        self._hedge_last_target  = 0.0
        self._hedge_trades       = []
        self.current_hedge_target = 0.0
        self.last_hedge_exit     = None

        # -- SMA200 + stress window -----------------------------------------
        self.spy_sma200   = self.SMA(self.anchor, 200, Resolution.Daily)
        self.WarmUpIndicator(self.anchor, self.spy_sma200, Resolution.Daily)
        self.stress_window = RollingWindow[float](STRESS_PERSIST_D)

        # -- FIX 2: pending weights for MOO drain ---------------------------
        self._pending_weights = None

        # -- Universe + schedule --------------------------------------------
        self.SetWarmUp(300)
        self.SetUniverseSelection(
            SectorTopUniverse(self, blacklist={"GME", "AMC"}))

        self.Schedule.On(
            self.DateRules.MonthEnd(self.anchor),
            self.TimeRules.BeforeMarketClose(self.anchor, 5),
            self._Rebalance)
        self.Schedule.On(
            self.DateRules.EveryDay(self.anchor),
            self.TimeRules.AfterMarketOpen(self.anchor, 10),
            self._DailyHedgeCheck)
        self.Schedule.On(
            self.DateRules.EveryDay(self.anchor),
            self.TimeRules.BeforeMarketClose(self.anchor, 1),
            self._DailySnapshot)

    # =========================================================================
    # SECURITIES CHANGED
    # =========================================================================

    def OnSecuritiesChanged(self, changes):
        excl = {self.anchor, self.gold, self.hedge_instrument}

        for sec in changes.RemovedSecurities:
            s = sec.Symbol
            if s in excl: continue
            self.symbols.discard(s)
            for d in [self.ma, self.adx, self.close_win,
                      self.stretch_ema, self.stretch_win, self.band_hist,
                      self.current_band_idx]:
                d.pop(s, None)
            # FIX 3: no stretch_max dict to clean up

        for sec in changes.AddedSecurities:
            s = sec.Symbol
            if s in excl: continue
            self.symbols.add(s)
            # FIX 3: stretch_max[s] = 0.0 removed — stretch_win is the rolling peak
            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.stretch_win[s]  = RollingWindow[float](STRETCH_WIN_LEN)   # FIX 3: bounded 126-day window
            self.band_hist[s]    = RollingWindow[int](self.hist_len)
            if self.LiveMode:
                try:
                    self.WarmUpIndicator(s, self.ma[s],          Resolution.Daily)
                    self.WarmUpIndicator(s, self.adx[s],         Resolution.Daily)
                    self.WarmUpIndicator(s, self.stretch_ema[s], Resolution.Daily)
                except: pass

    # =========================================================================
    # ON DATA
    # =========================================================================

    def OnData(self, data: Slice):
        # FIX 2: drain pending MOO rebalance weights placed by _Rebalance
        if self._pending_weights is not None and not self.IsWarmingUp:
            targets      = self._pending_weights
            self._pending_weights = None
            equity       = self.Portfolio.TotalPortfolioValue
            excl         = {self.anchor, self.gold, self.hedge_instrument}

            # Exit positions not in new targets
            for pos in list(self.Portfolio.Values):
                if pos.Invested and pos.Symbol not in targets and pos.Symbol not in excl:
                    if pos.Quantity != 0:
                        self.MarketOnOpenOrder(pos.Symbol, -pos.Quantity)

            # Enter / adjust to new targets via delta orders
            for sym, w in targets.items():
                if w <= 0 or not self.Securities.ContainsKey(sym): continue
                price = self.Securities[sym].Price
                if price <= 0: continue
                target_qty = int(equity * w / price)
                cur        = self.Portfolio[sym].Quantity if self.Portfolio.ContainsKey(sym) else 0
                delta      = target_qty - cur
                if abs(delta) > 0:
                    drift = abs(delta * price) / equity
                    if drift >= 0.02:   # FIX 2: skip trivial rebalances (< 2% drift)
                        self.MarketOnOpenOrder(sym, delta)

        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)
            # FIX 3: stretch_max update removed — peak is computed from rolling window at rebalance

            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)

    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

    # =========================================================================
    # DAILY HEDGE CHECK -- AMO+10
    # =========================================================================

    def _DailyHedgeCheck(self):
        if self.IsWarmingUp: return
        if not self.Securities[self.anchor].Exchange.DateTimeIsOpen(self.Time): return
        if not self.spy_sma200.IsReady: return

        eq = self.Portfolio.TotalPortfolioValue
        self._hwm = max(self._hwm, eq)
        dd = (eq - self._hwm) / self._hwm if self._hwm > 0 else 0.0

        # breadth stress
        idxs = list(self.current_band_idx.values())
        if len(idxs) == 0: return
        bottom_frac = sum(i in self.BOTTOM_LEVELS for i in idxs) / len(idxs)
        self.stress_window.Add(bottom_frac)
        if self.stress_window.Count < STRESS_PERSIST_D: return

        stress_persistent = all(v >= STRESS_CRASH for v in self.stress_window)

        # trend
        spy_price  = float(self.Securities[self.anchor].Price)
        sma200     = float(self.spy_sma200.Current.Value)
        trend_down = spy_price < sma200

        # cooldown
        if isinstance(self.last_hedge_exit, date):
            in_cooldown = (self.Time.date() - self.last_hedge_exit).days < COOLDOWN_DAYS
        else:
            in_cooldown = False

        crash_env = HEDGE_ENABLED and trend_down and stress_persistent and dd <= DD_CRASH and not in_cooldown
        current_upro_w = self.Portfolio[self.hedge_instrument].HoldingsValue / eq

        # ── HEDGE ON / SCALE UP ───────────────────────────────────────────
        if crash_env:
            target = 0.0
            if dd <= DD_CRASH:              target = SPY_HEDGE_STEP
            if dd <= DD_CRASH - 0.05:      target = 2 * SPY_HEDGE_STEP
            if dd <= DD_CRASH - 0.10:      target = SPY_HEDGE_MAX

            if target != self._hedge_last_target:
                upro_price = float(self.Securities[self.hedge_instrument].Price)
                self.Debug(f"[HEDGE ENTER] Swapping Gold → UPRO {target:.0%} "
                           f"dd={dd:.1%} stress={bottom_frac:.2f} "
                           f"spy={spy_price:.2f} sma200={sma200:.2f} upro={upro_price:.2f}")
                # Single SetHoldings: sell gold, buy UPRO simultaneously
                self.SetHoldings([
                    PortfolioTarget(self.gold,             0.0),
                    PortfolioTarget(self.hedge_instrument, target)
                ])
                self._hedge_last_target   = target
                self.current_hedge_target = target
                if self.LiveMode:
                    self.Log(f"[HEDGE ENTER] Gold→UPRO {target:.0%} dd={dd:.1%}")
                    self.Notify.Email(YOUR_EMAIL,
                        f"[ALERT] Hedge Entered {self.Time:%d %b %Y}",
                        f"Gold swapped to UPRO {target:.0%}\n"
                        f"DD={dd:.1%} Stress={bottom_frac:.1%}\n"
                        f"Portfolio: GBP{eq/1.27:,.0f}")

            if not self._hedge_active:
                self._hedge_active       = True
                self._hedge_entry_price  = float(self.Securities[self.hedge_instrument].Price)
                self._hedge_entry_date   = self.Time
                self._hedge_entry_stress = bottom_frac
                self._hedge_entry_dd     = dd
            return

        # ── HEDGE OFF / EXIT ──────────────────────────────────────────────
        stress_mean  = np.mean([float(x) for x in self.stress_window])
        max_duration = False
        if HEDGE_ENABLED and self._hedge_active and self._hedge_entry_date is not None:
            max_duration = (self.Time.date() - self._hedge_entry_date.date()).days >= 30

        exit_signal = (
            spy_price > sma200 or
            bottom_frac < 0.25 or
            bottom_frac < stress_mean or
            max_duration
        )

        if exit_signal and current_upro_w > HEDGE_TOLERANCE:
            # guard against None state
            if self._hedge_entry_price is None:
                self.SetHoldings([
                    PortfolioTarget(self.hedge_instrument, 0.0),
                    PortfolioTarget(self.gold,             GOLD_BUDGET)
                ])
                self._hedge_active        = False
                self._hedge_last_target   = 0.0
                self.current_hedge_target = 0.0
                self.last_hedge_exit      = self.Time.date()
                return

            exit_price = float(self.Securities[self.hedge_instrument].Price)
            ret = (exit_price - self._hedge_entry_price) / self._hedge_entry_price

            self.Debug(f"[HEDGE EXIT] Swapping UPRO → Gold "
                       f"px:{self._hedge_entry_price:.2f}→{exit_price:.2f} "
                       f"ret:{ret:+.2%} dd:{self._hedge_entry_dd:.1%}→{dd:.1%} "
                       f"stress:{self._hedge_entry_stress:.2f}→{bottom_frac:.2f}")

            # Single SetHoldings: sell UPRO, buy gold simultaneously
            self.SetHoldings([
                PortfolioTarget(self.hedge_instrument, 0.0),
                PortfolioTarget(self.gold,             GOLD_BUDGET)
            ])

            if self.LiveMode:
                self.Log(f"[HEDGE EXIT] UPRO→Gold ret={ret:+.2%} dd={dd:.1%}")
                self.Notify.Email(YOUR_EMAIL,
                    f"[ALERT] Hedge Exited {self.Time:%d %b %Y}",
                    f"UPRO swapped back to Gold\n"
                    f"Return: {ret:+.2%}\n"
                    f"DD: {self._hedge_entry_dd:.1%} → {dd:.1%}\n"
                    f"Portfolio: GBP{eq/1.27:,.0f}")

            self._hedge_trades.append({
                "entry_date":   self._hedge_entry_date,
                "exit_date":    self.Time,
                "entry_price":  self._hedge_entry_price,
                "exit_price":   exit_price,
                "return":       ret,
                "entry_stress": self._hedge_entry_stress,
                "exit_stress":  bottom_frac,
                "entry_dd":     self._hedge_entry_dd,
                "exit_dd":      dd,
            })

            self._hedge_active        = False
            self._hedge_entry_price   = None
            self._hedge_entry_date    = None
            self._hedge_entry_stress  = None
            self._hedge_entry_dd      = None
            self._hedge_last_target   = 0.0
            self.current_hedge_target = 0.0
            self.last_hedge_exit      = self.Time.date()

    # =========================================================================
    # REBALANCE -- MonthEnd BMC-5
    # =========================================================================

    def _Rebalance(self):
        if self.IsWarmingUp: return
        if not self.Securities[self.anchor].Exchange.DateTimeIsOpen(self.Time): 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 = max(self.max_stress, bottom_frac)

        # -- Breadth regime -------------------------------------------------
        if bottom_frac >= STRESS_RED:
            if not self.was_risk_off:
                self.risk_off_date = self.Time
            self.allow_universe = False
            self.was_risk_off   = True
            msg = f"[STRESS-RED] RISK-OFF bottom_frac={bottom_frac:.1%}"
            self.Debug(msg)
            if self.LiveMode:
                self.Log(msg)
                self.Notify.Email(YOUR_EMAIL,
                    f"[ALERT] S3 Risk-Off {self.Time:%d %b %Y}",
                    f"Breadth stress {bottom_frac:.1%} >= {STRESS_RED:.0%}\n"
                    f"S3 liquidated. Gold sleeve unaffected.\n"
                    f"Portfolio: GBP{self.Portfolio.TotalPortfolioValue/1.27:,.0f}")

        elif bottom_frac >= STRESS_AMBER and self.allow_universe:
            msg = f"[STRESS-AMBER] bottom_frac={bottom_frac:.1%}"
            self.Debug(msg)
            if self.LiveMode:
                self.Log(msg)
                self.Notify.Email(YOUR_EMAIL,
                    f"[ALERT] S3 Amber {self.Time:%d %b %Y}",
                    f"Stress {bottom_frac:.1%} approaching {STRESS_RED:.0%}\n"
                    f"Portfolio: GBP{self.Portfolio.TotalPortfolioValue/1.27:,.0f}")

        elif self.was_risk_off:
            denom = max(self.max_stress, 0.10)
            imp   = (self.max_stress - bottom_frac) / denom
            doff  = (self.Time - self.risk_off_date).days if self.risk_off_date else 0
            if imp >= RECOVERY_THRESHOLD or bottom_frac < 0.15 or doff > 180:
                trig = ("60pct" if imp >= RECOVERY_THRESHOLD
                        else "stress<15" if bottom_frac < 0.15 else "180d")
                msg = f"[RECOVERY] trigger={trig} stress={bottom_frac:.1%}"
                self.Debug(msg)
                if self.LiveMode:
                    self.Log(msg)
                    self.Notify.Email(YOUR_EMAIL,
                        f"[ALERT] S3 Recovery {self.Time:%d %b %Y}",
                        f"Breadth recovered. trigger={trig}\n"
                        f"Re-entering market.")
                for s in self.symbols:
                    if s in self.band_hist:
                        self.band_hist[s] = RollingWindow[int](self.hist_len)
                # FIX 3: reset stretch_win windows instead of stretch_max dict
                for s in self.symbols:
                    if s in self.stretch_win:
                        self.stretch_win[s] = RollingWindow[float](STRETCH_WIN_LEN)
                self.allow_universe = True
                self.was_risk_off   = False
                self.max_stress     = 0.0
                self.risk_off_date  = None
            else:
                self.Debug(f"[RISK-OFF] stress={bottom_frac:.1%} imp={imp:.1%} days={doff}")
        else:
            self.allow_universe = True

        # S3 risk-off: liquidate S3 only, preserve gold and hedge instrument
        if not self.allow_universe:
            for k in list(self.Portfolio):
                if (k.Value.Invested
                        and k.Key != self.anchor
                        and k.Key != self.gold
                        and k.Key != self.hedge_instrument):
                    self.Liquidate(k.Key)
            self.Debug(f"[RISK-OFF] S3 liquidated. Gold sleeve intact. stress={bottom_frac:.1%}")
            return

        # -- Exposure scale from breadth ------------------------------------
        target_exposure = float(np.interp(
            bottom_frac, [0.15, STRESS_RED], [1.0, 0.0]))
        target_exposure = float(round(target_exposure, 2))

        # -- DD circuit breaker ---------------------------------------------
        if self._hwm > 0 and bottom_frac < STRESS_AMBER:
            dd = (self.Portfolio.TotalPortfolioValue - self._hwm) / self._hwm
            if dd < -DD_THRESHOLD:
                dd_scale = max(DD_FLOOR, 1.0 + dd)
                target_exposure *= dd_scale
                self.Debug(f"[DD BREAKER] dd={dd:.1%} scale={dd_scale:.2f} "
                           f"exposure→{target_exposure:.2f}")
                if self.LiveMode:
                    self.Log(f"[DD BREAKER] dd={dd:.1%} scale={dd_scale:.2f} "
                             f"exposure→{target_exposure:.2f}")

        # -- Momentum ranking -----------------------------------------------
        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
            if self.Securities[s].Price <= self.ma[s].Current.Value: continue
            # FIX 6 (deploy book): re-check market cap at rebalance, not just at universe selection
            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:
            for k in list(self.Portfolio):
                if (k.Value.Invested
                        and k.Key != self.anchor
                        and k.Key != self.gold
                        and k.Key != self.hedge_instrument):
                    self.Liquidate(k.Key)
            return

        top = sorted(momentum, key=momentum.get, reverse=True)[:self.stock_count]

        # -- Band ceiling + exhaustion scaling ------------------------------
        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_h = max(hist_idx) if hist_idx else idx
            scale = (1.0 if historical_h <= 0
                     else 0.0 if idx >= historical_h
                     else max(0.2, 1.0 - idx / historical_h))

            # FIX 3: exhaustion uses rolling window peak, not lifetime stretch_max
            if self.stretch_win[s].IsReady:
                sw     = list(self.stretch_win[s])
                cur_s  = sw[0]
                peak_s = max(sw)   # FIX 3: 126-day rolling peak, not lifetime max
                if idx >= 10 and peak_s > 0 and cur_s < peak_s * 0.80:
                    scale = min(scale, 0.2)
                    self.Debug(f"[ANTICIPATION] {s.Value}")

            scaled[s] = (momentum[s] * self.adx[s].Current.Value) * scale

        if not scaled:
            for k in list(self.Portfolio):
                if (k.Value.Invested
                        and k.Key != self.anchor
                        and k.Key != self.gold
                        and k.Key != self.hedge_instrument):
                    self.Liquidate(k.Key)
            return

        # -- Final weighting: scale to S3_BUDGET ---------------------------
        total_scaled = sum(scaled.values())
        raw_weights  = {s: v / total_scaled for s, v in scaled.items()}
        capped       = {s: min(self.max_weight, w) for s, w in raw_weights.items()}
        cur_sum      = sum(capped.values())

        final_weights = {}
        if cur_sum > 0:
            for s, w in capped.items():
                final_weights[s] = (w / cur_sum) * S3_BUDGET * target_exposure

        # -- FIX 2: queue S3 targets for MOO drain in OnData ---------------
        # Gold and hedge are still managed directly via SetHoldings (intraday timing matters)
        s3_targets_dict = {s: w * (0.95 if self.LiveMode else 1.0)
                           for s, w in final_weights.items() if w > 0}
        self._pending_weights = s3_targets_dict

        # Gold target — only if hedge not active (hedge manages gold/UPRO swap)
        if not self._hedge_active:
            self.SetHoldings([PortfolioTarget(self.gold, GOLD_BUDGET)])

        hedge_tag = " [HEDGED]" if self._hedge_active else ""
        self.Debug(f"[S3] REBAL {self.Time:%Y-%m-%d} stress={bottom_frac:.1%} "
                   f"exp={target_exposure:.0%} pos={len(final_weights)}{hedge_tag} "
                   f"gold={'UPRO' if self._hedge_active else 'FNV'} "
                   f"[MOO queued]")
        if self.LiveMode:
            self.Log(f"[S3] REBAL {self.Time:%Y-%m-%d} stress={bottom_frac:.1%} "
                     f"exp={target_exposure:.0%} pos={len(final_weights)}{hedge_tag} "
                     f"stocks={[s.Value for s in final_weights]}")

    # =========================================================================
    # DAILY SNAPSHOT
    # =========================================================================

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

        eq = self.Portfolio.TotalPortfolioValue
        self._hwm = max(self._hwm, eq)
        dd = (eq - self._hwm) / self._hwm if self._hwm > 0 else 0.0
        dr = (eq - self._prev_value) / self._prev_value if self._prev_value else 0.0
        self._prev_value = eq
        self._daily_rets.append(dr)

        sh = ""
        if len(self._daily_rets) >= 20:
            r   = np.array(self._daily_rets)
            sig = np.std(r) * np.sqrt(252)
            sh  = f" Sh={np.mean(r)*252/sig if sig>0 else 0:+.2f}"

        mode  = "BULL" if self.allow_universe else "RISK-OFF"
        hedge = "|HEDGED" if self._hedge_active else ""
        gold_sym = "UPRO" if self._hedge_active else "FNV"
        self.Debug(f"[SNAP] {self.Time:%Y-%m-%d} Eq={eq:,.0f} DD={dd:.2%} "
                   f"D={dr:+.2%}{sh} [{mode}{hedge}] "
                   f"Gold={gold_sym} Cash={self.Portfolio.Cash/eq:.1%}")

        if not self.LiveMode: return

        positions = sorted(
            [(k.Key.Value, k.Value.HoldingsValue, k.Value.UnrealizedProfitPercent)
             for k in self.Portfolio if k.Value.Invested],
            key=lambda x: -x[1])
        pos_lines = "\n".join(
            f"  {s:<8} GBP {v/1.27:>8,.0f}  {p:>+.1%}"
            for s, v, p in positions)
        subject = (f"{'[UP]' if dr>=0 else '[DN]'} EOD {self.Time:%d %b %Y} "
                   f"{dr:+.2%} GBP{eq/1.27:,.0f} [{mode}{hedge}]")
        body = (f"Mode: {mode}{hedge} | Gold: {gold_sym}\n"
                f"Portfolio: GBP{eq/1.27:,.0f}\n"
                f"Day: {dr:+.2%}  DD: {dd:+.2%}{sh}\n\n{pos_lines}")
        self.Notify.Email(YOUR_EMAIL, subject, body)

    # =========================================================================
    # WARMUP FINISHED
    # =========================================================================

    def OnWarmupFinished(self):
        self.Debug("[INIT] Warmup complete")
        if self.LiveMode: self.Log("[INIT] Warmup complete")

        eq = self.Portfolio.TotalPortfolioValue
        self._hwm        = eq
        self._prev_value = eq

        if self.Portfolio.TotalHoldingsValue != 0:
            excl    = {self.anchor, self.gold, self.hedge_instrument}
            managed = self.symbols | excl

            s3_inv = sum(1 for s in self.symbols
                         if s in self.Portfolio and self.Portfolio[s].Invested)
            if s3_inv >= 5:
                self.allow_universe = True
                msg = f"[INIT] {s3_inv} S3 positions found -- inferring BULL mode"
                self.Debug(msg)
                if self.LiveMode: self.Log(msg)

            unknown = [k.Key for k in self.Portfolio
                       if k.Value.Invested and k.Key not in managed]
            for sym in unknown:
                self.symbols.add(sym)
                if sym not in self.ma:
                    # FIX 3: no stretch_max initialisation needed
                    self.ma[sym]           = self.EMA(sym, self.band_len, Resolution.Daily)
                    self.adx[sym]          = self.ADX(sym, self.adx_period, Resolution.Daily)
                    self.stretch_ema[sym]  = self.EMA(sym, self.band_len, Resolution.Daily)
                    self.close_win[sym]    = RollingWindow[float](self.band_len)
                    self.stretch_win[sym]  = RollingWindow[float](STRETCH_WIN_LEN)   # FIX 3
                    self.band_hist[sym]    = RollingWindow[int](self.hist_len)
                    if self.LiveMode:
                        try:
                            self.WarmUpIndicator(sym, self.ma[sym],          Resolution.Daily)
                            self.WarmUpIndicator(sym, self.adx[sym],         Resolution.Daily)
                            self.WarmUpIndicator(sym, self.stretch_ema[sym], Resolution.Daily)
                        except: pass
                msg = f"[INIT] Adopted orphan: {sym.Value}"
                self.Debug(msg)
                if self.LiveMode: self.Log(msg)

        # Initialise gold position if not already held and not in hedge
        if not self._hedge_active:
            gold_held = self.Portfolio[self.gold].Invested
            if not gold_held:
                self.SetHoldings(self.gold, GOLD_BUDGET)
                self.Debug(f"[INIT] Initialised gold position at {GOLD_BUDGET:.0%}")

        self.Debug(f"[INIT] Symbols={len(self.symbols)} "
                   f"Invested={sum(1 for k in self.Portfolio if k.Value.Invested)} "
                   f"Gold={'UPRO(hedge)' if self._hedge_active else 'FNV'}")

    # =========================================================================
    # END OF ALGORITHM
    # =========================================================================

    def OnEndOfAlgorithm(self):
        eq = self.Portfolio.TotalPortfolioValue
        self.Debug(f"[END] Eq={eq:,.2f} Ret={(eq/100_000-1)*100:+.2f}%")
        self.Debug(f"[END] Hedge trades: {len(self._hedge_trades)}")
        for t in self._hedge_trades:
            self.Debug(
                f"[HEDGE TRADE] "
                f"{t['entry_date']:%Y-%m-%d}→{t['exit_date']:%Y-%m-%d} "
                f"px:{t['entry_price']:.2f}→{t['exit_price']:.2f} "
                f"ret:{t['return']:+.2%} "
                f"stress:{t['entry_stress']:.2f}→{t['exit_stress']:.2f} "
                f"dd:{t['entry_dd']:.2%}→{t['exit_dd']:.2%}"
            )
#2011 - 2026 : PSR92.209%Sharpe Ratio1.433Total Orders962Average Win2.74%Average Loss-1.06%Compounding Annual Return51.043%Drawdown30.200%Expectancy1.292Start Equity100000End Equity51524077.59Net Profit51424.078%Sortino Ratio1.711Loss Rate36%Win Rate64%Profit-Loss Ratio2.59Alpha0.279Beta0.823Annual Standard Deviation0.243Annual Variance0.059Information Ratio1.233Tracking Error0.214Treynor Ratio0.423Total Fees$109951.66Estimated Strategy Capacity$84000000.00Lowest Capacity AssetBBIO X5ON8DLL8TIDPortfolio Turnover3.05%Drawdown Recovery1022
#Live on 19th May - after hedge and DD Circuit
from AlgorithmImports import *
from datetime import date
from collections import defaultdict, deque
import numpy as np

# -- Operational constants --------------------------------------------------
STRESS_AMBER        = 0.35   # breadth stress amber alert threshold
STRESS_RED          = 0.45   # breadth stress red / risk-off threshold
RECOVERY_THRESHOLD  = 0.60   # 60% improvement from peak stress -> recovery
FREE_CASH_PCT       = 0.025  # FreePortfolioValuePercentage buffer
YOUR_EMAIL          = "tinusanjeev@gmail.com"

# -- Drawdown circuit breaker -----------------------------------------------
DD_THRESHOLD        = 0.15   # DD from HWM that triggers the circuit breaker
DD_FLOOR            = 0.50   # minimum exposure fraction when breaker fires
DD_THRESHOLD_HEDGE  = 0.10   # added for hedge

# -- Hedge parameters --------------------------------------------------------
HEDGE_TOLERANCE     = 0.02   # avoid churn unless weight differs by >2%

SPY_HEDGE_MAX      = 0.90      # max 90% long
SPY_HEDGE_STEP     = 0.30      # 30% → 60% → 90% as crash deepens
STRESS_CRASH       = 0.35      # bottom bands threshold for persistent stress
STRESS_RECOVER     = 0.20      # below 20% = normal
DD_CRASH           = -0.10     # -10% portfolio DD to start hedging
DD_RECOVER         = -0.05     # recover above -5% → remove hedge
STRESS_PERSIST_D   = 3         # stress must be high for 3 consecutive days
COOLDOWN_DAYS      = 30        # no new hedge for 30 days after exit


class SectorTopUniverse(FundamentalUniverseSelectionModel):
    """
    Sector-neutral large-cap universe.
    Filters: primary exchange, price > $5, market cap >= $5B.
    Selects top 100 by market cap within each Morningstar sector.
    """
    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 not f.price or f.price <= 5: continue
            if not f.market_cap or f.market_cap < 5_000_000_000: continue
            sector = f.asset_classification.morningstar_sector_code
            if sector: 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 StockOnlyMomentum(QCAlgorithm):
    """
    S3 Momentum strategy with full live operational features.
    """

    def Initialize(self):
        self.SetStartDate(2011, 1, 1)
        self.SetCash(100_000)
        self.SetBrokerageModel(
            BrokerageName.InteractiveBrokersBrokerage, AccountType.Margin)
        self.Settings.FreePortfolioValuePercentage = FREE_CASH_PCT

        self.anchor = self.AddEquity("SPY", Resolution.Daily).Symbol
        self.hedge_instrument = self.AddEquity("UPRO", Resolution.Daily).Symbol
        self.SetBenchmark("SPY")
        self.SetSecurityInitializer(
            lambda s: (s.SetFeeModel(InteractiveBrokersFeeModel()),
                       s.SetFillModel(ImmediateFillModel())))

        # -- Momentum parameters --------------------------------------------
        self.lookbacks   = [21, 63, 126, 189, 252]
        self.stock_count = 10
        self.max_weight  = 0.20

        # -- Band parameters ------------------------------------------------
        self.band_len  = 189
        self.hist_len  = 126

        self.UniverseSettings.Resolution            = Resolution.Daily
        self.UniverseSettings.DataNormalizationMode = DataNormalizationMode.TotalReturn

        # -- Breadth / regime state -----------------------------------------
        self.allow_universe  = True
        self.was_risk_off    = False
        self.risk_off_date   = None
        self.max_stress      = 0.0
        self.current_band_idx: dict = {}
        self.BOTTOM_LEVELS   = {0, 1, 2, 3, 4}

        # -- Per-symbol indicators ------------------------------------------
        self.symbols:      set  = set()
        self.ma:           dict = {}
        self.adx:          dict = {}
        self.stretch_max:  dict = {}
        self.close_win:    dict = {}
        self.stretch_ema:  dict = {}
        self.stretch_win:  dict = {}
        self.band_hist:    dict = {}

        self.adx_limit  = 35
        self.adx_period = 14

        # -- Daily snapshot state -------------------------------------------
        self._hwm          = 0.0
        self._prev_value   = None
        self._daily_rets   = deque(maxlen=252)

        # -- Hedge state ----------------------------------------------------
        self._spy_hedge_on        = False
        self._hedge_active        = False
        self._hedge_entry_price   = None
        self._hedge_entry_date    = None
        self._hedge_entry_stress  = None
        self._hedge_entry_dd      = None
        self._hedge_trades        = []
        # FIX 1: track last placed target to prevent daily re-orders at same level
        self._hedge_last_target   = 0.0

        self.spy_sma200 = self.SMA(self.anchor, 200, Resolution.Daily)
        self.WarmUpIndicator(self.anchor, self.spy_sma200, Resolution.Daily)

        self.stress_window = RollingWindow[float](STRESS_PERSIST_D)
        self.last_hedge_exit = None
        self.current_hedge_target = 0.0

        # -- Universe + schedule --------------------------------------------
        self.SetWarmUp(300)
        self.SetUniverseSelection(
            SectorTopUniverse(self, blacklist={"GME", "AMC"}))

        self.Schedule.On(
            self.DateRules.MonthEnd(self.anchor),
            self.TimeRules.BeforeMarketClose(self.anchor, 5),
            self.Rebalance)
        self.Schedule.On(
            self.DateRules.EveryDay(self.anchor),
            self.TimeRules.BeforeMarketClose(self.anchor, 1),
            self._DailySnapshot)
        self.Schedule.On(
            self.DateRules.EveryDay(self.anchor),
            self.TimeRules.AfterMarketOpen(self.anchor, 10),
            self._DailyHedgeCheck)

    # =========================================================================
    # SECURITIES CHANGED
    # =========================================================================

    def OnSecuritiesChanged(self, changes):
        excl = {self.anchor}

        for sec in changes.RemovedSecurities:
            s = sec.Symbol
            if s in excl: continue
            self.symbols.discard(s)
            for d in [self.ma, self.adx, self.stretch_max, self.close_win,
                      self.stretch_ema, self.stretch_win, self.band_hist,
                      self.current_band_idx]:
                d.pop(s, None)

        for sec in changes.AddedSecurities:
            s = sec.Symbol
            if s in excl: continue
            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.stretch_win[s]  = RollingWindow[float](self.hist_len)
            self.band_hist[s]    = RollingWindow[int](self.hist_len)
            if self.LiveMode:
                try:
                    self.WarmUpIndicator(s, self.ma[s],          Resolution.Daily)
                    self.WarmUpIndicator(s, self.adx[s],         Resolution.Daily)
                    self.WarmUpIndicator(s, self.stretch_ema[s], Resolution.Daily)
                except: pass

    # =========================================================================
    # ON DATA
    # =========================================================================

    def OnData(self, data: Slice):
        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)

            if stretch > self.stretch_max.get(s, 0.0):
                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)

    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

    # =========================================================================
    # DAILY SNAPSHOT (BMC-1)
    # =========================================================================

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

        eq = self.Portfolio.TotalPortfolioValue
        self._hwm = max(self._hwm, eq)
        dd = (eq - self._hwm) / self._hwm if self._hwm > 0 else 0.0
        dr = (eq - self._prev_value) / self._prev_value if self._prev_value else 0.0
        self._prev_value = eq
        self._daily_rets.append(dr)

        sh = ""
        if len(self._daily_rets) >= 20:
            r   = np.array(self._daily_rets)
            sig = np.std(r) * np.sqrt(252)
            sh  = f" Sh={np.mean(r)*252/sig if sig>0 else 0:+.2f}"

        mode = "BULL" if self.allow_universe else "RISK-OFF"
        hedge = "|HEDGED" if self._hedge_active else ""
        self.Debug(f"[SNAP] {self.Time:%Y-%m-%d} Eq={eq:,.0f} DD={dd:.2%} "
                   f"D={dr:+.2%}{sh} [{mode}{hedge}] Cash={self.Portfolio.Cash/eq:.1%}")

        if not self.LiveMode: return

        positions = sorted(
            [(k.Key.Value, k.Value.HoldingsValue, k.Value.UnrealizedProfitPercent)
             for k in self.Portfolio if k.Value.Invested],
            key=lambda x: -x[1])
        pos_lines = "\n".join(
            f"  {s:<8} GBP {v/1.27:>8,.0f}  {p:>+.1%}"
            for s, v, p in positions)
        subject = (f"{'[UP]' if dr>=0 else '[DN]'} EOD {self.Time:%d %b %Y} "
                   f"{dr:+.2%} GBP{eq/1.27:,.0f} [{mode}{hedge}]")
        body = (f"Mode: {mode}{hedge}\nPortfolio: GBP{eq/1.27:,.0f}\n"
                f"Day: {dr:+.2%}  DD: {dd:+.2%}{sh}\n\n{pos_lines}")
        self.Notify.Email(YOUR_EMAIL, subject, body)

    # =========================================================================
    # DAILY HEDGE CHECK -- SPY long overlay (recovery play)
    # =========================================================================

    def _DailyHedgeCheck(self):
        if self.IsWarmingUp: return
        if not self.Securities[self.anchor].Exchange.DateTimeIsOpen(self.Time):
            return

        eq = self.Portfolio.TotalPortfolioValue
        self._hwm = max(self._hwm, eq)
        dd = (eq - self._hwm) / self._hwm if self._hwm > 0 else 0.0

        # --- breadth / stress ---
        idxs = list(self.current_band_idx.values())
        if len(idxs) == 0: return

        bottom_frac = sum(i in self.BOTTOM_LEVELS for i in idxs) / len(idxs)
        self.stress_window.Add(bottom_frac)
        if self.stress_window.Count < STRESS_PERSIST_D: return

        stress_persistent = all(v >= STRESS_CRASH for v in self.stress_window)

        # --- trend --- (always read SPY price for 200MA comparison)
        spy_price = self.Securities[self.anchor].Price
        sma200    = self.spy_sma200.Current.Value
        trend_down = spy_price < sma200

        # --- cooldown check ---
        if isinstance(self.last_hedge_exit, date):
            days_since_exit = (self.Time.date() - self.last_hedge_exit).days
            in_cooldown = days_since_exit < COOLDOWN_DAYS
        else:
            in_cooldown = False

        # --- crash-only hedge ON condition ---
        crash_env = trend_down and stress_persistent and dd <= DD_CRASH and not in_cooldown

        current_w = self.Portfolio[self.hedge_instrument].HoldingsValue / eq

        # ---------------- HEDGE ON / SCALE UP --------------------------------
        if crash_env:
            target = 0.0
            if dd <= DD_CRASH:               target = SPY_HEDGE_STEP
            if dd <= DD_CRASH - 0.05:        target = 2 * SPY_HEDGE_STEP
            if dd <= DD_CRASH - 0.10:        target = SPY_HEDGE_MAX

            # only place order if target changed from last placed order
            if target != self._hedge_last_target:
                upro_price = self.Securities[self.hedge_instrument].Price
                self.Debug(f"[CRASH-HEDGE] Buying UPRO → {target:.1%} "
                           f"(stress={bottom_frac:.2f}, dd={dd:.2%}, "
                           f"spy={spy_price:.2f}, sma200={sma200:.2f}, upro={upro_price:.2f})")
                self.SetHoldings(self.hedge_instrument, target)
                self._hedge_last_target   = target
                self.current_hedge_target = target

            if not self._hedge_active:
                self._hedge_active       = True
                self._hedge_entry_price  = self.Securities[self.hedge_instrument].Price
                self._hedge_entry_date   = self.Time
                self._hedge_entry_stress = bottom_frac
                self._hedge_entry_dd     = dd

            self._spy_hedge_on = True
            return

        # ---------------- HEDGE OFF / EXIT -----------------------------------
        stress_mean  = np.mean([float(x) for x in self.stress_window])
        max_duration = False
        if self._hedge_active and self._hedge_entry_date is not None:
            max_duration = (self.Time.date() - self._hedge_entry_date.date()).days >= 30

        exit_signal = (
            spy_price > sma200 or
            bottom_frac < 0.25 or
            bottom_frac < stress_mean or
            max_duration
        )

        if exit_signal and current_w > HEDGE_TOLERANCE:
            exit_price = self.Securities[self.hedge_instrument].Price
            # guard against None state — can happen if hedge was active from
            # a previous session or warmup edge case
            if self._hedge_entry_price is None:
                self.Liquidate(self.hedge_instrument)
                self._hedge_active        = False
                self._spy_hedge_on        = False
                self.current_hedge_target = 0.0
                self._hedge_last_target   = 0.0
                self.last_hedge_exit      = self.Time.date()
                return
            self.Debug(f"[CRASH-HEDGE] Selling UPRO long position "
                       f"(stress={bottom_frac:.2f}, dd={dd:.2%}, "
                       f"spy={spy_price:.2f}, sma200={sma200:.2f}, upro={exit_price:.2f})")
            self.Liquidate(self.hedge_instrument)

            ret = (exit_price - self._hedge_entry_price) / self._hedge_entry_price \
                if self._hedge_entry_price else 0

            self.Debug(
                f"[CRASH-HEDGE EXIT] {self.Time:%Y-%m-%d} "
                f"px:{self._hedge_entry_price:.2f}→{exit_price:.2f} "
                f"ret:{ret:+.2%} "
                f"stress:{self._hedge_entry_stress:.2f}→{bottom_frac:.2f} "
                f"dd:{self._hedge_entry_dd:.2%}→{dd:.2%}"
            )

            self._hedge_trades.append({
                "entry_date":   self._hedge_entry_date,
                "exit_date":    self.Time,
                "entry_price":  self._hedge_entry_price,
                "exit_price":   exit_price,
                "return":       ret,
                "entry_stress": self._hedge_entry_stress,
                "exit_stress":  bottom_frac,
                "entry_dd":     self._hedge_entry_dd,
                "exit_dd":      dd,
            })

            self._hedge_active        = False
            self._hedge_entry_price   = None
            self._hedge_entry_date    = None
            self._hedge_entry_stress  = None
            self._hedge_entry_dd      = None
            self._spy_hedge_on        = False
            self.current_hedge_target = 0.0
            self._hedge_last_target   = 0.0
            self.last_hedge_exit      = self.Time.date()

    # =========================================================================
    # REBALANCE (MonthEnd BMC-5)
    # =========================================================================

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

        if not self.Securities[self.anchor].Exchange.DateTimeIsOpen(self.Time):
            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 = max(self.max_stress, bottom_frac)

        if bottom_frac >= STRESS_RED:
            if not self.was_risk_off:
                self.risk_off_date = self.Time
            self.allow_universe = False
            self.was_risk_off   = True
            msg = f"[STRESS-RED] RISK-OFF bottom_frac={bottom_frac:.1%}"
            self.Debug(msg)
            if self.LiveMode:
                self.Log(msg)
                self.Notify.Email(YOUR_EMAIL,
                    f"[ALERT] S3 Risk-Off {self.Time:%d %b %Y} stress={bottom_frac:.1%}",
                    f"Breadth stress {bottom_frac:.1%} >= {STRESS_RED:.0%} threshold.\n"
                    f"S3 positions liquidated. Hedge overlay unaffected.\n"
                    f"Portfolio: GBP{self.Portfolio.TotalPortfolioValue/1.27:,.0f}")

        elif bottom_frac >= STRESS_AMBER and self.allow_universe:
            msg = f"[STRESS-AMBER] bottom_frac={bottom_frac:.1%} - approaching risk-off"
            self.Debug(msg)
            if self.LiveMode:
                self.Log(msg)
                self.Notify.Email(YOUR_EMAIL,
                    f"[ALERT] S3 Amber Warning {self.Time:%d %b %Y} stress={bottom_frac:.1%}",
                    f"Breadth stress {bottom_frac:.1%} approaching risk-off threshold {STRESS_RED:.0%}.\n"
                    f"Still invested. Monitor closely.\n"
                    f"Portfolio: GBP{self.Portfolio.TotalPortfolioValue/1.27:,.0f}")

        elif self.was_risk_off:
            denom = max(self.max_stress, 0.10)
            imp   = (self.max_stress - bottom_frac) / denom
            doff  = (self.Time - self.risk_off_date).days if self.risk_off_date else 0
            if imp >= RECOVERY_THRESHOLD or bottom_frac < 0.15 or doff > 180:
                trig = ("60pct" if imp >= RECOVERY_THRESHOLD
                        else "stress<15" if bottom_frac < 0.15 else "180d")
                msg = f"[RECOVERY] trigger={trig} stress={bottom_frac:.1%}"
                self.Debug(msg)
                if self.LiveMode:
                    self.Log(msg)
                    self.Notify.Email(YOUR_EMAIL,
                        f"[ALERT] S3 Recovery {self.Time:%d %b %Y}",
                        f"Breadth recovered. trigger={trig} stress={bottom_frac:.1%}\n"
                        f"Resetting band ceilings and re-entering market.")
                for s in self.symbols:
                    if s in self.band_hist:
                        self.band_hist[s] = RollingWindow[int](self.hist_len)
                self.stretch_max    = {s: 0.0 for s in self.stretch_max}
                self.allow_universe = True
                self.was_risk_off   = False
                self.max_stress     = 0.0
                self.risk_off_date  = None
            else:
                self.Debug(f"[RISK-OFF] stress={bottom_frac:.1%} imp={imp:.1%} days={doff}")
        else:
            self.allow_universe = True

        if not self.allow_universe:
            # liquidate S3 positions only — preserve hedge instrument position
            for k in list(self.Portfolio):
                if k.Value.Invested and k.Key != self.anchor and k.Key != self.hedge_instrument:
                    self.Liquidate(k.Key)
            self.Debug(f"[RISK-OFF] S3 liquidated. stress={bottom_frac:.1%}")
            return

        # -- Exposure scale from breadth ------------------------------------
        target_exposure = float(np.interp(
            bottom_frac, [0.15, STRESS_RED], [1.0, 0.0]))
        target_exposure = float(round(target_exposure, 2))

        # -- DD circuit breaker (MonthEnd only) -----------------------------
        if self._hwm > 0 and bottom_frac < STRESS_AMBER:
            dd = (self.Portfolio.TotalPortfolioValue - self._hwm) / self._hwm
            if dd < -DD_THRESHOLD:
                dd_scale = max(DD_FLOOR, 1.0 + dd)
                target_exposure *= dd_scale
                self.Debug(f"[DD CIRCUIT BREAKER] dd={dd:.1%} "
                           f"scale={dd_scale:.2f} exposure→{target_exposure:.2f}")
                if self.LiveMode:
                    self.Log(f"[DD CIRCUIT BREAKER] dd={dd:.1%} "
                             f"scale={dd_scale:.2f} exposure→{target_exposure:.2f}")

        # -- Momentum ranking -----------------------------------------------
        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
            if mom > 0: momentum[s] = mom

        if not momentum:
            for k in list(self.Portfolio):
                if k.Value.Invested and k.Key != self.anchor and k.Key != self.hedge_instrument:
                    self.Liquidate(k.Key)
            return

        top = sorted(momentum, key=momentum.get, reverse=True)[:self.stock_count]

        # -- Band ceiling + exhaustion scaling ------------------------------
        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_h = max(hist_idx) if hist_idx else idx
            scale = (1.0 if historical_h <= 0
                     else 0.0 if idx >= historical_h
                     else max(0.2, 1.0 - idx / historical_h))

            if self.stretch_win[s].IsReady:
                sw     = list(self.stretch_win[s])
                cur_s  = sw[0]
                peak_s = max(sw)
                if idx >= 10 and peak_s > 0 and cur_s < peak_s * 0.80:
                    scale = min(scale, 0.2)

            scaled[s] = (momentum[s] * self.adx[s].Current.Value) * scale

        if not scaled:
            for k in list(self.Portfolio):
                if k.Value.Invested and k.Key != self.anchor and k.Key != self.hedge_instrument:
                    self.Liquidate(k.Key)
            return

        # -- Final weighting ------------------------------------------------
        total_scaled = sum(scaled.values())
        raw_weights  = {s: v / total_scaled for s, v in scaled.items()}
        capped       = {s: min(self.max_weight, w) for s, w in raw_weights.items()}
        cur_sum      = sum(capped.values())

        final_weights = {}
        if cur_sum > 0:
            for s, w in capped.items():
                final_weights[s] = (w / cur_sum) * target_exposure

        # -- Execution ------------------------------------------------------
        if not self.Securities[self.anchor].Exchange.DateTimeIsOpen(self.Time):
            return

        for pos in self.Portfolio.Values:
            if pos.Invested and pos.Symbol != self.anchor and pos.Symbol != self.hedge_instrument and pos.Symbol not in final_weights:
                self.Liquidate(pos.Symbol)

        targets = [PortfolioTarget(s, w * (0.95 if self.LiveMode else 1.0))
                   for s, w in final_weights.items() if w > 0]
        if targets:
            self.SetHoldings(targets)

        ds = self.Time.strftime("%Y-%m-%d")
        hedge_tag = " [HEDGED]" if self._hedge_active else ""
        self.Debug(f"[S3] REBAL {ds} stress={bottom_frac:.1%} "
                   f"exp={target_exposure:.0%} pos={len(final_weights)}{hedge_tag}")
        if self.LiveMode:
            self.Log(f"[S3] REBAL {ds} stress={bottom_frac:.1%} "
                     f"exp={target_exposure:.0%} pos={len(final_weights)}{hedge_tag} "
                     f"stocks={[s.Value for s in final_weights]}")

    # =========================================================================
    # WARMUP FINISHED
    # =========================================================================

    def OnWarmupFinished(self):
        self.Debug("[INIT] Warmup complete")
        if self.LiveMode: self.Log("[INIT] Warmup complete")

        eq = self.Portfolio.TotalPortfolioValue
        self._hwm        = eq
        self._prev_value = eq

        if self.Portfolio.TotalHoldingsValue != 0:
            excl    = {self.anchor}
            managed = self.symbols | excl

            s3_inv = sum(1 for s in self.symbols
                         if s in self.Portfolio and self.Portfolio[s].Invested)
            if s3_inv >= 5:
                self.allow_universe = True
                msg = f"[INIT] {s3_inv} S3 positions found -- inferring prior BULL mode"
                self.Debug(msg)
                if self.LiveMode: self.Log(msg)

            unknown = [k.Key for k in self.Portfolio
                       if k.Value.Invested and k.Key not in managed]
            for sym in unknown:
                self.symbols.add(sym)
                if sym not in self.ma:
                    self.stretch_max[sym]  = 0.0
                    self.ma[sym]           = self.EMA(sym, self.band_len, Resolution.Daily)
                    self.adx[sym]          = self.ADX(sym, self.adx_period, Resolution.Daily)
                    self.stretch_ema[sym]  = self.EMA(sym, self.band_len, Resolution.Daily)
                    self.close_win[sym]    = RollingWindow[float](self.band_len)
                    self.stretch_win[sym]  = RollingWindow[float](self.hist_len)
                    self.band_hist[sym]    = RollingWindow[int](self.hist_len)
                    if self.LiveMode:
                        try:
                            self.WarmUpIndicator(sym, self.ma[sym],          Resolution.Daily)
                            self.WarmUpIndicator(sym, self.adx[sym],         Resolution.Daily)
                            self.WarmUpIndicator(sym, self.stretch_ema[sym], Resolution.Daily)
                        except: pass
                msg = f"[INIT] Adopted orphan position: {sym.Value}"
                self.Debug(msg)
                if self.LiveMode: self.Log(msg)

        msg = (f"[INIT] Symbols={len(self.symbols)} "
               f"Invested={sum(1 for k in self.Portfolio if k.Value.Invested)}")
        self.Debug(msg)
        if self.LiveMode: self.Log(msg)

    # =========================================================================
    # END OF ALGORITHM
    # =========================================================================

    def OnEndOfAlgorithm(self):
        eq = self.Portfolio.TotalPortfolioValue
        self.Debug(f"[END] Eq={eq:,.2f} Ret={(eq/100_000-1)*100:+.2f}%")
        for t in self._hedge_trades:
            self.Debug(
                f"[HEDGE TRADE] "
                f"{t['entry_date']:%Y-%m-%d}→{t['exit_date']:%Y-%m-%d} "
                f"px:{t['entry_price']:.2f}→{t['exit_price']:.2f} "
                f"ret:{t['return']:+.2%} "
                f"stress:{t['entry_stress']:.2f}→{t['exit_stress']:.2f} "
                f"dd:{t['entry_dd']:.2%}→{t['exit_dd']:.2%}"
            )
from AlgorithmImports import *
import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.preprocessing import StandardScaler


# ─────────────────────────────────────────────────────────────────────────────
# HYBRID STRATEGY — Production v1.1
#
# Macro engine  : VolatilityHarvest (SPY / GLD / VIX regime + RF ML overlay)
# Equity sleeve : Fundamental value + ROC63 momentum
# Design        : Cash in VolatilityHarvest replaced by equity sleeve
#
# Sleeve is active in calm / recovery regimes (Rules 2, 3, 5).
# Sleeve is liquidated in stress regimes (Rules 1, 4, 6).
#
# Prior backtest (2016–2026, v1.0 with look-ahead bias)
# ─────────────────────────────────────────────────────
#   CAGR 27.5%   Sharpe 1.34   Max DD 15.6%   PSR 95.3%   Expectancy 0.79
#
#   ⚠  These numbers include same-day VIX close in CheckSignal decisions.
#      RE-BACKTEST REQUIRED with v1.1. Expect material degradation —
#      especially in Rule 1 ("VIX spike + 5d down → buy the dip"), which
#      was making the call at 10 AM using a close that didn't exist yet.
#
# v1.1 (current) — Look-ahead bias fix
# ────────────────────────────────────
#   CBOE Reader was stamping VIX/VIX3M closes with Time = data_date.
#   QC treats Time as the bar's availability moment, so a 4 PM close
#   was visible to algorithms running at 10 AM the same day.
#   Fix: Time = data_date + 1 day so the close first appears at the
#   following midnight (next trading day), which is honest.
#
# v1.0 live hardening (carried forward)
# ─────────────────────────────────────
#   - _get_closes() unified helper handles both multi-index (backtest) and
#     flat (live) DataFrame formats — fixes the .loc[symbol] crash
#   - CheckSignal and TrainModel wrapped in outer try/except
#   - CheckSignal only updates weight state variables; all SetHoldings
#     calls consolidated into RebalanceSleeve for a single atomic rebalance
#   - RebalanceSleeve logs candidate count, tickers, and weights each month
# ─────────────────────────────────────────────────────────────────────────────


# ── VolatilityHarvest constants ───────────────────────────────────────────────

LABEL_HORIZON  = 21
SAFETY_BUFFER  = 10
TRAIN_VAL_GAP  = 126
MIN_TRAIN_ROWS = 100
ML_THRESHOLD   = 0.65

MIN_VIX_BARS   = 50
MIN_SPY_BARS   = 260
MIN_AUX_BARS   = 60

DIP_DEEP_THRESHOLD    = -0.08
DIP_SHALLOW_SPY_W     = 0.60
DIP_SHALLOW_SPY_W_ML  = 0.75
DIP_DEEP_SPY_W        = 0.85
DIP_DEEP_SPY_W_ML     = 1.00


# ── Algorithm ─────────────────────────────────────────────────────────────────

class HybridVolatilityHarvestFundamental(QCAlgorithm):

    # ── Initialise ────────────────────────────────────────────────────────────

    def Initialize(self):
        self.SetStartDate(2016, 1, 1)
        self.SetCash(100000)
        self.SetBrokerageModel(
            BrokerageName.InteractiveBrokersBrokerage,
            AccountType.Margin,
        )
        self.SetBenchmark("SPY")

        # Macro assets
        self.spy   = self.AddEquity("SPY", Resolution.Daily).Symbol
        self.gld   = self.AddEquity("GLD", Resolution.Daily).Symbol
        self.vix   = self.AddData(CBOE, "VIX",   Resolution.Daily).Symbol
        self.vix3m = self.AddData(CBOE, "VIX3M", Resolution.Daily).Symbol
        self.hyg   = self.AddEquity("HYG", Resolution.Daily).Symbol
        self.lqd   = self.AddEquity("LQD", Resolution.Daily).Symbol
        self.rsp   = self.AddEquity("RSP", Resolution.Daily).Symbol
        self.ief   = self.AddEquity("IEF", Resolution.Daily).Symbol
        self.shy   = self.AddEquity("SHY", Resolution.Daily).Symbol

        # ML model
        self.model = RandomForestClassifier(
            n_estimators=200,
            max_depth=6,
            min_samples_leaf=20,
            random_state=42,
        )
        self.scaler  = StandardScaler()
        self.trained = False

        # Macro weight state — written by CheckSignal, read by RebalanceSleeve
        self.spy_weight         = 0.0
        self.gld_weight         = 0.0
        self.cash_sleeve_weight = 0.0

        # Equity sleeve config
        self.MAX_POSITION_WEIGHT = 0.20
        self.MAX_POSITIONS       = 10
        self.MIN_HISTORY_DAYS    = 5
        self.MOMENTUM_LOOKBACK   = 63
        self.MOMENTUM_MIN_RETURN = 0.0

        self._selected_symbols:  List[Symbol] = []
        self._symbol_added_date: dict         = {}
        self._momentum:          dict         = {}

        self.UniverseSettings.Resolution             = Resolution.Daily
        self.UniverseSettings.DataNormalizationMode  = DataNormalizationMode.Adjusted
        self.UniverseSettings.FillDataBeforeStart    = True
        self.AddUniverse(self.FundamentalSelection)

        # Schedules
        # CheckSignal   — every day, 30 min after open  → updates weight state only
        # TrainModel    — month start, 60 min after open → retrains RF
        # RebalanceSleeve — month start, 90 min after open → single atomic rebalance
        self.Schedule.On(
            self.DateRules.EveryDay(self.spy),
            self.TimeRules.AfterMarketOpen(self.spy, 30),
            self.CheckSignal,
        )
        self.Schedule.On(
            self.DateRules.MonthStart(self.spy),
            self.TimeRules.AfterMarketOpen(self.spy, 60),
            self.TrainModel,
        )
        self.Schedule.On(
            self.DateRules.MonthStart(self.spy),
            self.TimeRules.AfterMarketOpen(self.spy, 90),
            self.RebalanceSleeve,
        )

        self.SetWarmUp(252)

    # ── History helpers ───────────────────────────────────────────────────────

    def _get_closes(self, symbol, n_bars, is_custom=False):
        """
        Fetch close prices as a numpy array.  Returns None on any failure.

        Handles two DataFrame structures seen across QC environments:
          Multi-index (symbol, time) → standard backtest format
          Flat index with 'close' column → some live environments

        is_custom=True fetches CBOE data via date-range History.
        """
        try:
            if is_custom:
                end_dt   = self.Time
                start_dt = end_dt - timedelta(days=n_bars * 2)
                df = self.History(CBOE, symbol, start_dt, end_dt, Resolution.Daily)
            else:
                df = self.History([symbol], n_bars, Resolution.Daily)

            if df is None or df.empty:
                return None

            # Try multi-index access (standard)
            if isinstance(df.index, pd.MultiIndex):
                for key in (symbol, symbol.Value if hasattr(symbol, 'Value') else None):
                    if key is None:
                        continue
                    try:
                        closes = df.xs(key, level=0)['close'].values
                        if len(closes) > 0:
                            return closes
                    except Exception:
                        pass

            # Flat DataFrame fallback
            if 'close' in df.columns:
                closes = df['close'].values
                if len(closes) > 0:
                    return closes

            self.Log(f"_get_closes: no closes extracted for {symbol}")
            return None

        except Exception as e:
            self.Log(f"_get_closes error ({symbol}): {e}")
            return None

    def _get_cboe_closes(self, symbol, days=4000, min_bars=1):
        """Fetch CBOE custom data closes over a date range."""
        try:
            end_dt   = self.Time
            start_dt = end_dt - timedelta(days=days)
            df = self.History(CBOE, symbol, start_dt, end_dt, Resolution.Daily)
            if df is None or df.empty:
                return None
            closes = df['close'].values
            return closes if len(closes) >= min_bars else None
        except Exception as e:
            self.Log(f"_get_cboe_closes error ({symbol}): {e}")
            return None

    # ── Fundamental helpers ───────────────────────────────────────────────────

    def _get_float(self, f, paths: List[str]):
        for p in paths:
            try:
                obj = f
                for part in p.split('.'):
                    obj = getattr(obj, part)
                if isinstance(obj, (float, int)) and np.isfinite(obj):
                    return float(obj)
                if hasattr(obj, 'Value'):
                    val = obj.Value
                    if isinstance(val, (float, int)) and np.isfinite(val):
                        return float(val)
                val = float(obj)
                if np.isfinite(val):
                    return val
            except:
                continue
        return float('nan')

    def _is_finite(self, v) -> bool:
        try:
            return v is not None and np.isfinite(float(v))
        except:
            return False

    # ── Fundamental universe ──────────────────────────────────────────────────

    def FundamentalSelection(self, fundamental: List[Fundamental]) -> List[Symbol]:
        filtered = [
            f for f in fundamental
            if getattr(f, 'HasFundamentalData', False)
            and float(getattr(f, 'Price', 0)) > 5
            and getattr(f, 'DollarVolume', 0) > 10_000_000
        ]
        top1000 = sorted(filtered, key=lambda f: f.DollarVolume, reverse=True)[:1000]

        selected = []
        for f in top1000:
            pe        = self._get_float(f, ["ValuationRatios.PERatio",
                                            "ValuationRatios.PriceEarningsRatio"])
            dte       = self._get_float(f, ["OperationRatios.DebtToEquity",
                                            "OperationRatios.TotalDebtEquityRatio"])
            div_yield = self._get_float(f, ["ValuationRatios.TrailingDividendYield",
                                            "ValuationRatios.ForwardDividendYield"])
            roi       = self._get_float(f, ["OperationRatios.ROIC",
                                            "ProfitabilityRatios.ROIC",
                                            "ProfitabilityRatios.ReturnOnInvestedCapital",
                                            "ProfitabilityRatios.ReturnOnInvestment"])

            if not all(self._is_finite(v) for v in [pe, dte, div_yield, roi]):
                continue
            if pe < 5 or pe > 18:  continue
            if dte >= 1.0:         continue
            if div_yield <= 0.01:  continue
            if roi <= 0.12:        continue

            selected.append((f.Symbol, float(roi)))

        symbols = [x[0] for x in sorted(selected, key=lambda x: x[1], reverse=True)[:20]]
        self._selected_symbols = symbols
        return symbols

    # ── Securities changed ────────────────────────────────────────────────────

    def OnSecuritiesChanged(self, changes: SecurityChanges):
        macro_symbols = {self.spy, self.gld, self.hyg, self.lqd,
                         self.rsp, self.ief, self.shy}

        for sec in changes.RemovedSecurities:
            if sec.Symbol in macro_symbols:
                continue
            self._momentum.pop(sec.Symbol, None)
            self._symbol_added_date.pop(sec.Symbol, None)

        for sec in changes.AddedSecurities:
            if sec.Symbol in macro_symbols:
                continue
            self._symbol_added_date[sec.Symbol] = self.Time
            self._momentum[sec.Symbol] = self.ROC(
                sec.Symbol, self.MOMENTUM_LOOKBACK, Resolution.Daily
            )

    # ── Feature engineering ───────────────────────────────────────────────────

    def GetFeatures(self, vix_c, spy_c,
                    vix3m_closes=None, hyg_closes=None, lqd_closes=None,
                    rsp_closes=None,   ief_closes=None, shy_closes=None):
        if len(vix_c) < MIN_VIX_BARS or len(spy_c) < MIN_SPY_BARS:
            return None
        try:
            cv           = vix_c[-1]
            vix_sma20    = np.mean(vix_c[-20:])
            vix_sma50    = np.mean(vix_c[-50:])
            vix_std      = np.std(vix_c[-20:])
            vix_zscore   = (cv - vix_sma20) / vix_std if vix_std > 0 else 0.0
            vix_pct_rank = float(np.sum(vix_c < cv)) / len(vix_c)

            sc           = spy_c[-1]
            spy_sma50    = np.mean(spy_c[-50:])
            spy_sma200   = np.mean(spy_c[-200:])
            spy_5d       = spy_c[-1] / spy_c[-5]   - 1
            spy_10d      = spy_c[-1] / spy_c[-10]  - 1
            spy_20d      = spy_c[-1] / spy_c[-20]  - 1
            spy_vol      = np.std(np.diff(spy_c[-21:]) / spy_c[-21:-1])
            spy_60d      = spy_c[-1] / spy_c[-60]  - 1
            spy_120d     = spy_c[-1] / spy_c[-120] - 1
            spy_252d     = spy_c[-1] / spy_c[-252] - 1

            if vix3m_closes is not None and len(vix3m_closes) >= 5 and vix3m_closes[-1] > 0:
                vix_tr = cv / vix3m_closes[-1]
                vix_t5 = (cv / vix_c[-5]) - (vix3m_closes[-1] / vix3m_closes[-5])
            else:
                vix_tr = vix_t5 = 0.0

            if (hyg_closes is not None and lqd_closes is not None
                    and len(hyg_closes) >= MIN_AUX_BARS and len(lqd_closes) >= MIN_AUX_BARS
                    and lqd_closes[-1] > 0):
                cr_r  = hyg_closes[-1] / lqd_closes[-1]
                cr_5  = (hyg_closes[-1] / hyg_closes[-5])  - (lqd_closes[-1] / lqd_closes[-5])
                cr_20 = (hyg_closes[-1] / hyg_closes[-20]) - (lqd_closes[-1] / lqd_closes[-20])
            else:
                cr_r = cr_5 = cr_20 = 0.0

            if rsp_closes is not None and len(rsp_closes) >= MIN_AUX_BARS and sc > 0:
                br_r  = rsp_closes[-1] / sc
                br_5  = (rsp_closes[-1] / rsp_closes[-5])  - (spy_c[-1] / spy_c[-5])
                br_20 = (rsp_closes[-1] / rsp_closes[-20]) - (spy_c[-1] / spy_c[-20])
            else:
                br_r = br_5 = br_20 = 0.0

            if (ief_closes is not None and shy_closes is not None
                    and len(ief_closes) >= MIN_AUX_BARS and len(shy_closes) >= MIN_AUX_BARS):
                cu_20 = (ief_closes[-1] / ief_closes[-20]) - (shy_closes[-1] / shy_closes[-20])
                cu_60 = (ief_closes[-1] / ief_closes[-60]) - (shy_closes[-1] / shy_closes[-60])
            else:
                cu_20 = cu_60 = 0.0

            return [
                cv, vix_zscore, vix_pct_rank, cv / vix_sma20, cv / vix_sma50,
                spy_5d, spy_10d, spy_20d, sc / spy_sma50, sc / spy_sma200,
                spy_vol * np.sqrt(252),
                spy_60d, spy_120d, spy_252d,
                vix_tr, vix_t5,
                cr_r, cr_5, cr_20,
                br_r, br_5, br_20,
                cu_20, cu_60,
            ]
        except Exception as e:
            self.Log(f"GetFeatures error: {e}")
            return None

    # ── Model training ────────────────────────────────────────────────────────

    def TrainModel(self):
        if self.IsWarmingUp:
            return
        try:
            self._TrainModelInner()
        except Exception as e:
            self.Log(f"TrainModel unhandled error: {e}")

    def _TrainModelInner(self):
        end_dt   = self.Time
        start_dt = end_dt - timedelta(days=4000)

        vix_c = self._get_cboe_closes(self.vix,  4000, MIN_VIX_BARS)
        spy_c = self._get_closes(self.spy, 4000)
        if vix_c is None or spy_c is None:
            self.Log("TrainModel: missing core history, skipping.")
            return

        self.Log(f"TrainModel | SPY {spy_c[0]:.2f}→{spy_c[-1]:.2f} "
                 f"bars={len(spy_c)} date={end_dt.date()}")

        vix3m_c = self._get_cboe_closes(self.vix3m, 4000, 5)
        hyg_c   = self._get_closes(self.hyg, 4000)
        lqd_c   = self._get_closes(self.lqd, 4000)
        rsp_c   = self._get_closes(self.rsp, 4000)
        ief_c   = self._get_closes(self.ief, 4000)
        shy_c   = self._get_closes(self.shy, 4000)

        label_cutoff = len(spy_c) - LABEL_HORIZON - SAFETY_BUFFER
        if label_cutoff < MIN_SPY_BARS + MIN_TRAIN_ROWS:
            self.Log("TrainModel: insufficient data.")
            return

        train_end = label_cutoff - TRAIN_VAL_GAP
        if train_end - MIN_SPY_BARS < MIN_TRAIN_ROWS:
            self.Log("TrainModel: window too small.")
            return

        indices  = list(range(MIN_SPY_BARS, label_cutoff))
        fwd_rets = [spy_c[i + LABEL_HORIZON] / spy_c[i] - 1 for i in indices]
        median_r = np.median(fwd_rets)

        X_all, y_all = [], []
        for idx, i in enumerate(indices):
            f = self.GetFeatures(
                vix_c[:i], spy_c[:i],
                vix3m_closes = vix3m_c[:i] if vix3m_c is not None else None,
                hyg_closes   = hyg_c[:i]   if hyg_c   is not None else None,
                lqd_closes   = lqd_c[:i]   if lqd_c   is not None else None,
                rsp_closes   = rsp_c[:i]   if rsp_c   is not None else None,
                ief_closes   = ief_c[:i]   if ief_c   is not None else None,
                shy_closes   = shy_c[:i]   if shy_c   is not None else None,
            )
            if f is not None:
                X_all.append(f)
                y_all.append(1 if fwd_rets[idx] > median_r else 0)

        if len(X_all) < MIN_TRAIN_ROWS + 20:
            self.Log(f"TrainModel: too few samples ({len(X_all)}).")
            return

        X_all = np.array(X_all)
        y_all = np.array(y_all)

        class_1_rate = float(np.mean(y_all))
        if class_1_rate > 0.95 or class_1_rate < 0.05:
            self.Log(f"TrainModel: degenerate labels ({class_1_rate:.3f}), disabling ML.")
            self.trained = False
            return

        split       = train_end - MIN_SPY_BARS
        X_tr, y_tr  = X_all[:split], y_all[:split]
        X_va, y_va  = X_all[split:], y_all[split:]

        if len(X_tr) < MIN_TRAIN_ROWS:
            self.Log("TrainModel: not enough training rows.")
            return

        self.scaler.fit(X_tr)
        self.model.fit(self.scaler.transform(X_tr), y_tr)
        self.trained = True

        if len(X_va) > 0:
            val_acc  = self.model.score(self.scaler.transform(X_va), y_va)
            baseline = float(np.mean(y_va))
            self.Log(f"TrainModel | train={len(X_tr)} val={len(X_va)} "
                     f"val_acc={val_acc:.3f} baseline={baseline:.3f} "
                     f"edge={val_acc - baseline:+.3f} "
                     f"label_rate={class_1_rate:.3f} median_fwd={median_r:.4f}")

        names = [
            "vix_level","vix_zscore","vix_pct_rank","vix_vs_sma20","vix_vs_sma50",
            "spy_5d","spy_10d","spy_20d","spy_vs_sma50","spy_vs_sma200","spy_vol",
            "spy_60d","spy_120d","spy_252d",
            "vix_term_ratio","vix_term_5d_chg",
            "credit_ratio","credit_5d_chg","credit_20d_chg",
            "breadth_ratio","breadth_5d","breadth_20d",
            "curve_slope_20d","curve_slope_60d",
        ]
        top = sorted(zip(names, self.model.feature_importances_),
                     key=lambda x: -x[1])[:7]
        self.Log("Features: " + " | ".join(f"{n}={v:.3f}" for n, v in top))

    # ── Macro signal — updates weight state only, no SetHoldings ─────────────

    def CheckSignal(self):
        if self.IsWarmingUp:
            return
        try:
            self._CheckSignalInner()
        except Exception as e:
            self.Log(f"CheckSignal unhandled error: {e}")

    def _CheckSignalInner(self):
        # Core history via hardened helper
        spy_c = self._get_closes(self.spy, 270)
        vix_c = self._get_closes(self.vix, 100, is_custom=True)

        if spy_c is None or vix_c is None:
            self.Log("CheckSignal: missing core history, skipping.")
            return
        if len(vix_c) < MIN_VIX_BARS or len(spy_c) < MIN_SPY_BARS:
            self.Log(f"CheckSignal: insufficient bars (vix={len(vix_c)} spy={len(spy_c)}), skipping.")
            return

        # Auxiliary series
        vix3m_c = self._get_closes(self.vix3m, 10,          is_custom=True)
        hyg_c   = self._get_closes(self.hyg,   MIN_AUX_BARS)
        lqd_c   = self._get_closes(self.lqd,   MIN_AUX_BARS)
        rsp_c   = self._get_closes(self.rsp,   MIN_AUX_BARS)
        ief_c   = self._get_closes(self.ief,   MIN_AUX_BARS)
        shy_c   = self._get_closes(self.shy,   MIN_AUX_BARS)

        # Regime indicators
        current_vix  = vix_c[-1]
        vix_sma      = np.mean(vix_c[-20:])
        vix_level_80 = np.percentile(vix_c, 80)
        spy_current  = spy_c[-1]
        spy_sma50    = np.mean(spy_c[-50:])
        spy_sma200   = np.mean(spy_c[-200:])
        spy_5d_ret   = spy_c[-1] / spy_c[-5]  - 1
        spy_10d_ret  = spy_c[-1] / spy_c[-10] - 1

        # ML signal
        ml_bullish = False
        if self.trained:
            feats = self.GetFeatures(
                vix_c, spy_c,
                vix3m_closes=vix3m_c,
                hyg_closes=hyg_c, lqd_closes=lqd_c,
                rsp_closes=rsp_c, ief_closes=ief_c, shy_closes=shy_c,
            )
            if feats is not None:
                try:
                    proba      = self.model.predict_proba(
                        self.scaler.transform([feats]))[0]
                    prob       = proba[1] if len(proba) == 2 else 0.5
                    ml_bullish = prob > ML_THRESHOLD
                except Exception as e:
                    self.Log(f"ML predict error: {e}")

        # ── Determine regime weights ──────────────────────────────────────────
        spy_w = gld_w = sleeve_w = 0.0

        # Rule 1 — VIX spike + oversold → full dip entry, no sleeve
        if current_vix > vix_level_80 and spy_5d_ret < -0.03:
            if spy_10d_ret <= DIP_DEEP_THRESHOLD:
                spy_w = DIP_DEEP_SPY_W_ML    if ml_bullish else DIP_DEEP_SPY_W
            else:
                spy_w = DIP_SHALLOW_SPY_W_ML if ml_bullish else DIP_SHALLOW_SPY_W
            gld_w    = max(0.0, 1.0 - spy_w)
            sleeve_w = 0.0
            self.Log(f"R1-dip | vix={current_vix:.1f} 5d={spy_5d_ret:.3f} "
                     f"10d={spy_10d_ret:.3f} spy={spy_w:.2f} ml={ml_bullish}")

        # Rule 2 — VIX very low + SPY extended → reduce risk, sleeve active
        elif current_vix < 13 and spy_current > spy_sma50 * 1.05:
            spy_w    = 0.40
            gld_w    = 0.20
            sleeve_w = 0.40

        # Rule 3 — VIX elevated but falling → recovery + sleeve
        elif 20 < current_vix < vix_sma:
            spy_w    = 0.85 if ml_bullish else 0.70
            gld_w    = 0.10
            sleeve_w = max(0.0, 1.0 - spy_w - gld_w)

        # Rule 4 — VIX rising sharply → defensive, no sleeve
        elif current_vix > vix_sma * 1.2:
            spy_w    = 0.30
            gld_w    = 0.20
            sleeve_w = 0.0

        # Rule 5 — Above 200MA → trend following + sleeve
        elif spy_current > spy_sma200:
            spy_w    = 0.70 if ml_bullish else 0.60
            gld_w    = 0.15
            sleeve_w = max(0.0, 1.0 - spy_w - gld_w)

        # Rule 6 — Below 200MA → defensive, no sleeve
        else:
            spy_w    = 0.30
            gld_w    = 0.20
            sleeve_w = 0.0

        # Store state for RebalanceSleeve (runs 60 min later on month-start)
        self.spy_weight         = spy_w
        self.gld_weight         = gld_w
        self.cash_sleeve_weight = max(0.0, min(1.0, sleeve_w))

        # ── Daily execution ───────────────────────────────────────────────────
        # On non-month-start days, RebalanceSleeve does not fire.
        # Apply macro weights directly and liquidate sleeve if it's off.
        # On month-start days, RebalanceSleeve fires 60 min later and handles
        # everything atomically — we still apply macro here immediately.
        self.SetHoldings(self.spy, self.spy_weight)
        self.SetHoldings(self.gld, self.gld_weight)

        if self.cash_sleeve_weight == 0.0:
            self._liquidate_sleeve()

    # ── Sleeve helpers ────────────────────────────────────────────────────────

    def _liquidate_sleeve(self):
        """Close all sleeve equity positions."""
        for sym in list(self._selected_symbols):
            if sym in self.Securities and self.Portfolio[sym].Invested:
                self.Liquidate(sym)

    def RebalanceSleeve(self):
        """
        Monthly atomic rebalance. Fires at MonthStart + 90 min, after
        CheckSignal (+ 30 min) and TrainModel (+ 60 min) have both run.
        Consolidates all SetHoldings into a single PortfolioTarget list
        so SPY, GLD, and sleeve weights are set atomically.
        """
        if self.IsWarmingUp:
            return
        try:
            self._RebalanceSleeveInner()
        except Exception as e:
            self.Log(f"RebalanceSleeve unhandled error: {e}")

    def _RebalanceSleeveInner(self):
        if self.cash_sleeve_weight <= 0 or not self._selected_symbols:
            self._liquidate_sleeve()
            # Ensure macro weights are still applied
            self.SetHoldings(self.spy, self.spy_weight)
            self.SetHoldings(self.gld, self.gld_weight)
            self.Log(f"RebalanceSleeve | sleeve OFF | "
                     f"spy={self.spy_weight:.2f} gld={self.gld_weight:.2f}")
            return

        now        = self.Time
        candidates = []

        for sym in self._selected_symbols:
            if sym not in self.Securities:
                continue
            sec = self.Securities[sym]
            if not sec.HasData or sec.Price <= 0 or not sec.IsTradable:
                continue

            added = self._symbol_added_date.get(sym)
            if added is not None and (now - added).days < self.MIN_HISTORY_DAYS:
                continue

            roc = self._momentum.get(sym)
            if roc is None or not roc.IsReady:
                continue
            if float(roc.Current.Value) < self.MOMENTUM_MIN_RETURN:
                continue

            candidates.append((sym, float(roc.Current.Value)))

        # Sort by momentum descending, cap at MAX_POSITIONS
        candidates = sorted(candidates, key=lambda x: -x[1])[:self.MAX_POSITIONS]

        if not candidates:
            self._liquidate_sleeve()
            self.SetHoldings(self.spy, self.spy_weight)
            self.SetHoldings(self.gld, self.gld_weight)
            self.Log("RebalanceSleeve | no valid candidates, sleeve liquidated.")
            return

        n        = len(candidates)
        base_w   = min(1.0 / n, self.MAX_POSITION_WEIGHT)
        scaled_w = base_w * self.cash_sleeve_weight

        # Log sleeve composition for monitoring
        tickers = [sym.Value for sym, _ in candidates]
        self.Log(f"RebalanceSleeve | n={n} sleeve_w={self.cash_sleeve_weight:.2f} "
                 f"per_stock={scaled_w:.3f} | {' '.join(tickers[:10])}")

        # Build single atomic target list
        targets = [PortfolioTarget(sym, scaled_w) for sym, _ in candidates]
        targets.append(PortfolioTarget(self.spy, self.spy_weight))
        targets.append(PortfolioTarget(self.gld, self.gld_weight))

        self.SetHoldings(targets)

    def OnData(self, data: Slice):
        pass


# ─────────────────────────────────────────────────────────────────────────────
# CBOE custom data reader
# ─────────────────────────────────────────────────────────────────────────────

class CBOE(PythonData):

    def GetSource(self, config, date, isLive):
        urls = {
            "VIX":   "https://cdn.cboe.com/api/global/us_indices/daily_prices/VIX_History.csv",
            "VIX3M": "https://cdn.cboe.com/api/global/us_indices/daily_prices/VIX3M_History.csv",
        }
        url = urls.get(config.Symbol.Value, urls["VIX"])
        return SubscriptionDataSource(url, SubscriptionTransportMedium.RemoteFile)

    def Reader(self, config, line, date, isLive):
        if not (line.strip() and line[0].isdigit()):
            return None
        cols = line.split(',')
        try:
            obj        = CBOE()
            obj.Symbol = config.Symbol

            # ─── LOOK-AHEAD BIAS FIX (v1.1) ──────────────────────────────────
            # CBOE publishes VIX/VIX3M close prices for date D at ~4:15 PM ET
            # that same day. QuantConnect treats `obj.Time` as the bar's
            # availability moment — set it to D and the 4 PM close is visible
            # to any algorithm running on day D, including CheckSignal at 10
            # AM. That's classic look-ahead: the strategy makes a "buy the
            # dip" call at 10 AM using a value that won't exist for six hours.
            #
            # Shifting Time to D + 1 day forces the close to first appear at
            # midnight of the next calendar day. A scheduled function on day
            # D+1 then sees day D's close, which is honest — and conservative
            # by ~18 hours, but daily resolution can't do better than that.
            data_date = datetime.strptime(cols[0], "%m/%d/%Y")
            obj.Time  = data_date + timedelta(days=1)
            # ─────────────────────────────────────────────────────────────────

            obj.Value    = float(cols[4])
            obj["close"] = float(cols[4])
            obj["open"]  = float(cols[1])
            obj["high"]  = float(cols[2])
            obj["low"]   = float(cols[3])
            return obj
        except:
            return None
from AlgorithmImports import *


class FundamentalValueSentiment30DayRebalanceAlgorithm(QCAlgorithm):
    """
    Quality-Value + NLP Sentiment + SPY 200MA Regime Filter
    ─────────────────────────────────────────────────────────
    Universe : P/E 5-18, ROI > 12%, D/E < 1.0, div yield > 1%
    Ranking  : ROI sort → FinBERT/keyword sentiment rerank
    Filters  : momentum ROC63 > 0, sentiment floor EWMA >= 0
    Risk     : 15% stop loss, 50% take profit, 10-day min hold
    Regime   : scale to 25% when SPY < 200-day SMA
    DD Guard : scale to 25% when monthly drawdown > 10%
    Gold     : 15% GLD when portfolio DD > 5% AND GLD ROC63 > 5%

    10Y Backtest (2016-2026): CAR 21.1% | Sharpe 0.805 | DD 21.7%
                              Alpha 0.084 | Beta 0.538 | PSR 35.4%
    Live OOS                : 3M 16.2%  | 1Y Sharpe 2.39 | 5Y CAGR 22.6%

    Brokerage: Interactive Brokers Margin
    Sentiment: FinBERT (live) / keyword fallback (backtest)
    Universe refresh: monthly | Rebalance: first trading day of month
    """

    # ── Sentiment ──────────────────────────────────────────────────────────
    MIN_NEWS_COUNT  = 3      # min articles for "trusted" sentiment classification
    SENTIMENT_ALPHA = 0.2    # EWMA alpha — new article weight
    DECAY_FACTOR    = 1.0 - SENTIMENT_ALPHA   # daily score decay (0.80)

    # ── Risk ───────────────────────────────────────────────────────────────
    TAKE_PROFIT   = 0.50   # exit at +50% from entry
    STOP_LOSS     = 0.15   # exit at -15% from entry
    MIN_HOLD_DAYS = 10     # grace period before stop loss is checked

    # ── Drawdown Guard ─────────────────────────────────────────────────────
    DD_GUARD_THRESHOLD = 0.10   # monthly DD threshold to fire circuit breaker
    DD_GUARD_SCALE     = 0.25   # scale all positions to this weight on fire

    # ── Portfolio Construction ─────────────────────────────────────────────
    SENTIMENT_ENTRY_FLOOR = 0.0    # min EWMA sentiment for trusted symbols
    MAX_POSITION_WEIGHT   = 0.20   # max single position weight
    MAX_POSITIONS         = 10     # max simultaneous equity positions
    MIN_HISTORY_DAYS      = 5      # min days in universe before rebalance-eligible

    # ── Momentum ───────────────────────────────────────────────────────────
    MOMENTUM_LOOKBACK   = 63    # ROC lookback in trading days (~3 months)
    MOMENTUM_MIN_RETURN = 0.0   # min ROC63 to pass momentum filter

    # ── Gold ───────────────────────────────────────────────────────────────
    GOLD_MAX_WEIGHT   = 0.15   # max GLD allocation when conditions met
    GOLD_MOMENTUM_MIN = 0.05   # min GLD ROC63 to allocate gold

    # ── Queue / Cache ──────────────────────────────────────────────────────
    QUEUE_MAX_SIZE  = 500    # max queued news items before oldest dropped
    SCORE_CACHE_MAX = 2000   # max cached sentiment scores (deduplication)

    # ── Warmup ─────────────────────────────────────────────────────────────
    PREWARM_MAX_ARTICLES_PER_SYMBOL = 5      # cap per symbol to prevent OOM
    PREWARM_KEYWORD_ONLY            = False  # True = skip FinBERT in pre-warm

    # ══════════════════════════════════════════════════════════════════════════
    # KEYWORD SENTIMENT DICTIONARIES
    # Fallback scorer when FinBERT unavailable (backtest mode).
    # Tiers: STRONG=1.5 | NORMAL=1.0 | WEAK=0.5 | AMBIGUOUS=-0.3
    # Negation window: 3 tokens. Macro headlines (>=3 hits) → neutral 0.0.
    # Final score normalised by total weight, clamped to [-1, +1].
    # ══════════════════════════════════════════════════════════════════════════

    _KW_STRONG_POS: frozenset = frozenset({
        "beat", "beats", "beating", "blowout", "smashed", "crushed", "topped",
        "exceeded", "surpassed", "outperformed", "record", "record-breaking",
        "all-time-high", "explosive", "blockbuster", "landmark", "milestone",
        "raised", "raises", "raise", "lifted", "increased", "boosted", "upped",
        "reiterated", "acquisition", "acquired", "merger", "buyout", "takeover",
        "deal", "partnership", "collaboration", "alliance", "joint-venture",
        "dividend", "dividends", "buyback", "repurchase", "special-dividend",
        "distribution", "upgrade", "upgraded", "upgrades", "overweight",
        "outperform", "strong-buy", "initiates", "approved", "approval",
        "launched", "breakthrough", "patent", "clearance", "fda-approval",
        "authorized",
    })

    _KW_NORMAL_POS: frozenset = frozenset({
        "profit", "profits", "profitable", "earnings", "revenue", "growth",
        "grew", "grow", "growing", "gains", "gain", "positive", "strong",
        "solid", "robust", "healthy", "improved", "improvement", "improving",
        "momentum", "expansion", "expanding", "efficient", "efficiency",
        "streamlined", "optimized", "margin", "margins", "cash-flow",
        "cashflow", "synergies", "synergy", "market-share", "competitive",
        "dominance", "leading", "leader", "innovative", "innovation",
        "differentiated", "guidance", "outlook", "forecast", "confident",
        "confidence", "optimistic", "opportunity", "opportunities", "debt-free",
        "investment-grade", "upgraded-credit", "liquidity", "well-capitalized",
        "win", "wins", "winning", "success", "successful", "deliver",
        "delivered", "delivering", "buy", "bullish", "bull",
    })

    _KW_WEAK_POS: frozenset = frozenset({
        "stable", "steady", "maintained", "maintains", "in-line", "inline",
        "met", "meets", "meeting", "matched", "matching", "resilient",
        "recovery", "recovering", "stabilizing", "stabilized", "bottomed",
        "rebound", "rebounding", "bouncing", "normalizing", "normalize",
        "gradual", "gradually", "progress", "progressing",
    })

    _KW_STRONG_NEG: frozenset = frozenset({
        "miss", "misses", "missed", "missed-estimates", "shortfall",
        "disappointed", "disappoints", "disappointing", "dismal",
        "lawsuit", "sued", "litigation", "indicted", "fraud", "scandal",
        "investigation", "probe", "subpoena", "regulatory-action", "fine",
        "fined", "penalty", "penalties", "violation", "violations",
        "criminal", "charges", "charged", "downgrade", "downgraded",
        "downgrades", "underweight", "underperform", "sell", "strong-sell",
        "avoid", "bankruptcy", "bankrupt", "insolvent", "default", "defaulted",
        "restructuring", "chapter-11", "liquidation", "seized", "receivership",
        "collapse", "collapsed", "imploded", "cut", "cuts", "cutting",
        "slashed", "slashing", "slashed-guidance", "reduced", "reduces",
        "lowered", "withdrew", "withdrawn", "suspended", "suspends",
        "suspending", "layoffs", "layoff", "fired", "termination",
        "terminated", "mass-layoff", "job-cuts", "redundancies",
    })

    _KW_NORMAL_NEG: frozenset = frozenset({
        "loss", "losses", "losing", "deficit", "write-down", "writedown",
        "write-off", "writeoff", "impairment", "charge", "charges",
        "negative", "weak", "weakness", "softness", "soft", "sluggish",
        "slowdown", "slowing", "declined", "declines", "declining",
        "decreased", "decrease", "fell", "fall", "falls", "falling",
        "down", "drop", "drops", "dropped", "lower", "lowered",
        "warned", "warns", "warning", "cautious", "caution", "headwinds",
        "headwind", "pressure", "pressured", "pressures", "challenged",
        "challenges", "difficult", "difficulties", "lost", "losing",
        "market-share-loss", "competition", "competitive-pressure",
        "disrupted", "disruption", "obsolete", "dilution", "diluted",
        "debt", "leverage", "overleveraged", "downgraded-credit", "junk",
        "high-yield-risk", "bear", "bearish",
    })

    _KW_WEAK_NEG: frozenset = frozenset({
        "below", "missed-slightly", "slightly-below", "modestly-below",
        "modest-decline", "slight-decline", "marginal-decline", "uncertainty",
        "uncertain", "unclear", "remains-unclear", "mixed", "uneven",
        "inconsistent", "volatile", "volatility", "delayed", "delay",
        "delays", "postponed", "postponement", "slower", "slowed", "muted",
        "subdued", "tepid", "lackluster",
    })

    _KW_AMBIGUOUS: frozenset = frozenset({
        "volatile", "volatility", "cautious", "caution", "mixed", "uncertain",
        "uncertainty", "unclear", "challenging", "complex", "complicated",
        "evolving", "fluid", "dynamic", "transitioning", "transition",
        "restructure", "restructuring", "transforming", "transformation",
        "pivoting", "pivot",
    })

    _KW_NEGATIONS: frozenset = frozenset({
        "not", "no", "never", "neither", "nor", "without", "lack", "lacking",
        "lacks", "failed", "fails", "unable", "unlikely", "didn't", "doesn't",
        "don't", "won't", "wasn't", "weren't", "isn't", "aren't", "hasn't",
        "haven't", "couldn't", "wouldn't", "shouldn't", "cannot", "cant",
    })

    _KW_MACRO_EXCLUDE: frozenset = frozenset({
        "fed", "federal-reserve", "fomc", "interest-rate", "interest-rates",
        "inflation", "cpi", "ppi", "gdp", "unemployment", "jobs-report",
        "nonfarm", "payrolls", "treasury", "yield-curve", "quantitative",
        "tightening", "tapering", "rate-hike", "rate-cut", "basis-points",
        "recession", "economic", "economy", "macro", "geopolitical",
        "tariff", "tariffs", "trade-war", "sanctions", "opec",
    })

    # ══════════════════════════════════════════════════════════════════════════
    # INITIALIZE
    # ══════════════════════════════════════════════════════════════════════════

    def initialize(self) -> None:
        if not self.live_mode:
            self.set_start_date(2016, 1, 1)
            self.set_end_date(2026, 1, 1)
        else:
            self.set_start_date(self.end_date - timedelta(5 * 365))

        self.set_benchmark("SPY")
        self.set_cash(100_000)

        self.set_brokerage_model(
            BrokerageName.INTERACTIVE_BROKERS_BROKERAGE,
            AccountType.MARGIN
        )

        self.universe_settings.resolution = Resolution.DAILY
        self.universe_settings.data_normalization_mode = DataNormalizationMode.ADJUSTED
        self.universe_settings.fill_data_before_start = True

        self._spy = self.add_equity("SPY", Resolution.MINUTE).symbol

        self.set_security_initializer(
            lambda s: s.set_fill_model(ImmediateFillModel())
        )

        self.universe_settings.schedule.on(
            self.date_rules.month_start(self._spy)
        )

        self._gld = self.add_equity("GLD", Resolution.DAILY).symbol
        self._gld_momentum = self.ROC(self._gld, self.MOMENTUM_LOOKBACK, Resolution.DAILY)

        self._spy_sma200 = self.SMA(self._spy, 200, Resolution.DAILY)
        self._regime_filter_active = False

        self._selected_symbols: List[Symbol] = []
        self._coarse_count = 0
        self._fine_count   = 0

        self._entry_price_by_symbol: dict = {}
        self._position_entry_date: dict   = {}

        self._monthly_peak_value = 0.0
        self._dd_guard_active    = False

        self._symbol_added_date: dict = {}
        self._momentum: dict          = {}

        self._news_symbol_by_underlying: dict = {}
        self._underlying_by_news_symbol: dict = {}

        self._sentiment_ewma_by_symbol: dict = {}
        self._sentiment_hit_count: dict      = {}
        self._sentiment_alpha = self.SENTIMENT_ALPHA

        self._pending_liquidations = set()

        self._news_queue: list  = []
        self._score_cache: dict = {}

        self._use_local_finbert = self.live_mode
        self._finbert           = {}
        self._finbert_ready     = False
        self._finbert_max_chars = 1500

        self.set_warm_up(timedelta(days=200 + 30))
        self._initialize_local_finbert()
        self.add_universe(self._fundamental_selection)

        self.schedule.on(
            self.date_rules.every_day(self._spy),
            self.time_rules.after_market_open(self._spy, 10),
            self._decay_sentiment,
        )
        self.schedule.on(
            self.date_rules.every_day(self._spy),
            self.time_rules.every(timedelta(minutes=30)),
            self._process_news_queue,
        )
        self.schedule.on(
            self.date_rules.month_start(self._spy),
            self.time_rules.after_market_open(self._spy, 30),
            self._rebalance_if_due,
        )
        self.schedule.on(
            self.date_rules.every_day(self._spy),
            self.time_rules.after_market_open(self._spy, 45),
            self._daily_risk_check,
        )
        self.schedule.on(
            self.date_rules.every_day(self._spy),
            self.time_rules.after_market_open(self._spy, 60),
            self._portfolio_drawdown_guard,
        )
        self.schedule.on(
            self.date_rules.every_day(self._spy),
            self.time_rules.after_market_open(self._spy, 75),
            self._regime_filter_check,
        )

    # ══════════════════════════════════════════════════════════════════════════
    # UNIVERSE SELECTION
    # ══════════════════════════════════════════════════════════════════════════

    def _fundamental_selection(self, fundamental: List[Fundamental]) -> List[Symbol]:
        """
        Monthly universe via Morningstar fundamentals.
        Coarse: price > $5, volume > $10M → top 1000 by dollar volume.
        Fine: P/E 5-18, D/E < 1.0, div yield > 1%, ROI > 12%.
        Returns top 20 sorted by ROI descending.
        """
        filtered = [
            f for f in fundamental
            if f.has_fundamental_data
            and f.price is not None
            and float(f.price) > 5
            and f.dollar_volume > 10_000_000
        ]
        top1000 = sorted(filtered, key=lambda f: f.dollar_volume, reverse=True)[:1000]
        self._coarse_count = len(top1000)

        selected = []
        for f in top1000:
            pe = self._get_float(f, [
                "valuation_ratios.pe_ratio",
                "valuation_ratios.peratio",
                "valuation_ratios.price_earnings_ratio",
            ])
            dte = self._get_float(f, [
                "operation_ratios.total_debt_equity_ratio",
                "operation_ratios.debt_to_equity",
                "operation_ratios.debttoequity",
            ])
            div_yield = self._get_float(f, [
                "valuation_ratios.trailing_dividend_yield",
                "valuation_ratios.dividend_yield",
                "valuation_ratios.dividendyield",
            ])
            roi = self._get_float(f, [
                "operation_ratios.roi",
                "operation_ratios.return_on_investment",
                "operation_ratios.returnoninvesment",
                "profitability_ratios.roi",
                "profitability_ratios.return_on_investment",
                "profitability_ratios.return_on_invested_capital",
                "operation_ratios.roic",
                "profitability_ratios.roic",
            ])

            if not all(self._is_finite_number(v) for v in [pe, dte, div_yield, roi]):
                continue
            if pe < 5 or pe > 18:  continue
            if dte >= 1.0:         continue
            if div_yield <= 0.01:  continue
            if roi <= 0.12:        continue
            selected.append((f.symbol, float(roi)))

        selected_sorted = sorted(selected, key=lambda x: x[1], reverse=True)
        symbols = [x[0] for x in selected_sorted[:20]]
        self._fine_count = len(selected_sorted)

        if set(symbols) != set(self._selected_symbols):
            self._selected_symbols = symbols

        return symbols

    # ══════════════════════════════════════════════════════════════════════════
    # SECURITIES CHANGED
    # ══════════════════════════════════════════════════════════════════════════

    def on_securities_changed(self, changes: SecurityChanges) -> None:
        """
        Removal: liquidate (or queue if warming up), clean all state.
        Addition: record entry date, create ROC indicator, subscribe TiingoNews.
        SPY and GLD are permanent — never removed or re-initialised here.
        """
        for security in changes.removed_securities:
            symbol = security.symbol
            if symbol in (self._gld, self._spy):
                continue
            if self.is_warming_up:
                self._pending_liquidations.add(symbol)
                self._entry_price_by_symbol.pop(symbol, None)
                self._sentiment_ewma_by_symbol.pop(symbol, None)
                self._sentiment_hit_count.pop(symbol, None)
                self._momentum.pop(symbol, None)
                self._symbol_added_date.pop(symbol, None)
                continue
            if self.portfolio[symbol].invested:
                self.liquidate(symbol)
            self._entry_price_by_symbol.pop(symbol, None)
            self._sentiment_ewma_by_symbol.pop(symbol, None)
            self._sentiment_hit_count.pop(symbol, None)
            self._momentum.pop(symbol, None)
            self._symbol_added_date.pop(symbol, None)
            self._remove_tiingo_news_subscription(symbol)

        for security in changes.added_securities:
            symbol = security.symbol
            if symbol in (self._gld, self._spy):
                continue
            if symbol not in self._symbol_added_date:
                self._symbol_added_date[symbol] = self.time
            if symbol not in self._momentum:
                self._momentum[symbol] = self.ROC(
                    symbol, self.MOMENTUM_LOOKBACK, Resolution.DAILY
                )
            self._ensure_tiingo_news_subscription(symbol)

    # ══════════════════════════════════════════════════════════════════════════
    # ON DATA
    # ══════════════════════════════════════════════════════════════════════════

    def on_data(self, slice: Slice) -> None:
        """Queue TiingoNews items for async scoring. Never scores inline."""
        if slice is None:
            return
        try:
            news_by_symbol = slice.get(TiingoNews)
        except Exception:
            news_by_symbol = None
        if news_by_symbol is None:
            return

        for kvp in news_by_symbol:
            try:
                news_symbol = kvp.key
                item        = kvp.value
            except Exception:
                continue
            if news_symbol is None or item is None:
                continue
            if self._underlying_by_news_symbol.get(news_symbol) is None:
                continue
            if len(self._news_queue) >= self.QUEUE_MAX_SIZE:
                self._news_queue.pop(0)
            self._news_queue.append((news_symbol, item))

    # ══════════════════════════════════════════════════════════════════════════
    # NEWS QUEUE PROCESSOR
    # ══════════════════════════════════════════════════════════════════════════

    def _process_news_queue(self) -> None:
        """
        Score queued TiingoNews every 30 min via scheduler.
        Priority: cache hit → FinBERT → keyword fallback.
        Skipped during warmup — on_warmup_finished() handles pre-warm.
        """
        if self.is_warming_up:
            self._news_queue.clear()
            return
        if not self._news_queue:
            return

        snapshot         = list(self._news_queue)
        self._news_queue = []

        scored = 0
        for news_symbol, item in snapshot:
            underlying = self._underlying_by_news_symbol.get(news_symbol)
            if underlying is None:
                continue
            text = self._extract_text(item)
            if not text:
                continue
            text_hash = hash(text)
            if text_hash in self._score_cache:
                score = self._score_cache[text_hash]
            else:
                score = self._finbert_sentiment_score(text)
                if score is None:
                    score = self._compute_naive_text_sentiment(item)
                if len(self._score_cache) >= self.SCORE_CACHE_MAX:
                    try:
                        self._score_cache.pop(next(iter(self._score_cache)))
                    except Exception:
                        pass
                self._score_cache[text_hash] = score
            if score is not None and self._is_finite_number(score):
                self._update_sentiment(underlying, float(score))
                scored += 1

        if scored > 0 and self.live_mode:
            self.debug(f"Queue processed: {len(snapshot)} items, {scored} scored")

    # ══════════════════════════════════════════════════════════════════════════
    # TEXT EXTRACTION
    # ══════════════════════════════════════════════════════════════════════════

    def _extract_text(self, news_item) -> str:
        """Concatenate all text fields from TiingoNews item."""
        parts = []
        for attr in ["title", "Title", "headline", "Headline",
                     "description", "Description", "summary", "Summary"]:
            if hasattr(news_item, attr):
                try:
                    val = getattr(news_item, attr)
                    if val:
                        parts.append(str(val).strip())
                except Exception:
                    continue
        return " ".join(parts).strip()

    # ══════════════════════════════════════════════════════════════════════════
    # ORDER EVENTS
    # ══════════════════════════════════════════════════════════════════════════

    def on_order_event(self, order_event: OrderEvent) -> None:
        """Record entry price/date on first fill; clear records on close."""
        if order_event is None or order_event.status != OrderStatus.FILLED:
            return
        symbol = order_event.symbol
        if symbol is None or not self.securities.contains_key(symbol):
            return
        holding = self.portfolio[symbol]
        if holding.invested and symbol not in self._entry_price_by_symbol:
            fill_price = float(order_event.fill_price)
            if self._is_finite_number(fill_price) and fill_price > 0:
                self._entry_price_by_symbol[symbol] = fill_price
                self._position_entry_date[symbol]   = self.time
        if not holding.invested:
            self._entry_price_by_symbol.pop(symbol, None)
            self._position_entry_date.pop(symbol, None)

    # ══════════════════════════════════════════════════════════════════════════
    # SCHEDULED JOBS
    # ══════════════════════════════════════════════════════════════════════════

    def _spy_below_200ma(self) -> bool:
        """True if SPY price < 200-day SMA. False if indicator not ready."""
        if not self._spy_sma200.is_ready:
            return False
        return float(self.securities[self._spy].price) < float(self._spy_sma200.current.value)

    def _gold_target_weight(self) -> float:
        """
        Returns GOLD_MAX_WEIGHT only when BOTH conditions met:
          1. Portfolio drawdown from monthly peak > 5%
          2. GLD ROC63 > GOLD_MOMENTUM_MIN (5%)
        Returns 0.0 otherwise — avoids persistent bull-market drag.
        """
        if not self._gld_momentum.is_ready:
            return 0.0
        if float(self._gld_momentum.current.value) <= self.GOLD_MOMENTUM_MIN:
            return 0.0
        equity = self.portfolio.total_portfolio_value
        if self._monthly_peak_value <= 0:
            return 0.0
        drawdown = (self._monthly_peak_value - equity) / self._monthly_peak_value
        if drawdown < 0.05:
            return 0.0
        return self.GOLD_MAX_WEIGHT

    def _regime_filter_check(self) -> None:
        """
        SPY 200MA regime filter — runs at 75 min after open.
        Crossover below: scale all positions to DD_GUARD_SCALE (25%).
                         Suppresses DD guard (_dd_guard_active = True).
        Recovery above:  clear flag — positions rebuilt at next rebalance.
        Fires once per crossover event, not on every bar below 200MA.
        """
        if self.is_warming_up:
            return

        below_200ma = self._spy_below_200ma()

        if below_200ma and not self._regime_filter_active:
            self._regime_filter_active = True
            self._dd_guard_active      = True
            equity = self.portfolio.total_portfolio_value
            regime_msg = (
                f"Regime filter triggered: SPY below 200MA | "
                f"SPY={self.securities[self._spy].price:.2f} "
                f"SMA200={self._spy_sma200.current.value:.2f} | "
                f"scaling to {self.DD_GUARD_SCALE:.0%}"
            )
            self.debug(regime_msg)
            if self.live_mode:
                self.log(f"[REGIME] {regime_msg}")
            targets = []
            for symbol, holding in self.portfolio.items():
                if not holding.invested:
                    continue
                current_weight = holding.holdings_value / equity
                scaled_weight  = current_weight * self.DD_GUARD_SCALE
                targets.append(PortfolioTarget(symbol, scaled_weight))
            if targets:
                self.set_holdings(targets)

        elif not below_200ma and self._regime_filter_active:
            self._regime_filter_active = False
            recovery_msg = (
                f"Regime filter cleared: SPY above 200MA | "
                f"SPY={self.securities[self._spy].price:.2f} "
                f"SMA200={self._spy_sma200.current.value:.2f} | "
                f"positions restore at next rebalance"
            )
            self.debug(recovery_msg)
            if self.live_mode:
                self.log(f"[REGIME] {recovery_msg}")

    def _rebalance_if_due(self) -> None:
        """
        Monthly rebalance on first trading day of each month.
        Filters: price data, min history days, momentum, sentiment floor.
        Weights: sentiment-tilted 60/40, 20% position cap, gold-aware budget.
        Resets monthly peak, DD guard and regime state after execution.
        """
        if self.is_warming_up:
            return
        if not self._selected_symbols:
            self.debug("Rebalance skipped — no symbols in universe yet")
            return

        ranked = self._rank_by_sentiment(self._selected_symbols)
        if not ranked:
            return

        momentum_excluded  = 0
        history_excluded   = 0
        sentiment_excluded = 0
        tradeable = []

        for s in ranked:
            if s not in self.securities:
                continue
            sec = self.securities[s]
            if not sec.has_data or sec.price <= 0 or not sec.is_tradable:
                continue
            added = self._symbol_added_date.get(s)
            if added is not None:
                if (self.time - added).days < self.MIN_HISTORY_DAYS:
                    history_excluded += 1
                    continue
            roc = self._momentum.get(s)
            if roc is not None and roc.is_ready:
                if float(roc.current.value) < self.MOMENTUM_MIN_RETURN:
                    momentum_excluded += 1
                    continue
            hits = self._sentiment_hit_count.get(s, 0)
            if hits >= self.MIN_NEWS_COUNT:
                if self._get_current_sentiment(s) < self.SENTIMENT_ENTRY_FLOOR:
                    sentiment_excluded += 1
                    continue
            tradeable.append(s)

        if not tradeable:
            self.debug("Rebalance skipped — no symbols passed filters")
            return

        if self.live_mode:
            no_data = len(ranked) - len(tradeable) - momentum_excluded - history_excluded - sentiment_excluded
            if no_data > 0:        self.debug(f"Rebalance: {no_data} skipped (no price data)")
            if history_excluded:   self.debug(f"Rebalance: {history_excluded} skipped (< {self.MIN_HISTORY_DAYS} days)")
            if momentum_excluded:  self.debug(f"Rebalance: {momentum_excluded} excluded (momentum)")
            if sentiment_excluded: self.debug(f"Rebalance: {sentiment_excluded} excluded (sentiment)")

        ranked_f    = tradeable[:self.MAX_POSITIONS]
        gold_weight = self._gold_target_weight()
        targets     = self._build_weighted_targets(ranked_f, gold_weight)
        if not targets:
            return

        if gold_weight > 0.0:
            targets.append(PortfolioTarget(self._gld, gold_weight))
            self.debug(f"Gold allocated: {gold_weight:.0%} | GLD ROC63={self._gld_momentum.current.value:.3f}")
        else:
            if self.portfolio[self._gld].invested:
                self.liquidate(self._gld)
                self.debug("Gold exited — conditions not met")

        self.set_holdings(targets)

        self._monthly_peak_value   = self.portfolio.total_portfolio_value
        self._dd_guard_active      = False
        self._regime_filter_active = False
        self._last_rebalance_time  = self.time
        self._pending_rebalance    = False

        preview = ",".join([x.value for x in ranked_f[:5]])
        scores_summary = " | ".join([
            f"{s.value}={self._get_current_sentiment(s):.2f}" for s in ranked_f[:5]
        ])
        rebalance_msg = (
            f"Rebalance {self.time.date()} | "
            f"coarse={self._coarse_count} fine={self._fine_count} "
            f"selected={len(self._selected_symbols)} "
            f"hist_excl={history_excluded} mom_excl={momentum_excluded} "
            f"sent_excl={sentiment_excluded} positions={len(ranked_f)} "
            f"gold={gold_weight:.0%} | top5={preview} | scores={scores_summary}"
        )
        self.debug(rebalance_msg)
        if self.live_mode:
            self.log(f"[REBALANCE] {rebalance_msg}")
            for s in ranked_f:
                self.log(
                    f"  {s.value}: sentiment={self._get_current_sentiment(s):.3f} "
                    f"hits={self._sentiment_hit_count.get(s, 0)} "
                    f"finbert={'yes' if self._finbert_ready else 'keyword'}"
                )

    def _daily_risk_check(self) -> None:
        """
        Check stop loss / take profit for each invested position.
        MIN_HOLD_DAYS grace period prevents whipsaw exits after entry.
        """
        if self.is_warming_up:
            return
        for symbol in list(self._entry_price_by_symbol.keys()):
            if not self.portfolio[symbol].invested:
                self._entry_price_by_symbol.pop(symbol, None)
                self._position_entry_date.pop(symbol, None)
                continue
            entry = self._entry_price_by_symbol.get(symbol, 0.0)
            if not self._is_finite_number(entry) or float(entry) <= 0:
                self._entry_price_by_symbol.pop(symbol, None)
                self._position_entry_date.pop(symbol, None)
                continue
            entry_date = self._position_entry_date.get(symbol)
            if entry_date is not None:
                if (self.time - entry_date).days < self.MIN_HOLD_DAYS:
                    continue
            price = float(self.securities[symbol].price)
            if not self._is_finite_number(price) or price <= 0:
                continue
            hit_tp = price >= (1.0 + self.TAKE_PROFIT) * float(entry)
            hit_sl = price <= (1.0 - self.STOP_LOSS)   * float(entry)
            if hit_tp or hit_sl:
                reason   = "take-profit" if hit_tp else "stop-loss"
                exit_msg = f"Risk exit [{reason}] {symbol} | entry={entry:.2f} now={price:.2f}"
                self.debug(exit_msg)
                if self.live_mode:
                    self.log(f"[RISK EXIT] {exit_msg}")
                self.liquidate(symbol)
                self._entry_price_by_symbol.pop(symbol, None)
                self._position_entry_date.pop(symbol, None)

    def _portfolio_drawdown_guard(self) -> None:
        """
        Circuit breaker: scale to DD_GUARD_SCALE if monthly DD > DD_GUARD_THRESHOLD.
        Skipped if regime filter already active (regime takes priority).
        Fires once per month — resets at next rebalance.
        """
        if self.is_warming_up:
            return
        if self._regime_filter_active:
            return

        equity = self.portfolio.total_portfolio_value

        if self._monthly_peak_value <= 0:
            self._monthly_peak_value = equity
            return
        if equity > self._monthly_peak_value:
            self._monthly_peak_value = equity
            return
        if self._dd_guard_active:
            return

        drawdown = (self._monthly_peak_value - equity) / self._monthly_peak_value
        if drawdown < self.DD_GUARD_THRESHOLD:
            return

        self._dd_guard_active = True
        guard_msg = (
            f"DD Guard triggered: drawdown={drawdown:.1%} from peak "
            f"${self._monthly_peak_value:,.0f} | current=${equity:,.0f} | "
            f"scaling to {self.DD_GUARD_SCALE:.0%}"
        )
        self.debug(guard_msg)
        if self.live_mode:
            self.log(f"[DD GUARD] {guard_msg}")

        targets = []
        for symbol, holding in self.portfolio.items():
            if not holding.invested:
                continue
            current_weight = holding.holdings_value / equity
            targets.append(PortfolioTarget(symbol, current_weight * self.DD_GUARD_SCALE))
        if targets:
            self.set_holdings(targets)

    def _decay_sentiment(self) -> None:
        """Daily EWMA decay: score *= DECAY_FACTOR. Prevents stale sentiment dominance."""
        if self.is_warming_up:
            return
        for symbol in list(self._sentiment_ewma_by_symbol.keys()):
            v = self._sentiment_ewma_by_symbol.get(symbol)
            if self._is_finite_number(v):
                self._sentiment_ewma_by_symbol[symbol] = float(v) * self.DECAY_FACTOR

    # ══════════════════════════════════════════════════════════════════════════
    # SENTIMENT RANKING & WEIGHTING
    # ══════════════════════════════════════════════════════════════════════════

    def _rank_by_sentiment(self, symbols: List[Symbol]) -> List[Symbol]:
        """
        Rank by EWMA sentiment score.
        Trusted symbols (>= MIN_NEWS_COUNT articles) ranked first,
        untrusted symbols follow — both sorted descending by score.
        """
        if not symbols:
            return []
        trusted, untrusted = [], []
        for s in symbols:
            score = float(self._get_current_sentiment(s))
            hits  = self._sentiment_hit_count.get(s, 0)
            (trusted if hits >= self.MIN_NEWS_COUNT else untrusted).append((s, score))
        trusted.sort(key=lambda x: x[1], reverse=True)
        untrusted.sort(key=lambda x: x[1], reverse=True)
        return [x[0] for x in trusted + untrusted]

    def _build_weighted_targets(self, ranked: List[Symbol], gold_weight: float = 0.0) -> List[PortfolioTarget]:
        """
        Sentiment-tilted 60/40 weighting within equity_budget = 1 - gold_weight.
        Top 25% by sentiment → 60% of budget. Bottom 75% → 40%.
        Per-position cap: MAX_POSITION_WEIGHT. Renormalise after capping.
        """
        n = len(ranked)
        if n <= 0:
            return []

        equity_budget = 1.0 - gold_weight
        top_n  = max(1, min(int(math.ceil(0.25 * n)), n))
        rest_n = n - top_n
        weights: dict = {}

        if rest_n <= 0:
            w = equity_budget / n
            for s in ranked:
                weights[s] = w
        else:
            w_top  = (0.60 * equity_budget) / top_n
            w_rest = (0.40 * equity_budget) / rest_n
            for i, s in enumerate(ranked):
                weights[s] = w_top if i < top_n else w_rest

        for s in weights:
            if weights[s] > self.MAX_POSITION_WEIGHT:
                weights[s] = self.MAX_POSITION_WEIGHT

        total = sum(weights.values())
        if self._is_finite_number(total) and total > 0 and abs(total - equity_budget) > 1e-6:
            scale = equity_budget / total
            for s in weights:
                weights[s] *= scale

        return [PortfolioTarget(s, float(w)) for s, w in weights.items()]

    # ══════════════════════════════════════════════════════════════════════════
    # SENTIMENT HELPERS
    # ══════════════════════════════════════════════════════════════════════════

    def _update_sentiment(self, symbol: Symbol, score: float) -> None:
        """EWMA update: new = alpha*score + (1-alpha)*prev. Increments hit count."""
        if symbol is None or not self._is_finite_number(score):
            return
        prev = self._sentiment_ewma_by_symbol.get(symbol, float("nan"))
        if not self._is_finite_number(prev):
            self._sentiment_ewma_by_symbol[symbol] = float(score)
        else:
            a = self._sentiment_alpha
            self._sentiment_ewma_by_symbol[symbol] = (
                a * float(score) + (1.0 - a) * float(prev)
            )
        self._sentiment_hit_count[symbol] = self._sentiment_hit_count.get(symbol, 0) + 1

    def _get_current_sentiment(self, symbol: Symbol) -> float:
        """Returns current EWMA sentiment score. Defaults to 0.0 if no data."""
        if symbol is None:
            return 0.0
        v = self._sentiment_ewma_by_symbol.get(symbol, float("nan"))
        return float(v) if self._is_finite_number(v) else 0.0

    # ══════════════════════════════════════════════════════════════════════════
    # FINBERT
    # ══════════════════════════════════════════════════════════════════════════

    def _initialize_local_finbert(self) -> None:
        """
        Load ProsusAI/finbert pipeline for live mode only.
        Skipped in backtest — 200ms/article × 100k+ articles = 50+ hour runtime.
        Runs validation inference on load; sets _finbert_ready = True on success.
        """
        self._finbert_ready = False
        self._finbert = {}
        if not self.live_mode:
            self.debug("FinBERT skipped — backtest mode, using keyword model")
            return
        if not self._use_local_finbert:
            self.debug("FinBERT disabled by configuration")
            return
        try:
            from transformers import pipeline  # type: ignore
            pipe = pipeline(
                task="sentiment-analysis",
                model="ProsusAI/finbert",
                tokenizer="ProsusAI/finbert",
                truncation=True,
            )
            test = pipe("earnings beat expectations")
            if not test:
                raise RuntimeError("Empty test inference")
            self._finbert = {"pipeline": pipe}
            self._finbert_ready = True
            self.debug(f"Local FinBERT ready (test: {test[0].get('label')})")
        except Exception as exc:
            self.debug(f"Local FinBERT unavailable: {exc}")

    def _finbert_sentiment_score(self, text: str):
        """
        Run FinBERT inference. Returns float in [-1, +1] or None on failure.
        positive → +confidence | negative → -confidence | neutral → 0.0
        """
        if not self._finbert_ready or not text:
            return None
        try:
            pipe = self._finbert.get("pipeline")
            if pipe is None:
                return None
            result = pipe(str(text).strip()[:self._finbert_max_chars])
            if isinstance(result, list) and result:
                result = result[0]
            if not isinstance(result, dict):
                return None
            label = str(result.get("label", "")).lower()
            conf  = max(0.0, min(1.0, float(result.get("score", 0.0))))
            if "pos" in label: return  conf
            if "neg" in label: return -conf
            if "neu" in label: return  0.0
            return None
        except Exception:
            return None

    # ══════════════════════════════════════════════════════════════════════════
    # KEYWORD SENTIMENT FALLBACK
    # ══════════════════════════════════════════════════════════════════════════

    def _compute_naive_text_sentiment(self, news_item) -> float:
        """
        Weighted keyword scorer — three tiers (1.5/1.0/0.5), negation window
        of 3 tokens, ambiguous words = -0.3, macro exclusion filter.
        Score = total_score / total_weight, clamped to [-1, +1].
        Returns None if text too short (< 3 tokens) or extraction fails.
        """
        text = self._extract_text(news_item)
        if not text:
            return None
        try:
            cleaned = str(text).lower()
            for ch in "\n\r\t,.;:!?()[]{}'\"":
                cleaned = cleaned.replace(ch, " ")
            tokens = [t.strip("-") for t in cleaned.split() if t.strip("-")]
        except Exception:
            return None

        if len(tokens) < 3:
            return None

        bigrams  = [tokens[i] + "-" + tokens[i+1] for i in range(len(tokens)-1)]
        trigrams = [tokens[i] + "-" + tokens[i+1] + "-" + tokens[i+2]
                    for i in range(len(tokens)-2)]
        all_tokens = tokens + bigrams + trigrams

        if sum(1 for t in all_tokens if t in self._KW_MACRO_EXCLUDE) >= 3:
            return 0.0

        total_score  = 0.0
        total_weight = 0.0
        negation_indices = {i for i, t in enumerate(tokens) if t in self._KW_NEGATIONS}

        def _is_negated(idx: int) -> bool:
            return any(idx - 3 <= ni < idx for ni in negation_indices)

        for i, token in enumerate(tokens):
            weight = polarity = None
            if   token in self._KW_STRONG_POS: weight, polarity = 1.5,  1.0
            elif token in self._KW_NORMAL_POS: weight, polarity = 1.0,  1.0
            elif token in self._KW_WEAK_POS:   weight, polarity = 0.5,  1.0
            elif token in self._KW_STRONG_NEG: weight, polarity = 1.5, -1.0
            elif token in self._KW_NORMAL_NEG: weight, polarity = 1.0, -1.0
            elif token in self._KW_WEAK_NEG:   weight, polarity = 0.5, -1.0
            elif token in self._KW_AMBIGUOUS:
                total_score  -= 0.3
                total_weight += 0.3
                continue
            if weight is None:
                continue
            if _is_negated(i):
                polarity *= -1.0
            total_score  += weight * polarity
            total_weight += weight

        if total_weight == 0.0:
            return 0.0

        score = max(-1.0, min(1.0, total_score / total_weight))
        return float(score) if self._is_finite_number(score) else None

    # ══════════════════════════════════════════════════════════════════════════
    # TIINGO NEWS SUBSCRIPTIONS
    # ══════════════════════════════════════════════════════════════════════════

    def _ensure_tiingo_news_subscription(self, underlying: Symbol) -> None:
        """
        Subscribe to TiingoNews for an equity symbol.
        is_tradable=False prevents news tickers appearing in capacity calculation
        (known QC bug: news symbols with zero liquidity cause capacity = $0).
        """
        if underlying is None or underlying in self._news_symbol_by_underlying:
            return
        news_security = self.add_data(TiingoNews, underlying)
        news_security.is_tradable = False
        news_symbol = news_security.symbol
        self._news_symbol_by_underlying[underlying] = news_symbol
        self._underlying_by_news_symbol[news_symbol] = underlying

    def _remove_tiingo_news_subscription(self, underlying: Symbol) -> None:
        """Remove TiingoNews subscription and clean up both lookup maps."""
        if underlying is None:
            return
        news_symbol = self._news_symbol_by_underlying.pop(underlying, None)
        if news_symbol is None:
            return
        self._underlying_by_news_symbol.pop(news_symbol, None)
        try:
            self.remove_security(news_symbol)
        except Exception:
            pass

    # ══════════════════════════════════════════════════════════════════════════
    # STARTUP RECONCILIATION
    # ══════════════════════════════════════════════════════════════════════════

    def _reconcile_existing_holdings(self) -> None:
        """
        Adopt existing account positions on strategy restart.
        Populates entry price from IB average cost and sets entry date to
        MIN_HOLD_DAYS ago so stop loss activates immediately.
        Skips GLD, SPY, and symbols already tracked.
        Called after pending liquidations are cleared in on_warmup_finished().
        """
        reconciled = 0
        for symbol, holding in self.portfolio.items():
            if not holding.invested:
                continue
            if symbol in (self._gld, self._spy):
                continue
            if symbol in self._entry_price_by_symbol:
                continue
            avg_cost = float(holding.average_price)
            if not self._is_finite_number(avg_cost) or avg_cost <= 0:
                continue
            self._entry_price_by_symbol[symbol] = avg_cost
            self._position_entry_date[symbol] = (
                self.time - timedelta(days=self.MIN_HOLD_DAYS)
            )
            reconciled += 1
            self.debug(f"Reconciled existing holding: {symbol.value} avg_cost={avg_cost:.2f}")

        if reconciled > 0:
            msg = f"Startup reconciliation: {reconciled} existing positions adopted"
            self.debug(msg)
            if self.live_mode:
                self.log(f"[RECONCILE] {msg}")
        else:
            self.debug("Startup reconciliation: no existing positions found")

    # ══════════════════════════════════════════════════════════════════════════
    # WARMUP FINISHED
    # ══════════════════════════════════════════════════════════════════════════

    def on_warmup_finished(self) -> None:
        """
        Runs once when 230-day warmup ends.
        1. Preserve pre-existing IB holdings from pending liquidation queue.
        2. Liquidate remaining queued symbols (genuine universe dropouts).
        3. Reconcile preserved holdings into entry price/date tracking.
        4. Pre-warm sentiment from last 10 days of TiingoNews history.
        """
        # ── 1. Preserve pre-existing holdings from liquidation queue ───────
        # Symbols that dropped out of universe during warmup should not be
        # liquidated if they were held in IB before restart — reconciliation
        # will adopt them. Only remove them from the queue here; liquidation
        # of genuine dropouts happens in step 2.
        pre_existing = {
            s for s in self._pending_liquidations
            if self.portfolio[s].invested
            and self._is_finite_number(float(self.portfolio[s].average_price))
            and float(self.portfolio[s].average_price) > 0
        }
        if pre_existing:
            names = ", ".join(s.value for s in pre_existing)
            self.debug(f"Preserving pre-existing holdings from liquidation queue: {names}")
            if self.live_mode:
                self.log(f"[RECONCILE] Preserving pre-existing holdings: {names}")
            self._pending_liquidations -= pre_existing

        # ── 2. Liquidate genuine universe dropouts ─────────────────────────
        for symbol in list(self._pending_liquidations):
            if self.portfolio[symbol].invested:
                self.liquidate(symbol)
            self._entry_price_by_symbol.pop(symbol, None)
            self._sentiment_ewma_by_symbol.pop(symbol, None)
            self._sentiment_hit_count.pop(symbol, None)
            self._momentum.pop(symbol, None)
            self._symbol_added_date.pop(symbol, None)
            self._remove_tiingo_news_subscription(symbol)
        self._pending_liquidations.clear()

        # ── 3. Reconcile existing holdings into risk tracking ──────────────
        self._reconcile_existing_holdings()

        # ── 4. Sentiment pre-warm ──────────────────────────────────────────
        scorer = "keyword-only" if (self.PREWARM_KEYWORD_ONLY or not self.live_mode) \
                 else "FinBERT+keyword"
        self.debug(f"Sentiment pre-warm starting [{scorer}] ...")
        total_articles = 0
        total_scored   = 0

        for underlying, news_symbol in list(self._news_symbol_by_underlying.items()):
            try:
                history = self.history(TiingoNews, news_symbol, 10, Resolution.DAILY)
                if history is None or history.empty:
                    continue
                rows = list(history.iterrows())[-self.PREWARM_MAX_ARTICLES_PER_SYMBOL:]
                for _, row in rows:
                    text_parts = []
                    for col in ["title", "description", "summary",
                                "Title", "Description", "Summary"]:
                        val = row.get(col, "")
                        if val and str(val).strip():
                            text_parts.append(str(val).strip())
                    text = " ".join(text_parts).strip()
                    if not text:
                        continue
                    total_articles += 1
                    text_hash = hash(text)
                    if text_hash in self._score_cache:
                        score = self._score_cache[text_hash]
                    else:
                        score = None
                        if not self.PREWARM_KEYWORD_ONLY and self.live_mode:
                            score = self._finbert_sentiment_score(text)
                        if score is None:
                            class _Row:
                                pass
                            r             = _Row()
                            r.title       = row.get("title", "")
                            r.description = row.get("description", "")
                            r.summary     = row.get("summary", "")
                            score = self._compute_naive_text_sentiment(r)
                        if len(self._score_cache) >= self.SCORE_CACHE_MAX:
                            try:
                                self._score_cache.pop(next(iter(self._score_cache)))
                            except Exception:
                                pass
                        self._score_cache[text_hash] = score
                    if score is not None and self._is_finite_number(score):
                        self._update_sentiment(underlying, float(score))
                        total_scored += 1
            except Exception as e:
                self.debug(f"Pre-warm error [{underlying.value}]: {e}")
                continue

        trusted = sum(1 for c in self._sentiment_hit_count.values() if c >= self.MIN_NEWS_COUNT)
        self.debug(
            f"Sentiment pre-warm complete | articles={total_articles} "
            f"scored={total_scored} | symbols={len(self._sentiment_hit_count)} "
            f"trusted (>={self.MIN_NEWS_COUNT} hits)={trusted}"
        )

    # ══════════════════════════════════════════════════════════════════════════
    # STATIC UTILITIES
    # ══════════════════════════════════════════════════════════════════════════

    @staticmethod
    def _is_finite_number(x) -> bool:
        """True if x is a finite real number — not None, NaN, Inf, or bool."""
        if x is None or isinstance(x, bool):
            return False
        try:
            return math.isfinite(float(x))
        except Exception:
            return False

    @staticmethod
    def _get_float(obj, attr_paths: List[str]):
        """
        Traverse nested object via dot-notation paths, return first valid float.
        Tries multiple paths — handles Morningstar field name inconsistencies.
        Unwraps QC IndicatorDataPoint .value attribute if present.
        """
        for path in attr_paths:
            current = obj
            ok = True
            for part in path.split("."):
                if current is None or not hasattr(current, part):
                    ok = False
                    break
                current = getattr(current, part)
            if not ok or current is None:
                continue
            if hasattr(current, "value"):
                current = current.value
            try:
                return float(current)
            except Exception:
                continue
        return None

    def _to_ratio(self, value):
        """Convert percentage value > 1.0 to decimal ratio. Returns None if invalid."""
        if not self._is_finite_number(value):
            return None
        v = float(value)
        return v / 100.0 if v > 1.0 else v
"""
Momentum and Historical Band Ceiling Sizing Algorithm.

This module implements a sector-neutral large-cap momentum strategy. 
It uses a universe-wide breadth indicator to manage risk-on/risk-off regimes
and applies historical band-based ceilings to individual position sizing.
"""

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

# ====================================================
# Sector-Neutral Large-Cap Universe
# ====================================================
class SectorTopUniverse(FundamentalUniverseSelectionModel):
    """
    Selection model for a sector-neutral large-cap universe.
    
    Filters for primary exchange listing, minimum price, and minimum market cap, 
    then selects the top 75 stocks by market capitalization within each 
    Morningstar sector.
    """
    def __init__(self, algo, blacklist=None):
        """
        Initializes the SectorTopUniverse.

        Parameters
        ----------
        algo : QCAlgorithm
            The algorithm instance.
        blacklist : list of str, optional
            List of ticker strings to exclude from selection.
        """
        self.algo = algo
        self.blacklist = set(blacklist or [])
        super().__init__(self._select)

    def _select(self, fundamentals):
        """
        Performs the fundamental selection logic.

        Parameters
        ----------
        fundamentals : list[Fundamental]
            The list of fundamental data objects.

        Returns
        -------
        list[Symbol]
            The symbols to include in the universe.
        """
        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


# ====================================================
# Momentum + Historical Band Ceiling Sizing
# ====================================================
class StockOnlyMomentum(QCAlgorithm):
    """
    Momentum strategy with dynamic position sizing based on price band history.

    This algorithm selects top momentum stocks, verifies they are above 
    their EMA, and sizes them based on their current price position 
    relative to historical peak price bands (Z-score based).
    """

    def Initialize(self):
        """
        Initializes the algorithm state, parameters, and scheduling.
        """
        self.SetStartDate(2004, 1, 1)
        self.SetCash(100_000)

        # --------------------
        # Momentum parameters
        # --------------------
        self.lookbacks = [21, 63, 126, 189, 252]
        self.stock_count = 10
        self.max_weight = 0.20

        # --------------------
        # Band parameters
        # --------------------
        self.band_len = 189
        self.hist_len = 126

        self.UniverseSettings.Resolution = Resolution.Daily
        self.UniverseSettings.DataNormalizationMode = DataNormalizationMode.TOTAL_RETURN

        # -------- BREADTH STATE --------
        self.allow_universe = True
        self.current_band_idx = {}
        self.bottom_frac_hist = deque(maxlen=3)
        self.BOTTOM_LEVELS = {0, 1, 2, 3, 4}

        # track worst breadth
        self.min_bottom_frac = 1.0
        self.was_risk_off = False  
        self.SetUniverseSelection(
            SectorTopUniverse(self, blacklist={"GME", "AMC"})
        )

        self.symbols = set()

        self.adx_limit = 35
        self.adx_period = 14

        # Per-symbol state
        self.ma = {}
        self.adx = {}
        self.stretch_max = {}
        self.close_win = {}
        self.stretch_ema = {}
        self.band_hist = {}

        self.SetWarmUp(300)

        self.Schedule.On(
            self.DateRules.MonthEnd("SPY"),
            self.TimeRules.BeforeMarketClose("SPY", 5),
            self.Rebalance
        )

    def OnSecuritiesChanged(self, changes):
        """
        Initializes/cleans up indicators when securities enter or leave the universe.

        Parameters
        ----------
        changes : SecurityChanges
            The added and removed securities.
        """
        for sec in changes.AddedSecurities:
            sec.SetFeeModel(ConstantFeeModel(0))
            s = sec.Symbol
            self.symbols.add(s)

            self.stretch_max[sec.Symbol] = 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[s] = RollingWindow[int](self.hist_len)

        for sec in changes.RemovedSecurities:
            s = sec.Symbol
            self.symbols.discard(s)
            self.ma.pop(s, None)
            self.adx.pop(s, None)
            self.stretch_max.pop(sec.Symbol, 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)

    def OnData(self, data):
        """
        Updates technical indicators and band indices on every new data slice.
        Also tracks peak stretch (Z-score) to anticipate momentum blow-offs.
        """
        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

            # Calculate Standard Deviation and Mean
            dev = np.std(list(self.close_win[s]))
            if dev <= 0:
                continue

            mid = self.ma[s].Current.Value
            
            # 1. Calculate the current Stretch (Z-score)
            stretch = abs(close - mid) / dev
            self.stretch_ema[s].Update(self.Time, stretch)

            # 2. Track the long-term peak Stretch for this symbol
            # This identifies the "Maximum Velocity" of the current multi-year trend
            if s not in self.stretch_max:
                self.stretch_max[s] = 0.0
            
            # Update the peak stretch seen so far
            if stretch > self.stretch_max[s]:
                self.stretch_max[s] = stretch

            # 3. Calculate the Price Bands
            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
            ]

            # 4. Map price to Band Index
            idx = self._band_index(close, bands)
            self.current_band_idx[s] = idx

    def _band_index(self, price, bands):
        """
        Determines which index a price occupies within a set of bands.

        Parameters
        ----------
        price : float
            Current price of the asset.
        bands : list[float]
            List of price levels defining the bands.

        Returns
        -------
        int
            The index of the band.
        """
        for i in range(len(bands) - 1):
            if bands[i] <= price < bands[i + 1]:
                return i
        return len(bands) - 2

    def Rebalance(self):
        """
        Main execution logic for rebalancing the portfolio at month-end.
        
        Evaluates market breadth stress, ranks momentum, and applies 
        historical high band scaling to position sizing.
        """
        if self.IsWarmingUp:
            return

        # -------- UNIVERSE-WIDE BREADTH --------
        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)

        if not hasattr(self, 'max_stress_level'): self.max_stress_level = 0.0
        self.max_stress_level = max(self.max_stress_level, bottom_frac)

        # -------- BREADTH REGIME --------
        if bottom_frac >= 0.45:
            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

            if improvement >= 0.60 or bottom_frac < 0.15:
                self.Debug(f"Recovery! Stress: {bottom_frac:.1%}. Resetting ceilings.")
                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 
        else:
            self.allow_universe = True

        if not self.allow_universe:
            self.Liquidate()
            self.Debug("Risk-Off.") # Corrected from lowercase debug
            return

        # -------- NORMAL REBALANCE LOGIC --------
        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

            if mom > 0:
                momentum[s] = mom

        if not momentum:
            self.Liquidate()
            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)

            scaled[s] = (momentum[s] * self.adx[s].current.value) * scale

            # 2. NEW: Anticipatory Exhaustion Scaling
            # Pull the current stretch and the peak stretch we recorded in OnData
            current_stretch = self.stretch_ema[s].Current.Value
            peak_stretch = self.stretch_max.get(s, 0.0)

            # If we are in high bands (idx >= 10) but the stretch has decayed 20% from peak
            if idx >= 10 and peak_stretch > 0:
                if current_stretch < (peak_stretch * 0.80):
                    # We override the scale to its minimum (0.2) because 
                    # the momentum is 'exhausted' even if the price is still high.
                    scale = 0.2
                    self.Debug(f"ANTICIPATION: Scaling down {s.Value} due to Stretch Exhaustion.")

            # 3. Apply the final scale to momentum
            scaled[s] = (momentum[s] * self.adx[s].current.value) * scale

        # -------- FINAL WEIGHTING LOGIC --------
        if not scaled:
            self.Liquidate()
            self.Debug("No Assets to trade.")
            return

        # 1. Calculate Market Exposure Scale
        # bottom_frac represents market stress. 
        # Exposure = 100% when stress is 0, scaling down as stress increases.
        # We cap the stress influence at 1.0 (though it's naturally 0-1)
        min_stress = 0.15
        max_stress = 0.45

        # Calculate Target Exposure using Linear Interpolation
        # If bottom_frac = 0.20, exposure will be approx 0.71
        target_exposure = np.interp(bottom_frac, [min_stress, max_stress], [1.0, 0.0])
        target_exposure = float(round(target_exposure, 2))

        # 2. Proportional Weights
        total_scaled = sum(scaled.values())
        raw_weights = {s: (v / total_scaled) for s, v in scaled.items()}

        # 3. Apply 20% Cap and the target_exposure scale
        # We multiply by target_exposure to reduce total portfolio leverage
        capped_weights = {s: min(self.max_weight, 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():
                # Normalize weight to 100% then apply the market exposure scale
                normalized_w = w / current_sum
                final_weights[s] = normalized_w * target_exposure

        # 4. Execution
        # Use a list of symbols to liquidate that are NOT in our final_weights
        for pos in self.Portfolio.Values:
            if pos.Invested and pos.Symbol not in final_weights:
                self.Liquidate(pos.Symbol)

        for s, w in final_weights.items():
            if w > 0:
                self.SetHoldings(s, w)

        # Output logic
        output = ", ".join([f"{s.Value}: {w*100:.1f}%" for s, w in final_weights.items() if w > 0])
        if output:
            self.Debug(f"Exposure: {target_exposure:.1%} | Weights: {output}")
        else:
            self.Debug(f"Exposure: 0% | No active positions")
"""
Deploy Book — Crash-Guarded LETF Rotation + Sector-Neutral Momentum
===================================================================

Single-algorithm deployment of a 2-sleeve long-only US equity book:

  * 60% capital: crash-guarded 4-way leveraged-ETF rotation ensemble
                 (T11 + T10 + S3 + S2, each 25% of the bucket, + a QQQ
                  velocity crash guard that de-levers in tiers on QQQ
                  trailing 10-day return) — "Multi-Model Tactical ETF
                  Rotation 504 with Velocity Crash Guard"

  * 40% capital: sector-neutral large-cap trend momentum (monthly
                 rebalance, top-10 multi-horizon momentum, EMA-189 trend
                 filter, ADX-35 cap, Fibonacci-band ceiling sizing,
                 breadth risk-off with 180-day hard timeout) — "Sector-
                 Neutral Large-Cap Trend Momentum (Fixed)", FIX 1-6.

NOTE ON THE 60/40 SPLIT: the class name and bucket percentages were
originally 75/25; the split was shifted to 60/40 to improve PSR. The
W_504CG / W_476 constants below are the source of truth.

Execution: weights aggregated daily, filled via MarketOnOpenOrder for clean
next-open fills (no look-ahead). Account: cash/margin (IBKR), Daily resolution.

Operational features:
  - Daily EOD email report with GBP P&L, positions, rolling Sharpe
  - Crash-guard entry/exit email alerts
  - 476 risk-off / recovery email alerts
  - OnWarmupFinished: HWM init, orphan 476 position adoption, mode inference
  - ObjectStore save/restore of Fibonacci rolling windows + regime state
  - OnEndOfAlgorithm: final equity + crash guard event summary
  - FreePortfolioValuePercentage = 2% buffer

============================================================================
UCITS EXECUTION MAP — conId reference + dormant deployability gate
============================================================================
QuantConnect trades US tickers in BOTH modes (QC has no Market.LSE/BVME/AEB
enum). The UCITS map is therefore NOT a QC routing table — it is:

  (a) authoritative reference data (conId + exchange + currency) for
      replicating each leg manually in IBKR TWS for a MiFID-II UCITS account;
  (b) a DEPLOYABILITY GATE: any leg whose UCITS equivalent lacks a conId is
      dropped to cash in live mode. All legs now carry a conId, so the gate is
      dormant — but it stays wired so a future unverified leg fails safe.

conIds were entered by hand and are only authoritative once IBKR resolves each
for the exact line intended (conId is currency- and listing-specific).
_validate_ucits_map() runs at Initialize and flags mechanically-detectable
errors (bad exchange strings, missing fields) — it does NOT confirm a conId
points at the right instrument. Review flags: IOO->SDIG and BIL->TBIL/EBS look
unusual; QLD->LQQ is a EUR Paris line. Confirm all three in TWS.

The map spans USD/EUR/GBP lines, so GBP P&L reporting is currency-aware via
UCITS_CCY + FX_TO_GBP rather than a single hardcoded rate.

Ticker strings alone are unsafe to route on: the WisdomTree 3x NASDAQ product
trades as QQQ3 (LSE/USD), LQQ3 (LSE/GBP), 3QQQ (Xetra), QQQ3.MI (Milan), all
under ISIN IE00BLRPRL42. Route by conId once known. The WisdomTree 3x/2x
leveraged lines are ETNs/ETPs (UCITS-eligible), not UCITS funds themselves —
a MiFID-II labelling distinction relevant to account-level eligibility.

EMAIL: set YOUR_EMAIL before deploying live.
"""

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

# ============================================================================
# Operational constants
# ============================================================================
YOUR_EMAIL      = "tinusanjeev@gmail.com"   # set before deploying live
FREE_CASH_PCT   = 0.02                        # 2% buffer against over-allocation
STATE_STORE_KEY = 'deploy_book_476_state'     # ObjectStore key for Fibonacci state

# Static FX for GBP reporting only (display, not execution). Update periodically
# or replace with self.Securities[fx].Price if you add the FX pairs as data.
# Keys are the leg currencies present in UCITS_MAP.
FX_TO_GBP = {"GBP": 1.0, "USD": 1.0 / 1.27, "EUR": 1.0 / 1.17}

# 476 momentum-sleeve blacklist. SATS (EchoStar) restored from the standalone —
# it was the lowest-capacity asset in the 2011-2026 run and likely inflated the
# momentum sleeve via an untradeable name. Add tickers here to exclude them from
# universe selection entirely.
MOMENTUM_BLACKLIST = {"SATS", "GME", "AMC"}

# ============================================================================
# UCITS EXECUTION MAP — (us -> (ucits_ticker, conId, exchange, currency))
# ============================================================================
KNOWN_EXCHANGES = {"LSEETF", "BVME.ETF", "AEB", "SBF", "EBS"}

# conId entries below were entered by hand from a TWS lookup. A conId is only
# authoritative once IBKR resolves it for the *exact line* you intend to trade
# (conId is currency- and listing-specific). _validate_ucits_map() checks the
# mechanically-detectable errors (bad exchange string, missing fields); it
# CANNOT confirm a conId points at the right instrument. Confirm each leg in
# TWS before trading live — especially the ones flagged in review (IOO->SDIG,
# BIL->TBIL, the EUR-denominated QLD->LQQ line).
UCITS_MAP = {
    "SPY":  ("CSPX", 76023663,  "LSEETF",   "USD"),  # iShares Core S&P 500
    "GLD":  ("SGLN", 86656185,  "LSEETF",   "GBP"),  # iShares Physical Gold
    "TQQQ": ("QQQ3", 454140835, "LSEETF",   "USD"),  # WT NASDAQ 100 3x
    "SOXL": ("SMH3", 532462928, "LSEETF",   "USD"),  # WT Semiconductors 3x
    "QQQ":  ("EQQQ", 18706552,  "BVME.ETF", "EUR"),  # Invesco EQQQ (Milan, EUR)
    "SOXS": ("SMHS", 768510040, "LSEETF",   "USD"),  # WT Semiconductors 3x short
    "PSQ":  ("QQQS", 454140879, "LSEETF",   "USD"),  # WT NASDAQ 100 1x short
    "TLT":  ("IDTL", 181150836, "LSEETF",   "USD"),  # iShares $ Treasury 20+
    "IEF":  ("IBTM", 68489974,  "LSEETF",   "GBP"),  # iShares $ Treasury 7-10
    "BSV":  ("IBTS", 52444671,  "LSEETF",   "GBP"),  # iShares $ Treasury 1-3
    "BND":  ("AGGG", 297484323, "LSEETF",   "USD"),  # iShares Core Global Agg
    "SH":   ("SPXS", 79000331,  "LSEETF",   "USD"),  # WT S&P 500 1x short
    "VTV":  ("IWVL", 169449245, "LSEETF",   "USD"),  # iShares Edge USA Value
    "XLK":  ("WTCH", 231949398, "AEB",      "EUR"),  # WT US Tech (Amsterdam, EUR)
    "XLF":  ("WFIN", 232129588, "LSEETF",   "USD"),  # WT US Financials
    "SPXL": ("3USL", 118833789, "LSEETF",   "USD"),  # WT S&P 500 3x
    "QLD":  ("LQQ",  328860292, "SBF",      "EUR"),  # Amundi NASDAQ 100 2x (Paris, EUR)
    "SQQQ": ("LQQS", 118859837, "LSEETF",   "GBP"),  # WT NASDAQ 100 3x short  (was LSETF typo)
    "SMH":  ("SMGB", 458512497, "LSEETF",   "GBP"),  # WT Semiconductor UCITS  (was LSETF typo)
    "IOO":  ("SDIG", 136370290, "LSEETF",   "USD"),  # iShares Global 100  [REVIEW: SDIG?]
    "XLP":  ("SXLP", 199482910, "LSEETF",   "USD"),  # WT US Consumer Staples
    "XLY":  ("SXLY", 199482907, "LSEETF",   "USD"),  # WT US Consumer Discretionary
    "AGG":  ("SUAG", 94304175,  "LSEETF",   "GBP"),  # iShares Core Global Agg
    "BIL":  ("TBIL", 388688449, "EBS",      "USD"),  # USD T-Bill 0-3m  [REVIEW: TBIL/EBS?]
}

# All legs now carry a conId, so TIER2 is empty and the live deployability gate
# is dormant (kept for the case where a future leg is added unverified).
TIER1 = {k for k, v in UCITS_MAP.items() if v[1] is not None}
TIER2 = {k for k, v in UCITS_MAP.items() if v[1] is None}
UCITS_EXEC_MAP = {v[0]: (v[1], v[2], v[3]) for v in UCITS_MAP.values()}
UNVERIFIED_UCITS = set(TIER2)

# us_ticker -> currency of its UCITS leg, for currency-aware GBP reporting.
UCITS_CCY = {us: v[3] for us, v in UCITS_MAP.items()}


def ucits_is_verified(us_ticker):
    """True only if this US ticker has a conId-verified UCITS leg."""
    v = UCITS_MAP.get(us_ticker)
    return bool(v and v[1] is not None)


def ucits_resolve_leg(us_ticker):
    """(ucits_ticker, conId, exchange, currency) for a verified leg; raises on
    an unverified (Tier 2, conId=None) leg."""
    v = UCITS_MAP.get(us_ticker)
    if v is None:
        raise KeyError(f"{us_ticker}: no UCITS mapping defined")
    if v[1] is None:
        raise ValueError(
            f"{us_ticker} -> {v[0]}: UNVERIFIED leg (conId=None). "
            f"Resolve in TWS and populate UCITS_MAP before trading live.")
    return v


# ============================================================================
# 476 universe selector (sector-neutral top-by-market-cap large caps)
# ============================================================================
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


# ============================================================================
# Unified deploy book
# ============================================================================
class DeployBook(QCAlgorithm):

    # -------- Capital split between sleeves (source of truth) --------
    W_504CG = 0.60   # shifted from 0.75 -> 0.60 to improve PSR
    W_476   = 0.40   # shifted from 0.25 -> 0.40

    # -------- 504cg constants --------
    QUARTER        = 0.25            # each sub-sleeve gets 25% of the 504cg bucket
    _T10_LATE      = set()
    _T11_LATE      = set()
    # Tiered crash guard on QQQ 10-day velocity
    CRASH_TIERS    = [(-0.07, 0.50), (-0.15, 0.25), (-0.20, 0.00)]
    CRASH_EXIT     = -0.04
    CRASH_LOOKBACK = 10

    # -------- 476 constants --------
    LOOKBACKS          = [21, 63, 126, 189, 252]
    STOCK_COUNT        = 10
    MAX_WEIGHT_476     = 0.20
    BAND_LEN           = 189
    HIST_LEN           = 126
    STRETCH_WIN_LEN    = 126     # FIX 3: rolling peak window (not lifetime max)
    ADX_LIMIT          = 35
    ADX_PERIOD         = 14
    BOTTOM_LEVELS      = {0, 1, 2, 3, 4}
    RISK_OFF_THRESHOLD = 0.45

    def Initialize(self):
        # ------------------------------------------------------------------
        # Date window is parameter-driven so the same code runs in-sample,
        # out-of-sample, or full-period without edits. In the QC UI / config,
        # set parameters:
        #   "start_date" = "2011-01-01"   "end_date" = "2019-01-01"   (IS)
        #   "start_date" = "2019-01-01"   "end_date" = "2026-01-01"   (OOS)
        #   (omit both)                                               -> full
        # A clean IS/OOS split: tune nothing on IS, then measure OOS. If the
        # 56% CAGR / 1.28 Sharpe survives 2019->2026 it is far more credible;
        # if it collapses, the PSR (69.5%) was warning about overfit.
        # ------------------------------------------------------------------
        start = self._parse_date_param("start_date", default=(2011, 1, 1))
        end   = self._parse_date_param("end_date",   default=(2026, 1, 1))
        self.SetStartDate(start.year, start.month, start.day)
        self.SetEndDate(end.year, end.month, end.day)
        self.Log(f"[WINDOW] {start:%Y-%m-%d} -> {end:%Y-%m-%d}")

        self.SetCash(100_000)
        self.SetBrokerageModel(BrokerageName.INTERACTIVE_BROKERS_BROKERAGE, AccountType.MARGIN)
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.0
        self.Settings.FreePortfolioValuePercentage = FREE_CASH_PCT
        self.SetBenchmark("SPY")

        res = Resolution.Daily

        # US market anchor — scheduling + exchange-open guards only. Never traded.
        self._us_anchor = self.AddEquity("SPY", res).Symbol
        # QQQ signal instrument — crash guard velocity window only. Never traded.
        self._qqq_signal = self.AddEquity("QQQ", res).Symbol

        # -------- 504cg manual universe (US tickers; traded in both modes) ----
        all_tickers = [
            "TQQQ", "SOXL", "SPXL", "QLD",                 # leveraged long
            "SQQQ", "SOXS", "PSQ", "SH",                   # inverse / short
            "SPY", "QQQ", "SMH", "IOO",                    # broad equity
            "VTV", "XLK", "XLF", "XLP", "XLY",             # factor / sector
            "TLT", "IEF", "AGG", "BND", "BSV", "BIL",      # fixed income
        ]
        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._504cg_universe = set(self._syms.values())

        # -------- LIVE deployability gate -------------------------------------
        # In live, any 504cg leg whose UCITS equivalent is unverified (Tier 2)
        # is dropped to cash. In backtest, US legs trade directly so nothing is
        # dropped. Report the gated set once at startup.
        self._live_blocked = set(UNVERIFIED_UCITS) if self.LiveMode else set()
        if self.LiveMode and self._live_blocked:
            self.Log(f"[UCITS-GATE] LIVE: {len(self._live_blocked)} unverified "
                     f"legs will route to cash: {sorted(self._live_blocked)}")
            self.Log(f"[UCITS-GATE] Verified legs ({len(TIER1)}): {sorted(TIER1)}")

        # Structural validation of the UCITS map (exchange typos, bad conIds,
        # unmapped currencies). Runs in both modes so backtests surface map
        # errors too. Does not confirm conId correctness — that needs TWS.
        self._validate_ucits_map()

        # -------- 504cg indicators --------
        def rsi10(t): return self.RSI(self._syms[t], 10, MovingAverageType.Wilders, res)
        self._t10_rsi = {t: rsi10(t) for t in [
            "VTV", "XLP", "TQQQ", "XLY", "SPY", "SOXL", "SPXL", "XLK"]}
        self._t11_rsi10 = {t: rsi10(t) for t in [
            "SPY", "IOO", "TQQQ", "VTV", "XLF", "XLK", "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._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")

        # -------- 504cg state --------
        self._qqq_window       = RollingWindow[float](self.CRASH_LOOKBACK + 1)
        self._in_crash         = False
        self._crash_gross_prev = 1.0
        self._504cg_targets    = {}
        self._504cg_last_label = ""
        self._crash_events     = []

        # -------- 476 universe + state --------
        self.UniverseSettings.Resolution = Resolution.Daily
        self.UniverseSettings.DataNormalizationMode = DataNormalizationMode.TOTAL_RETURN
        # Blacklist regressed from the standalone ({"SATS","GME","AMC"}); SATS
        # (EchoStar) is a low-capacity name that was the binding capacity
        # constraint in the 2011-2026 run. Restored here plus other illiquid /
        # meme names that distort a top-10 momentum sleeve.
        self.SetUniverseSelection(SectorTopUniverse(self, blacklist=MOMENTUM_BLACKLIST))
        self._476_symbols = set()
        self._476_targets = {}

        self.ma          = {}
        self.adx         = {}
        self.close_win   = {}
        self.stretch_ema = {}
        self.band_hist   = {}
        self.stretch_win = {}

        self.current_band_idx = {}
        self.allow_universe   = True
        self.max_stress_level = 0.0
        self.was_risk_off     = False
        self.risk_off_date    = None
        self._risk_off_count  = 0

        # -------- daily tracking state --------
        self._hwm        = 0.0
        self._prev_value = None
        self._daily_rets = deque(maxlen=252)

        self._pending_weights = None
        self._restart_hold    = False

        self.SetWarmUp(300)

        self.Schedule.On(
            self.DateRules.MonthEnd("SPY"),
            self.TimeRules.BeforeMarketClose("SPY", 5),
            self.Rebalance_476)

        self.Schedule.On(
            self.DateRules.EveryDay("SPY"),
            self.TimeRules.BeforeMarketClose("SPY", 1),
            self._DailySnapshot)

    # ========================================================================
    # 504cg live-gate helper
    # ========================================================================
    def _gate_504cg(self, weights):
        """In live mode, zero out any leg whose UCITS equivalent is unverified
        so its weight routes to cash. No-op in backtest. Operates on a
        {Symbol: weight} dict."""
        if not self._live_blocked:
            return weights
        gated = {}
        for sym, w in weights.items():
            us = sym.Value
            if us in self._live_blocked:
                continue   # drop to cash
            gated[sym] = w
        return gated

    # ========================================================================
    # Parameter-driven date window (for IS/OOS split testing)
    # ========================================================================
    def _parse_date_param(self, name, default):
        """Read a 'YYYY-MM-DD' QC parameter; fall back to a (y,m,d) tuple.
        Lets the same algorithm run in-sample / out-of-sample / full-period
        by changing parameters in the QC UI rather than editing code."""
        from datetime import datetime as _dt
        raw = self.GetParameter(name)
        if raw:
            try:
                return _dt.strptime(raw.strip(), "%Y-%m-%d")
            except ValueError:
                self.Log(f"[WINDOW] bad {name}='{raw}', using default {default}")
        return _dt(*default)

    # ========================================================================
    # UCITS map validation — catches mechanically-detectable errors at startup
    # ========================================================================
    def _validate_ucits_map(self):
        """Flag malformed UCITS_MAP entries before any order is routed. Checks
        the structural errors (bad exchange string, missing/blank conId for a
        leg that claims to be verified, missing currency). Does NOT and cannot
        confirm a conId resolves to the intended instrument — that requires TWS.
        Logs warnings rather than raising, so a typo surfaces loudly but the
        backtest (which trades US legs) still runs."""
        problems = []
        for us, v in UCITS_MAP.items():
            if not isinstance(v, tuple) or len(v) != 4:
                problems.append(f"{us}: malformed entry {v!r}")
                continue
            uc, cid, exch, ccy = v
            if cid is not None:   # claims verified
                if exch not in KNOWN_EXCHANGES:
                    problems.append(f"{us}->{uc}: unknown exchange '{exch}' "
                                    f"(known: {sorted(KNOWN_EXCHANGES)})")
                if ccy not in FX_TO_GBP:
                    problems.append(f"{us}->{uc}: currency '{ccy}' has no FX_TO_GBP rate")
                if not isinstance(cid, int) or cid <= 0:
                    problems.append(f"{us}->{uc}: conId '{cid}' not a positive int")
        if problems:
            self.Log(f"[UCITS-VALIDATE] {len(problems)} issue(s) found:")
            for p in problems:
                self.Log(f"  - {p}")
            if self.LiveMode:
                self.Notify.Email(YOUR_EMAIL,
                    f"[WARN] UCITS map validation: {len(problems)} issue(s)",
                    "Malformed UCITS_MAP entries detected at startup:\n\n"
                    + "\n".join(problems)
                    + "\n\nThese are structural checks only; conId correctness "
                      "must still be confirmed in TWS.")
        else:
            self.Log(f"[UCITS-VALIDATE] OK — {len(UCITS_MAP)} legs, "
                     f"exchanges {sorted({v[2] for v in UCITS_MAP.values()})}")

    def _to_gbp(self, value, ccy):
        """Convert a holdings value in `ccy` to GBP for reporting. Falls back to
        the USD rate if the currency is unmapped (with no crash)."""
        return value * FX_TO_GBP.get(ccy, FX_TO_GBP["USD"])

    def _portfolio_gbp(self):
        """Currency-aware GBP value of the whole portfolio for email reporting.
        Each holding is converted by its leg currency; the 476 stock sleeve is
        USD (US-listed). Cash is reported in the account base currency as-is via
        the USD rate (IBKR base is typically USD here)."""
        total = 0.0
        for kvp in self.Portfolio:
            h = kvp.Value
            if not h.Invested:
                continue
            ccy = UCITS_CCY.get(h.Symbol.Value, "USD")
            total += self._to_gbp(h.HoldingsValue, ccy)
        total += self._to_gbp(self.Portfolio.Cash, "USD")
        return total

    # ========================================================================
    # OnSecuritiesChanged — route 504cg ETFs vs 476 stocks
    # ========================================================================
    def OnSecuritiesChanged(self, changes):
        for sec in changes.AddedSecurities:
            sec.SetFeeModel(InteractiveBrokersFeeModel())
            sec.SetSlippageModel(ConstantSlippageModel(0.001))
            s = sec.Symbol
            if s in self._504cg_universe:
                continue
            self._476_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.STRETCH_WIN_LEN)

        for sec in changes.RemovedSecurities:
            s = sec.Symbol
            if s in self._504cg_universe:
                continue
            self._476_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)

    # ========================================================================
    # 504cg sleeve readiness checks
    # ========================================================================
    @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
    # ========================================================================
    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 "PSQ"
        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 sleeve weight methods
    # ========================================================================
    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("VTV",0)>79 or r.get("XLP",0)>75
                or r.get("TQQQ",0)>79 or r.get("XLY",0)>80
                or r.get("SPY",0)>80):
            return {}
        if r.get("TQQQ",50) < 30: return {self._syms["TQQQ"]: 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}
        return {self._syms["TQQQ"]: self.QUARTER/3,
                self._syms["SOXL"]: self.QUARTER/3,
                self._syms["BIL"]:  self.QUARTER/3}

    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
        spy_sma  = self._t11_spy_sma200.Current.Value
        tqqq_sma = self._t11_tqqq_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:
            return {self._syms["BIL"]: self.QUARTER}
        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:
            return {self._syms["TQQQ"]: self.QUARTER * (2/3),
                    self._syms["SOXL"]: self.QUARTER * (1/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:
            return {self._syms["TQQQ"]: self.QUARTER}
        if tqqq_rsi < 31: return {self._syms["TQQQ"]: 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:
                return {}
            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 {}

    # ========================================================================
    # 504cg daily rebalance (called from OnData)
    # ========================================================================
    def _504cg_rebalance(self):
        crash_gross = 1.0
        if self._qqq_window.IsReady:
            qqq_ret = self._qqq_window[0] / self._qqq_window[self.CRASH_LOOKBACK] - 1.0
            new_gross = 1.0
            for threshold, gross in self.CRASH_TIERS:
                if qqq_ret < threshold:
                    new_gross = gross
            crash_gross = new_gross

            prev_gross = self._crash_gross_prev
            if crash_gross != prev_gross:
                direction = 'DE-LEVER' if crash_gross < prev_gross else 'RE-LEVER'
                msg = (f'[CRASH-{direction}] {self.Time.date()} '
                       f'QQQ 10d={qqq_ret*100:.1f}% '
                       f'gross {prev_gross:.0%}->{crash_gross:.0%}')
                self.Log(msg)
                self._crash_events.append({
                    'date': self.Time, 'type': direction,
                    'qqq_ret': qqq_ret, 'gross': crash_gross})
                if self.LiveMode:
                    eq_gbp = self._portfolio_gbp()
                    self.Notify.Email(YOUR_EMAIL,
                        f'[ALERT] Crash Guard {self.Time:%d %b %Y} gross->{crash_gross:.0%}',
                        f'QQQ 10-day return: {qqq_ret*100:.1f}%\n'
                        f'504cg de-levered to {crash_gross:.0%} gross\n'
                        f'Portfolio: GBP{eq_gbp:,.0f}')
            self._crash_gross_prev = crash_gross
            self._in_crash = crash_gross < 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

        # LIVE deployability gate: drop unverified-UCITS legs to cash.
        combined = self._gate_504cg(combined)

        self._504cg_targets    = combined
        self._504cg_last_label = new_label
        return True

    # ========================================================================
    # 476 monthly rebalance (scheduled)
    # ========================================================================
    def Rebalance_476(self):
        if self.IsWarmingUp:
            return
        if not self.Securities[self._us_anchor].Exchange.DateTimeIsOpen(self.Time):
            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 >= self.RISK_OFF_THRESHOLD:
            if not self.was_risk_off:
                self.risk_off_date = self.Time
                self._risk_off_count += 1
                if self.LiveMode:
                    eq_gbp = self._portfolio_gbp()
                    self.Notify.Email(YOUR_EMAIL,
                        f"[ALERT] 476 Risk-Off {self.Time:%d %b %Y}",
                        f"Breadth stress {bottom_frac:.1%} >= {self.RISK_OFF_THRESHOLD:.0%}\n"
                        f"476 sleeve liquidated — 504cg sleeve unaffected\n"
                        f"Portfolio: GBP{eq_gbp:,.0f}")
            self.allow_universe = False
            self.was_risk_off   = True
            self.Debug(f"[476 RISK-OFF] stress={bottom_frac:.1%}")

        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:
                trig = ("60pct" if improvement >= 0.60
                        else "stress<15" if bottom_frac < 0.15 else "180d")
                if self.LiveMode:
                    eq_gbp = self._portfolio_gbp()
                    self.Notify.Email(YOUR_EMAIL,
                        f"[ALERT] 476 Recovery {self.Time:%d %b %Y}",
                        f"Breadth recovered. trigger={trig}\n"
                        f"Re-entering 476 momentum sleeve\n"
                        f"Portfolio: GBP{eq_gbp:,.0f}")
                for s in self._476_symbols:
                    if s in self.band_hist:
                        self.band_hist[s] = RollingWindow[int](self.HIST_LEN)
                    if s in self.stretch_win:
                        self.stretch_win[s] = RollingWindow[float](self.STRETCH_WIN_LEN)
                self.allow_universe   = True
                self.was_risk_off     = False
                self.max_stress_level = 0.0
                self.risk_off_date    = None
                self.Debug(f"[476 RECOVERY] trigger={trig} stress={bottom_frac:.1%}")
        else:
            self.allow_universe = True

        if not self.allow_universe:
            self._476_targets = {}
            self._update_combined()
            return

        hist = self.History(list(self._476_symbols), max(self.LOOKBACKS) + 1, Resolution.Daily)
        if hist.empty:
            return

        closes = hist["close"].unstack(0)
        momentum = {}

        for s in self._476_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_v = self.ma[s].Current.Value
            if price <= ema_v: 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._476_targets = {}
            self._update_combined()
            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_h = max(hist_idx) if hist_idx else idx
            scale = (1.0 if historical_h <= 0
                     else 0.0 if idx >= historical_h
                     else max(0.2, 1.0 - idx / historical_h))

            if self.stretch_win[s].IsReady:
                sw     = list(self.stretch_win[s])
                cur_s  = sw[0]
                peak_s = max(sw)
                if idx >= 10 and peak_s > 0 and cur_s < peak_s * 0.80:
                    scale = min(scale, 0.2)

            scaled[s] = momentum[s] * scale

        if not scaled:
            self._476_targets = {}
            self._update_combined()
            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_476, w) for s, w in raw_weights.items()}
        current_sum    = sum(capped_weights.values())
        if current_sum > 0:
            self._476_targets = {s: w / current_sum for s, w in capped_weights.items()}
        else:
            self._476_targets = {}

        if self._restart_hold:
            equity = self.Portfolio.TotalPortfolioValue
            for pos in self.Portfolio.Values:
                s = pos.Symbol
                if (pos.Invested
                        and s in self._476_symbols
                        and s not in self._476_targets
                        and s not in self._504cg_universe):
                    hold_w = pos.HoldingsValue / equity if equity > 0 else 0.0
                    if hold_w > 0:
                        self._476_targets[s] = hold_w / self.W_476
                        self.Debug(f'[RESTART-HOLD] Preserving {s.Value} w={hold_w:.1%}')
                        if self.LiveMode:
                            self.Log(f'[RESTART-HOLD] Preserving {s.Value} w={hold_w:.1%}')
            self._restart_hold = False
            self.Debug('[RESTART-HOLD] Guard cleared — normal rebalancing resumes next month')
            if self.LiveMode:
                self.Log('[RESTART-HOLD] Guard cleared — normal rebalancing resumes next month')

        self._update_combined()

        stocks_str = ", ".join(s.Value for s in self._476_targets)
        self.Debug(f"[476 REBAL] {self.Time:%Y-%m-%d} stress={bottom_frac:.1%} "
                   f"pos={len(self._476_targets)} stocks={stocks_str}")
        if self.LiveMode:
            self.Log(f"[476 REBAL] {self.Time:%Y-%m-%d} pos={len(self._476_targets)} "
                     f"stocks={stocks_str}")

    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

    # ========================================================================
    # Combined weights aggregator + execution
    # ========================================================================
    def _update_combined(self):
        all_syms = set(self._504cg_targets) | set(self._476_targets)
        combined = {}
        for s in all_syms:
            w = (self.W_504CG * self._504cg_targets.get(s, 0.0)
                 + self.W_476   * self._476_targets.get(s, 0.0))
            if w > 0:
                combined[s] = w
        self._pending_weights = combined

    # ========================================================================
    # OnData
    # ========================================================================
    def OnData(self, data):
        if data.Bars.ContainsKey(self._qqq_signal):
            self._qqq_window.Add(data.Bars[self._qqq_signal].Close)

        if self.IsWarmingUp:
            return

        if self._504cg_rebalance():
            self._update_combined()

        for s in list(self._476_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._pending_weights is not None:
            targets = self._pending_weights
            self._pending_weights = None
            equity = self.Portfolio.TotalPortfolioValue
            excl = {self._syms['SPY']}
            for pos in list(self.Portfolio.Values):
                if pos.Invested and pos.Symbol not in targets and pos.Symbol not in excl:
                    if pos.Quantity != 0:
                        self.MarketOnOpenOrder(pos.Symbol, -pos.Quantity)
            for sym, w in targets.items():
                if w <= 0 or not self.Securities.ContainsKey(sym): continue
                price = self.Securities[sym].Price
                if price <= 0: continue
                target_qty = int(equity * w / price)
                cur   = self.Portfolio[sym].Quantity if self.Portfolio.ContainsKey(sym) else 0
                delta = target_qty - cur
                if delta != 0:
                    drift = abs(delta * price) / equity
                    if drift >= 0.02:
                        self.MarketOnOpenOrder(sym, delta)

    # ========================================================================
    # ObjectStore state persistence — save/restore Fibonacci rolling windows
    # ========================================================================
    def _save_state(self):
        if not self.LiveMode:
            return
        try:
            import json
            state = {
                "band_hist":   {s.Value: list(self.band_hist[s])
                                for s in self.band_hist if self.band_hist[s].Count > 0},
                "stretch_win": {s.Value: list(self.stretch_win[s])
                                for s in self.stretch_win if self.stretch_win[s].Count > 0},
                "close_win":   {s.Value: list(self.close_win[s])
                                for s in self.close_win if self.close_win[s].Count > 0},
                "allow_universe":   self.allow_universe,
                "was_risk_off":     self.was_risk_off,
                "max_stress_level": self.max_stress_level,
                "risk_off_date":    self.risk_off_date.isoformat() if self.risk_off_date else None,
                "hwm":              self._hwm,
                "saved_at":         self.Time.isoformat(),
            }
            self.ObjectStore.Save(STATE_STORE_KEY, json.dumps(state))
            self.Debug(f"[STATE] Saved: {len(state['band_hist'])} band_hist, "
                       f"{len(state['stretch_win'])} stretch_win, "
                       f"{len(state['close_win'])} close_win symbols")
        except Exception as e:
            self.Log(f"[STATE] Save failed: {e}")

    def _restore_state(self):
        if not self.LiveMode:
            return
        if not self.ObjectStore.ContainsKey(STATE_STORE_KEY):
            self.Debug("[STATE] No saved state found — starting fresh")
            return
        try:
            import json
            from datetime import datetime
            raw   = self.ObjectStore.Read(STATE_STORE_KEY)
            state = json.loads(raw)
            saved_at = state.get("saved_at", "unknown")
            self.Debug(f"[STATE] Restoring state saved at {saved_at}")

            sym_by_value = {s.Value: s for s in self._476_symbols}
            restored_band = restored_stretch = restored_close = 0

            for ticker, values in state.get("band_hist", {}).items():
                s = sym_by_value.get(ticker)
                if s is None or s not in self.band_hist:
                    continue
                win = RollingWindow[int](self.HIST_LEN)
                for v in reversed(values):
                    win.Add(int(v))
                self.band_hist[s] = win
                restored_band += 1

            for ticker, values in state.get("stretch_win", {}).items():
                s = sym_by_value.get(ticker)
                if s is None or s not in self.stretch_win:
                    continue
                win = RollingWindow[float](self.STRETCH_WIN_LEN)
                for v in reversed(values):
                    win.Add(float(v))
                self.stretch_win[s] = win
                restored_stretch += 1

            for ticker, values in state.get("close_win", {}).items():
                s = sym_by_value.get(ticker)
                if s is None or s not in self.close_win:
                    continue
                win = RollingWindow[float](self.BAND_LEN)
                for v in reversed(values):
                    win.Add(float(v))
                self.close_win[s] = win
                restored_close += 1

            self.allow_universe   = state.get("allow_universe",   True)
            self.was_risk_off     = state.get("was_risk_off",      False)
            self.max_stress_level = state.get("max_stress_level",  0.0)
            rd = state.get("risk_off_date")
            self.risk_off_date    = datetime.fromisoformat(rd) if rd else None

            saved_hwm = state.get("hwm", 0.0)
            if saved_hwm > self._hwm:
                self._hwm = saved_hwm

            msg = (f"[STATE] Restored: band_hist={restored_band} "
                   f"stretch_win={restored_stretch} close_win={restored_close} "
                   f"regime=({'RISK-OFF' if not self.allow_universe else 'BULL'}) "
                   f"hwm={self._hwm:,.0f}")
            self.Debug(msg)
            self.Log(msg)
            if self.LiveMode:
                self.Notify.Email(YOUR_EMAIL,
                    f"[INIT] State Restored {self.Time:%d %b %Y}",
                    f"Fibonacci state restored from {saved_at}\n"
                    f"band_hist: {restored_band} symbols\n"
                    f"stretch_win: {restored_stretch} symbols\n"
                    f"close_win: {restored_close} symbols\n"
                    f"Regime: {'RISK-OFF' if not self.allow_universe else 'BULL'}")

        except Exception as e:
            self.Log(f"[STATE] Restore failed: {e} — starting fresh")
            self.allow_universe   = True
            self.was_risk_off     = False
            self.max_stress_level = 0.0
            self.risk_off_date    = None

    # ========================================================================
    # OnWarmupFinished — HWM init + orphan 476 position adoption + state restore
    # ========================================================================
    def OnWarmupFinished(self):
        eq = self.Portfolio.TotalPortfolioValue
        self._hwm        = eq
        self._prev_value = eq

        msg = "[INIT] Warmup complete"
        self.Debug(msg)
        if self.LiveMode: self.Log(msg)

        if self.Portfolio.TotalHoldingsValue != 0:
            inv_476 = sum(1 for s in self._476_symbols
                          if self.Portfolio.ContainsKey(s) and self.Portfolio[s].Invested)
            if inv_476 >= 5:
                self.allow_universe = True
                msg = f"[INIT] {inv_476} 476 positions found — inferring BULL mode"
                self.Debug(msg)
                if self.LiveMode: self.Log(msg)

            managed = self._476_symbols | self._504cg_universe
            orphans = [k.Key for k in self.Portfolio
                       if k.Value.Invested and k.Key not in managed]
            for sym in orphans:
                self._476_symbols.add(sym)
                if sym not in self.ma:
                    self.ma[sym]          = self.EMA(sym, self.BAND_LEN, Resolution.Daily)
                    self.adx[sym]         = self.ADX(sym, self.ADX_PERIOD, Resolution.Daily)
                    self.stretch_ema[sym] = self.EMA(sym, self.BAND_LEN, Resolution.Daily)
                    self.close_win[sym]   = RollingWindow[float](self.BAND_LEN)
                    self.stretch_win[sym] = RollingWindow[float](self.STRETCH_WIN_LEN)
                    self.band_hist[sym]   = RollingWindow[int](self.HIST_LEN)
                    if self.LiveMode:
                        try:
                            self.WarmUpIndicator(sym, self.ma[sym],          Resolution.Daily)
                            self.WarmUpIndicator(sym, self.adx[sym],         Resolution.Daily)
                            self.WarmUpIndicator(sym, self.stretch_ema[sym], Resolution.Daily)
                        except: pass
                msg = f"[INIT] Adopted orphan: {sym.Value}"
                self.Debug(msg)
                if self.LiveMode: self.Log(msg)

        if self.Portfolio.TotalHoldingsValue > 0:
            self._restart_hold = True
            msg = "[INIT] Restart guard ARMED — existing holdings preserved on first rebalance"
            self.Debug(msg)
            if self.LiveMode: self.Log(msg)

        self._restore_state()

        self.Debug(f"[INIT] 476_symbols={len(self._476_symbols)} "
                   f"504cg_universe={len(self._504cg_universe)} "
                   f"invested={sum(1 for k in self.Portfolio if k.Value.Invested)} "
                   f"allow_476={self.allow_universe} crash_guard={self._in_crash}")

    # ========================================================================
    # _DailySnapshot — EOD email with P&L, positions, rolling Sharpe
    # ========================================================================
    def _DailySnapshot(self):
        if self.IsWarmingUp: return
        if not self.Securities[self._us_anchor].Exchange.DateTimeIsOpen(self.Time): return

        eq = self.Portfolio.TotalPortfolioValue
        self._hwm = max(self._hwm, eq)
        dd = (eq - self._hwm) / self._hwm if self._hwm > 0 else 0.0
        dr = (eq - self._prev_value) / self._prev_value if self._prev_value else 0.0
        self._prev_value = eq
        self._daily_rets.append(dr)

        sh = ""
        if len(self._daily_rets) >= 20:
            r   = np.array(self._daily_rets)
            sig = np.std(r) * np.sqrt(252)
            sh  = f" Sh={np.mean(r)*252/sig if sig>0 else 0:+.2f}"

        mode_476  = "BULL" if self.allow_universe else "RISK-OFF"
        crash_tag = "|CRASH-GUARD" if self._in_crash else ""
        self.Debug(f"[SNAP] {self.Time:%Y-%m-%d} Eq={eq:,.0f} DD={dd:.2%} "
                   f"D={dr:+.2%}{sh} [{mode_476}{crash_tag}] "
                   f"Cash={self.Portfolio.Cash/eq:.1%}")

        if not self.LiveMode: return

        # Per-position GBP value converted by each leg's currency (USD for the
        # 476 stock sleeve, leg currency for 504cg ETFs).
        positions = sorted(
            [(k.Key.Value,
              self._to_gbp(k.Value.HoldingsValue, UCITS_CCY.get(k.Key.Value, "USD")),
              k.Value.UnrealizedProfitPercent)
             for k in self.Portfolio if k.Value.Invested],
            key=lambda x: -x[1])
        pos_lines = "\n".join(
            f"  {s:<8} GBP {v_gbp:>8,.0f}  {p:>+.1%}"
            for s, v_gbp, p in positions)

        eq_gbp = self._portfolio_gbp()
        subject = (f"{'[UP]' if dr>=0 else '[DN]'} EOD {self.Time:%d %b %Y} "
                   f"{dr:+.2%} GBP{eq_gbp:,.0f} [{mode_476}{crash_tag}]")
        body = (f"Mode: 476={mode_476}  504cg={'CRASH-GUARD' if self._in_crash else 'FULL'}\n"
                f"Portfolio: GBP{eq_gbp:,.0f}\n"
                f"Day: {dr:+.2%}  DD: {dd:+.2%}{sh}\n\n"
                f"Positions ({len(positions)}):\n{pos_lines}")
        self.Notify.Email(YOUR_EMAIL, subject, body)

        self._save_state()

    # ========================================================================
    # OnEndOfAlgorithm — final summary
    # ========================================================================
    def OnEndOfAlgorithm(self):
        eq = self.Portfolio.TotalPortfolioValue
        self.Debug(f"[END] Eq={eq:,.2f} Ret={(eq/100_000-1)*100:+.2f}%")
        self.Debug(f"[END] 476 risk-off periods: {self._risk_off_count}")
        self.Debug(f"[END] 504cg crash events: {len(self._crash_events)}")
        for e in self._crash_events:
            self.Debug(f"  [{e['type']}] {e['date']:%Y-%m-%d} QQQ_10d={e['qqq_ret']*100:.1f}%")
"""
Three-Sleeve Hybrid Strategy — v1.3.8
══════════════════════════════════════
MODE LOGIC — binary switch:
  S3+S1 bull (strong bull): S3=80% momentum, S1=20% fixed hedge (75/25 BRK.B/NEM), S2=0%
  S1+S2  (not bull)       : S1=40-100% BRK.B/NEM, S2=0-40% equity, S3=0%

Strong bull gate — ALL five required:
  1. SPY > 200-day SMA    4. VIX < 80th-pct (300-bar window)
  2. SPY > 50-day SMA     5. VIX < 25 (hard ceiling)
  3. SPY 20-day return > 0

Regime (S1+S2 mode): R1/R4/R6 stress -> S2 off; R2/R3/R5 calm -> S2 on

Schedules (anchored to SPY/NYSE in both live and backtest):
  CheckSignal  : Daily      BMC-120  (~14:00 ET / 19:00 London)
  TrainModel   : MonthStart BMC-150
  RebalanceS2  : MonthStart BMC-90
  RebalanceS3  : MonthEnd   BMC-30
  DailySnapshot: Daily      BMC-1

Live instruments (UK — no PRIIPs issues, all US individual stocks):
  S1 hedge : BRK.B (Berkshire B — S&P proxy, ~0.95 correlation)
             NEM   (Newmont Mining — gold proxy, ~0.80 gold correlation)
  S2/S3    : US-listed equities (value+momentum / large-cap momentum)
  Signals  : SPY/GLD/HYG/LQD/IEF/SHY (read-only, never traded in live)

Log tags: [GATE] [SWITCH] [STATE] [S1] [S2] [S3] [SNAP] [INIT] [END]
"""

from AlgorithmImports import *
from collections import defaultdict, deque
import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.preprocessing import StandardScaler

LABEL_HORIZON  = 21
SAFETY_BUFFER  = 10
TRAIN_VAL_GAP  = 126
MIN_TRAIN_ROWS = 100
ML_THRESHOLD   = 0.65
MIN_VIX_BARS   = 50
MIN_SPY_BARS   = 260
MIN_AUX_BARS   = 60
DIP_DEEP_THRESHOLD   = -0.08
DIP_SHALLOW_SPY_W    = 0.60
DIP_SHALLOW_SPY_W_ML = 0.75
DIP_DEEP_SPY_W       = 0.85
DIP_DEEP_SPY_W_ML    = 1.00
S3_BULL_BUDGET    = 0.80   # S3 allocation in strong bull mode
S1_BULL_BUDGET    = 0.20   # S1 macro hedge in strong bull mode
S1_BULL_SPY_FRAC  = 0.75   # fixed SPY fraction of S1 hedge in bull mode
S1_BULL_GLD_FRAC  = 0.25   # fixed GLD fraction of S1 hedge in bull mode


class ThreeSleeveHybrid(QCAlgorithm):

    def Initialize(self):
        self.SetStartDate(2023, 1, 1)
        self.SetEndDate(2025, 1, 1)
        self.SetCash(100_000)
        self.SetBrokerageModel(BrokerageName.InteractiveBrokersBrokerage, AccountType.Margin)

        if self.LiveMode:
            # UK PRIIPs: use US-listed individual stocks as S1 hedge — no restrictions:
            #   BRK.B (Berkshire B) — broad market proxy, ~0.95 S&P correlation
            #   NEM  (Newmont)  — largest gold miner, ~0.80 gold price correlation
            spy_ticker, gld_ticker = "BRK.B", "NEM"
            hyg_ticker, lqd_ticker = "HYG",  "LQD"
            ief_ticker, shy_ticker = "IEF",  "SHY"
            use_rsp = False
        else:
            spy_ticker, gld_ticker = "SPY",  "GLD"
            hyg_ticker, lqd_ticker = "HYG",  "LQD"
            ief_ticker, shy_ticker = "IEF",  "SHY"
            use_rsp = True

        self.spy = self.AddEquity(spy_ticker, Resolution.Daily).Symbol
        self.gld = self.AddEquity(gld_ticker, Resolution.Daily).Symbol
        self.vix   = self.AddData(CBOE, "VIX",   Resolution.Daily).Symbol
        self.vix3m = self.AddData(CBOE, "VIX3M", Resolution.Daily).Symbol
        self.hyg   = self.AddEquity(hyg_ticker, Resolution.Daily).Symbol
        self.lqd   = self.AddEquity(lqd_ticker, Resolution.Daily).Symbol
        self.rsp   = self.AddEquity("RSP", Resolution.Daily).Symbol if use_rsp else None
        self.ief   = self.AddEquity(ief_ticker, Resolution.Daily).Symbol
        self.shy   = self.AddEquity(shy_ticker, Resolution.Daily).Symbol

        if self.LiveMode:
            # Regime signals always use SPY/GLD history (read-only, never traded)
            self.spy_hist = self.AddEquity("SPY", Resolution.Daily).Symbol
            self.gld_hist = self.AddEquity("GLD", Resolution.Daily).Symbol
            self.hyg_hist = self.hyg
            self.lqd_hist = self.lqd
            self.ief_hist = self.ief
            self.shy_hist = self.shy
            # S1 hedge executes via BRK/B and NEM — fully automated, no PRIIPs issues
            self.spy_hedge = self.spy   # BRK/B
            self.gld_hedge = self.gld   # NEM
            self.Log("[INIT] Live S1 hedge: BRK.B (market proxy) + NEM (gold proxy)")
        else:
            self.spy_hist = self.spy;  self.gld_hist = self.gld
            self.hyg_hist = self.hyg;  self.lqd_hist = self.lqd
            self.ief_hist = self.ief;  self.shy_hist = self.shy
            self.spy_hedge = self.spy   # SPY in backtest
            self.gld_hedge = self.gld   # GLD in backtest

        self.SetBenchmark("SPY")   # SPY always available as benchmark read-only
        self.Log(f"[INIT] mode={'Live' if self.LiveMode else 'Backtest'} spy={spy_ticker}")

        self.model   = RandomForestClassifier(n_estimators=200, max_depth=6,
                                              min_samples_leaf=20, random_state=42)
        self.scaler  = StandardScaler()
        self.trained = False

        self.s1_spy_weight    = 0.0
        self.s1_gld_weight    = 0.0
        self._sleeves_active  = True
        self._s3_bull_market  = False
        self.s2_sleeve_budget = 0.0
        self._initial_deploy_done = False

        self.S2_MAX_POSITION_WEIGHT = 0.20
        self.S2_MAX_POSITIONS       = 10
        self.S2_MIN_HISTORY_DAYS    = 5
        self.S2_MOMENTUM_LOOKBACK   = 63
        self.S2_MOMENTUM_MIN_RETURN = 0.0
        self._s2_candidates: set  = set()
        self._s2_added_date: dict = {}
        self._s2_momentum:   dict = {}

        self._s3_candidates: set  = set()
        self.s3_lookbacks         = [21, 63, 126, 189, 252]
        self.s3_stock_count       = 10
        self.s3_band_len          = 189
        self.s3_hist_len          = 126
        self.s3_adx_limit         = 35
        self.s3_adx_period        = 14
        self.s3_rebal_threshold   = 0.015
        self.s3_symbols     = set()
        self.s3_ma          = {}
        self.s3_adx         = {}
        self.s3_close_win   = {}
        self.s3_stretch_ema = {}
        self.s3_band_hist   = {}
        self.s3_stretch_win = {}
        self.s3_band_idx    = {}
        self.s3_BOTTOM_LEVELS = {0, 1, 2, 3, 4}
        self.s3_allow         = True
        self.s3_was_risk_off  = False
        self.s3_risk_off_date = None
        self.s3_max_stress    = 0.0

        self._hwm        = 0.0
        self._prev_value = None
        self._daily_rets = deque(maxlen=252)

        self.UniverseSettings.Resolution            = Resolution.Daily
        self.UniverseSettings.DataNormalizationMode = DataNormalizationMode.Adjusted
        self.UniverseSettings.FillDataBeforeStart   = True
        self._universe_blacklist = {"GME", "AMC"}
        self.AddUniverse(self.MergedUniverseSelection)

        # Schedule anchor: spy_hist = SPY in both live and backtest.
        # SPY NYSE hours ensure CheckSignal fires during US market session.
        # In live: BMC-120 = ~14:00 ET = 19:00 London (US market open, orders fill same day).
        anchor = self.spy_hist
        self.Schedule.On(self.DateRules.MonthStart(anchor),
                         self.TimeRules.BeforeMarketClose(anchor, 150), self.TrainModel)
        self.Schedule.On(self.DateRules.EveryDay(anchor),
                         self.TimeRules.BeforeMarketClose(anchor, 120), self.CheckSignal)
        self.Schedule.On(self.DateRules.MonthStart(anchor),
                         self.TimeRules.BeforeMarketClose(anchor, 90), self.RebalanceSleeve2)
        self.Schedule.On(self.DateRules.MonthEnd(anchor),
                         self.TimeRules.BeforeMarketClose(anchor, 30), self.RebalanceSleeve3)
        self.Schedule.On(self.DateRules.EveryDay(anchor),
                         self.TimeRules.BeforeMarketClose(anchor, 1), self._DailySnapshot)
        self.Log(f"[INIT] Schedule anchor=SPY/NYSE BMC-120 (~14:00 ET)")
        self.SetWarmUp(300)

    # ── Logging helpers ───────────────────────────────────────────────────────

    def _log_gate(self, spy, sma50, sma200, ret20, vix, vix80, bull):
        def t(v): return "PASS" if v else "FAIL"
        self.Log(
            f"[GATE] {self.Time:%Y-%m-%d} "
            f"C1(>200MA):{t(spy>sma200)} C2(>50MA):{t(spy>sma50)} "
            f"C3(20d>0):{t(ret20>0)}({ret20:+.2%}) "
            f"C4(VIX<80pct):{t(vix<vix80)}({vix:.1f}<{vix80:.1f}) "
            f"C5(VIX<25):{t(vix<25)} => {'BULL' if bull else 'NOT_BULL'}"
        )

    def _log_state(self, tag):
        eq = self.Portfolio.TotalPortfolioValue
        if eq <= 0: return
        macro = {self.spy_hedge, self.gld_hedge}
        # Exclusive buckets: S1 first, then S2, then S3 for anything not already claimed.
        # Dual-listed stocks (in both _s2_candidates and s3_symbols) are counted once in S2.
        s1k = {kvp.Key for kvp in self.Portfolio if kvp.Value.Invested and kvp.Key in macro}
        s2k = {kvp.Key for kvp in self.Portfolio if kvp.Value.Invested and kvp.Key in self._s2_candidates}
        s3k = {kvp.Key for kvp in self.Portfolio if kvp.Value.Invested
               and kvp.Key in self.s3_symbols and kvp.Key not in s2k}
        s1v = sum(self.Portfolio[k].HoldingsValue for k in s1k)
        s2v = sum(self.Portfolio[k].HoldingsValue for k in s2k)
        s3v = sum(self.Portfolio[k].HoldingsValue for k in s3k)
        self.Log(
            f"[STATE] {tag} {self.Time:%Y-%m-%d} Eq={eq:,.0f} "
            f"S1={s1v/eq:.1%} S2={s2v/eq:.1%} S3={s3v/eq:.1%} Cash={self.Portfolio.Cash/eq:.1%}"
        )
        if s1k: self.Log(f"[STATE]  S1: {' '.join(f'{k.Value}({self.Portfolio[k].HoldingsValue/eq:.1%})' for k in s1k)}")
        if s2k: self.Log(f"[STATE]  S2: {' '.join(f'{k.Value}({self.Portfolio[k].HoldingsValue/eq:.1%})' for k in s2k)}")
        if s3k: self.Log(f"[STATE]  S3: {' '.join(f'{k.Value}({self.Portfolio[k].HoldingsValue/eq:.1%})' for k in s3k)}")

    def _log_budgets(self, tag):
        self.Log(
            f"[STATE] budgets/{tag} mode={'S3+S1' if self._s3_bull_market else 'S1+S2'} "
            f"spy={self.s1_spy_weight:.3f} gld={self.s1_gld_weight:.3f} "
            f"S2={self.s2_sleeve_budget:.3f} "
            f"S3={S3_BULL_BUDGET if self._s3_bull_market else 0.0:.3f} "
            f"S2active={self._sleeves_active}"
        )

    # ── Universe ──────────────────────────────────────────────────────────────

    def _uni_get_float(self, f, paths):
        for p in paths:
            try:
                obj = f
                for part in p.split('.'): obj = getattr(obj, part)
                if isinstance(obj, (float,int)) and np.isfinite(obj): return float(obj)
                if hasattr(obj,'Value'):
                    val = obj.Value
                    if isinstance(val,(float,int)) and np.isfinite(val): return float(val)
                val = float(obj)
                if np.isfinite(val): return val
            except: continue
        return float('nan')

    def _uni_is_finite(self, v):
        try: return v is not None and np.isfinite(float(v))
        except: return False

    def MergedUniverseSelection(self, fundamentals):
        s2_candidates = []
        s3_buckets    = defaultdict(list)
        for f in fundamentals:
            if not f.has_fundamental_data: continue
            if f.symbol.Value in self._universe_blacklist: continue
            exchange = f.company_reference.primary_exchange_id
            price    = f.price
            mktcap   = f.market_cap
            if (exchange in ("NYS","NAS","ASE") and price and price > 5
                    and mktcap and mktcap >= 5_000_000_000
                    and getattr(f,'DollarVolume',0) >= 50_000_000):   # $50M ADV floor
                sector = f.asset_classification.morningstar_sector_code
                if sector: s3_buckets[sector].append(f)
            if not price or price <= 5: continue
            if getattr(f,'DollarVolume',0) <= 10_000_000: continue
            pe  = self._uni_get_float(f,["ValuationRatios.PERatio","ValuationRatios.PriceEarningsRatio"])
            dte = self._uni_get_float(f,["OperationRatios.DebtToEquity","OperationRatios.TotalDebtEquityRatio"])
            dy  = self._uni_get_float(f,["ValuationRatios.TrailingDividendYield","ValuationRatios.ForwardDividendYield"])
            roi = self._uni_get_float(f,["OperationRatios.ROIC","ProfitabilityRatios.ROIC",
                                         "ProfitabilityRatios.ReturnOnInvestedCapital",
                                         "ProfitabilityRatios.ReturnOnInvestment"])
            if not all(self._uni_is_finite(v) for v in [pe,dte,dy,roi]): continue
            if pe<5 or pe>18 or dte>=1.0 or dy<=0.01 or roi<=0.12: continue
            s2_candidates.append((f.symbol, float(roi)))
        s2_sym = [x[0] for x in sorted(s2_candidates,key=lambda x:x[1],reverse=True)[:20]]
        s3_sym = []
        for _,stocks in s3_buckets.items():
            stocks.sort(key=lambda x:x.market_cap,reverse=True)
            s3_sym.extend(s.symbol for s in stocks[:100])
        self._s2_candidates = set(s2_sym)
        self._s3_candidates = set(s3_sym)
        self.Log(f"[STATE] Universe S2={len(s2_sym)} S3={len(s3_sym)} union={len(set(s2_sym)|set(s3_sym))}")
        return list(set(s2_sym)|set(s3_sym))

    def OnSecuritiesChanged(self, changes: SecurityChanges):
        macro = {self.spy,self.gld,self.hyg,self.lqd,self.ief,self.shy}
        if self.rsp: macro.add(self.rsp)
        added_s2,added_s3,rem_s2,rem_s3 = [],[],[],[]
        for sec in changes.RemovedSecurities:
            s = sec.Symbol
            if s in macro: continue
            self._s2_momentum.pop(s,None); self._s2_added_date.pop(s,None)
            if s in self._s2_candidates: rem_s2.append(s.Value)
            self.s3_symbols.discard(s)
            for d in [self.s3_ma,self.s3_adx,self.s3_stretch_ema,self.s3_close_win,
                      self.s3_band_hist,self.s3_band_idx,self.s3_stretch_win]: d.pop(s,None)
            if s in self._s3_candidates: rem_s3.append(s.Value)
        for sec in changes.AddedSecurities:
            s = sec.Symbol
            if s in macro: continue
            sec.SetFeeModel(InteractiveBrokersFeeModel())
            self._s2_added_date[s] = self.Time
            self._s2_momentum[s]   = self.ROC(s,self.S2_MOMENTUM_LOOKBACK,Resolution.Daily)
            if s in self._s2_candidates: added_s2.append(s.Value)
            if s in self._s3_candidates:
                self.s3_symbols.add(s)
                self.s3_ma[s]          = self.EMA(s,self.s3_band_len,Resolution.Daily)
                self.s3_adx[s]         = self.ADX(s,self.s3_adx_period,Resolution.Daily)
                self.s3_stretch_ema[s] = self.EMA(s,self.s3_band_len,Resolution.Daily)
                self.s3_close_win[s]   = RollingWindow[float](self.s3_band_len)
                self.s3_band_hist[s]   = RollingWindow[int](self.s3_hist_len)
                self.s3_stretch_win[s] = RollingWindow[float](self.s3_hist_len)
                added_s3.append(s.Value)
        if added_s2 or rem_s2:
            self.Log(f"[STATE] UniChange S2 +{len(added_s2)}/-{len(rem_s2)} pool={len(self._s2_candidates)}")
        if added_s3 or rem_s3:
            self.Log(f"[STATE] UniChange S3 +{len(added_s3)}/-{len(rem_s3)} active={len(self.s3_symbols)}")

    # ── OnData ────────────────────────────────────────────────────────────────

    def OnData(self, data: Slice):
        for s in list(self.s3_symbols):
            if not data.ContainsKey(s): continue
            bar = data[s]
            if bar is None: continue
            close = bar.Close
            self.s3_close_win[s].Add(close)
            if not self.s3_close_win[s].IsReady or not self.s3_ma[s].IsReady: continue
            dev = np.std(list(self.s3_close_win[s]))
            if dev <= 0: continue
            mid     = self.s3_ma[s].Current.Value
            stretch = abs(close - mid) / dev
            self.s3_stretch_ema[s].Update(self.Time, stretch)
            self.s3_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.s3_band_idx[s] = self._s3_band_index(close, bands)

    def _s3_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

    # ── Daily snapshot ────────────────────────────────────────────────────────

    def _DailySnapshot(self):
        if self.IsWarmingUp: return
        eq        = self.Portfolio.TotalPortfolioValue
        self._hwm = max(self._hwm, eq)
        dd        = (eq-self._hwm)/self._hwm if self._hwm>0 else 0.0
        dr        = (eq-self._prev_value)/self._prev_value if self._prev_value else 0.0
        self._prev_value = eq
        self._daily_rets.append(dr)
        sh = ""
        if len(self._daily_rets) >= 20:
            r = np.array(self._daily_rets)
            sig = np.std(r)*np.sqrt(252)
            sh = f" Sh={np.mean(r)*252/sig if sig>0 else 0:+.2f}"
        macro = {self.spy_hedge, self.gld_hedge}
        s1v = sum(kvp.Value.HoldingsValue for kvp in self.Portfolio if kvp.Value.Invested and kvp.Key in macro)
        s2v = sum(kvp.Value.HoldingsValue for kvp in self.Portfolio if kvp.Value.Invested and kvp.Key in self._s2_candidates)
        s3v = sum(kvp.Value.HoldingsValue for kvp in self.Portfolio if kvp.Value.Invested and kvp.Key in self.s3_symbols)
        mode = "S3+S1  " if self._s3_bull_market else "S1+S2  "
        self.Log(
            f"[SNAP] {self.Time:%Y-%m-%d} Eq={eq:,.0f} DD={dd:.2%} D={dr:+.2%}{sh} "
            f"[{mode}] S1={s1v/eq:.1%} S2={s2v/eq:.1%} S3={s3v/eq:.1%} Cash={self.Portfolio.Cash/eq:.1%}"
        )

    # ── History helpers ───────────────────────────────────────────────────────

    def _extract_closes(self, df, symbol):
        if df is None or df.empty: return None
        if isinstance(df.index, pd.MultiIndex):
            for key in (symbol, symbol.Value if hasattr(symbol,'Value') else None):
                if key is None: continue
                try:
                    c = df.xs(key,level=0)['close'].values
                    if len(c)>0: return c
                except: pass
        if 'close' in df.columns:
            c = df['close'].values
            if len(c)>0: return c
        return None

    def _get_closes(self, symbol, n_bars, is_custom=False):
        nm = symbol.Value if hasattr(symbol,'Value') else str(symbol)
        try:
            if is_custom:
                df = self.History(CBOE, symbol, self.Time-timedelta(days=n_bars*2), self.Time, Resolution.Daily)
                c  = self._extract_closes(df, symbol)
                if c is not None: return c
                self.Log(f"_get_closes CBOE [{nm}]: empty"); return None
            df = self.History([symbol], n_bars, Resolution.Daily)
            c  = self._extract_closes(df, symbol)
            if c is not None: return c
            df = self.History([symbol], self.Time-timedelta(days=n_bars*2), self.Time, Resolution.Daily)
            c  = self._extract_closes(df, symbol)
            if c is not None: return c
            self.Log(f"_get_closes [{nm}]: both attempts empty"); return None
        except Exception as e:
            self.Log(f"_get_closes error [{nm}]: {e}"); return None

    def _get_cboe_closes(self, symbol, days=4000, min_bars=1):
        nm = symbol.Value if hasattr(symbol,'Value') else str(symbol)
        for att, mult in enumerate((1,2), start=1):
            try:
                df = self.History(CBOE, symbol, self.Time-timedelta(days=days*mult), self.Time, Resolution.Daily)
                if df is None or df.empty: self.Log(f"_get_cboe [{nm}]: empty att={att}"); continue
                c = df['close'].values
                if len(c)>=min_bars: return c
            except Exception as e:
                self.Log(f"_get_cboe error [{nm}]: {e}"); return None
        self.Log(f"_get_cboe [{nm}]: failed"); return None

    # ── Sleeve 1 features & training ─────────────────────────────────────────

    def GetFeatures(self, vix_c, spy_c, vix3m_closes=None, hyg_closes=None,
                    lqd_closes=None, rsp_closes=None, ief_closes=None, shy_closes=None):
        if len(vix_c)<MIN_VIX_BARS or len(spy_c)<MIN_SPY_BARS: return None
        try:
            cv=vix_c[-1]; sc=spy_c[-1]
            vs20=np.mean(vix_c[-20:]); vs50=np.mean(vix_c[-50:]); vstd=np.std(vix_c[-20:])
            vz=(cv-vs20)/vstd if vstd>0 else 0.0; vpr=float(np.sum(vix_c<cv))/len(vix_c)
            ss50=np.mean(spy_c[-50:]); ss200=np.mean(spy_c[-200:])
            s5=spy_c[-1]/spy_c[-5]-1; s10=spy_c[-1]/spy_c[-10]-1; s20=spy_c[-1]/spy_c[-20]-1
            svol=np.std(np.diff(spy_c[-21:])/spy_c[-21:-1])
            s60=spy_c[-1]/spy_c[-60]-1; s120=spy_c[-1]/spy_c[-120]-1; s252=spy_c[-1]/spy_c[-252]-1
            vtr=vt5=0.0
            if vix3m_closes is not None and len(vix3m_closes)>=5 and vix3m_closes[-1]>0:
                vtr=cv/vix3m_closes[-1]; vt5=(cv/vix_c[-5])-(vix3m_closes[-1]/vix3m_closes[-5])
            cr=c5=c20=0.0
            if (hyg_closes is not None and lqd_closes is not None
                    and len(hyg_closes)>=MIN_AUX_BARS and len(lqd_closes)>=MIN_AUX_BARS and lqd_closes[-1]>0):
                cr=hyg_closes[-1]/lqd_closes[-1]
                c5=(hyg_closes[-1]/hyg_closes[-5])-(lqd_closes[-1]/lqd_closes[-5])
                c20=(hyg_closes[-1]/hyg_closes[-20])-(lqd_closes[-1]/lqd_closes[-20])
            br=b5=b20=0.0
            if rsp_closes is not None and len(rsp_closes)>=MIN_AUX_BARS and sc>0:
                br=rsp_closes[-1]/sc
                b5=(rsp_closes[-1]/rsp_closes[-5])-(spy_c[-1]/spy_c[-5])
                b20=(rsp_closes[-1]/rsp_closes[-20])-(spy_c[-1]/spy_c[-20])
            cu20=cu60=0.0
            if (ief_closes is not None and shy_closes is not None
                    and len(ief_closes)>=MIN_AUX_BARS and len(shy_closes)>=MIN_AUX_BARS):
                cu20=(ief_closes[-1]/ief_closes[-20])-(shy_closes[-1]/shy_closes[-20])
                cu60=(ief_closes[-1]/ief_closes[-60])-(shy_closes[-1]/shy_closes[-60])
            return [cv,vz,vpr,cv/vs20,cv/vs50,s5,s10,s20,sc/ss50,sc/ss200,
                    svol*np.sqrt(252),s60,s120,s252,vtr,vt5,cr,c5,c20,br,b5,b20,cu20,cu60]
        except Exception as e:
            self.Log(f"GetFeatures error: {e}"); return None

    def TrainModel(self):
        if self.IsWarmingUp: return
        try: self._TrainModelInner()
        except Exception as e: self.Log(f"[S1] TrainModel error: {e}")

    def _TrainModelInner(self):
        vix_c=self._get_cboe_closes(self.vix,4000,MIN_VIX_BARS)
        spy_c=self._get_closes(self.spy_hist,4000)
        if vix_c is None or spy_c is None: self.Log("[S1] TrainModel: missing history"); return
        self.Log(f"[S1] TrainModel SPY {spy_c[0]:.2f}->{spy_c[-1]:.2f} bars={len(spy_c)}")
        vix3m_c=self._get_cboe_closes(self.vix3m,4000,5)
        hyg_c=self._get_closes(self.hyg_hist,4000); lqd_c=self._get_closes(self.lqd_hist,4000)
        rsp_c=self._get_closes(self.rsp,4000) if self.rsp else None
        ief_c=self._get_closes(self.ief_hist,4000); shy_c=self._get_closes(self.shy_hist,4000)
        lc=len(spy_c)-LABEL_HORIZON-SAFETY_BUFFER
        if lc<MIN_SPY_BARS+MIN_TRAIN_ROWS: self.Log("[S1] TrainModel: insufficient data"); return
        te=lc-TRAIN_VAL_GAP
        if te-MIN_SPY_BARS<MIN_TRAIN_ROWS: self.Log("[S1] TrainModel: window too small"); return
        idx=list(range(MIN_SPY_BARS,lc))
        fr=[spy_c[i+LABEL_HORIZON]/spy_c[i]-1 for i in idx]; med=np.median(fr)
        Xa,ya=[],[]
        for ii,i in enumerate(idx):
            ft=self.GetFeatures(vix_c[:i],spy_c[:i],
                vix3m_closes=vix3m_c[:i] if vix3m_c is not None else None,
                hyg_closes=hyg_c[:i] if hyg_c is not None else None,
                lqd_closes=lqd_c[:i] if lqd_c is not None else None,
                rsp_closes=rsp_c[:i] if rsp_c is not None else None,
                ief_closes=ief_c[:i] if ief_c is not None else None,
                shy_closes=shy_c[:i] if shy_c is not None else None)
            if ft is not None: Xa.append(ft); ya.append(1 if fr[ii]>med else 0)
        if len(Xa)<MIN_TRAIN_ROWS+20: self.Log(f"[S1] TrainModel: too few samples ({len(Xa)})"); return
        Xa=np.array(Xa); ya=np.array(ya); r1=float(np.mean(ya))
        if r1>0.95 or r1<0.05: self.Log(f"[S1] TrainModel: degenerate ({r1:.3f})"); self.trained=False; return
        sp=te-MIN_SPY_BARS
        Xtr,ytr=Xa[:sp],ya[:sp]; Xva,yva=Xa[sp:],ya[sp:]
        if len(Xtr)<MIN_TRAIN_ROWS: self.Log("[S1] TrainModel: not enough rows"); return
        self.scaler.fit(Xtr); self.model.fit(self.scaler.transform(Xtr),ytr); self.trained=True
        if len(Xva)>0:
            acc=self.model.score(self.scaler.transform(Xva),yva)
            self.Log(f"[S1] TrainModel acc={acc:.3f} base={np.mean(yva):.3f} edge={acc-np.mean(yva):+.3f}")
        names=["vix_level","vix_zscore","vix_pct_rank","vix_vs_sma20","vix_vs_sma50",
               "spy_5d","spy_10d","spy_20d","spy_vs_sma50","spy_vs_sma200","spy_vol",
               "spy_60d","spy_120d","spy_252d","vix_term_ratio","vix_term_5d",
               "credit_ratio","credit_5d","credit_20d","breadth_ratio","breadth_5d",
               "breadth_20d","curve_20d","curve_60d"]
        top=sorted(zip(names,self.model.feature_importances_),key=lambda x:-x[1])[:5]
        self.Log("[S1] Features: "+" | ".join(f"{n}={v:.3f}" for n,v in top))

    # ── CheckSignal ───────────────────────────────────────────────────────────

    def CheckSignal(self):
        if self.IsWarmingUp: return
        try: self._CheckSignalInner()
        except Exception as e: self.Log(f"[S1] CheckSignal error: {e}")

    def _CheckSignalInner(self):
        spy_c=self._get_closes(self.spy_hist,270)
        vix_c=self._get_closes(self.vix,300,is_custom=True)
        if spy_c is None or vix_c is None: self.Log("[S1] CheckSignal: missing history"); return
        if len(vix_c)<MIN_VIX_BARS or len(spy_c)<MIN_SPY_BARS:
            self.Log(f"[S1] CheckSignal: bars vix={len(vix_c)} spy={len(spy_c)}"); return

        vix3m_c=self._get_closes(self.vix3m,10,is_custom=True)
        hyg_c=self._get_closes(self.hyg_hist,MIN_AUX_BARS)
        lqd_c=self._get_closes(self.lqd_hist,MIN_AUX_BARS)
        rsp_c=self._get_closes(self.rsp,MIN_AUX_BARS) if self.rsp else None
        ief_c=self._get_closes(self.ief_hist,MIN_AUX_BARS)
        shy_c=self._get_closes(self.shy_hist,MIN_AUX_BARS)

        cv=vix_c[-1]; vsma=np.mean(vix_c[-20:]); v80=np.percentile(vix_c,80)
        sc=spy_c[-1]; s50=np.mean(spy_c[-50:]); s200=np.mean(spy_c[-200:])
        r5=spy_c[-1]/spy_c[-5]-1; r10=spy_c[-1]/spy_c[-10]-1; r20=spy_c[-1]/spy_c[-20]-1

        ml=False
        if self.trained:
            ft=self.GetFeatures(vix_c,spy_c,vix3m_closes=vix3m_c,hyg_closes=hyg_c,
                                lqd_closes=lqd_c,rsp_closes=rsp_c,ief_closes=ief_c,shy_closes=shy_c)
            if ft is not None:
                try:
                    p=self.model.predict_proba(self.scaler.transform([ft]))[0]
                    ml=(p[1] if len(p)==2 else 0.5)>ML_THRESHOLD
                except Exception as e: self.Log(f"[S1] ML error: {e}")

        rs=rg=0.0; sa=True
        if cv>v80 and r5<-0.03:
            rs=(DIP_DEEP_SPY_W_ML if ml else DIP_DEEP_SPY_W) if r10<=DIP_DEEP_THRESHOLD else (DIP_SHALLOW_SPY_W_ML if ml else DIP_SHALLOW_SPY_W)
            rg=max(0.0,1.0-rs); sa=False; rn="R1-dip"
        elif cv<13 and sc>s50*1.05:
            rs=0.40; rg=0.20; rn="R2-lowvol"
        elif 20<cv<vsma:
            rs=0.85 if ml else 0.70; rg=0.10; rn="R3-recovery"
        elif cv>vsma*1.2:
            rs=0.30; rg=0.20; sa=False; rn="R4-stress"
        elif sc>s200:
            rs=0.70 if ml else 0.60; rg=0.15; rn="R5-trend"
        else:
            rs=0.30; rg=0.20; sa=False; rn="R6-below200"

        bull=(sc>s200 and sc>s50 and r20>0.0 and cv<v80 and cv<25)
        self._log_gate(sc,s50,s200,r20,cv,v80,bull)

        prev=self._s3_bull_market
        self._s3_bull_market=bull; self._sleeves_active=sa

        if bull and not prev:
            self.Log(f"[SWITCH] S1+S2->S3+S1(80/20) {self.Time:%Y-%m-%d} spy={sc:.2f} 50MA={s50:.2f} 200MA={s200:.2f} 20d={r20:+.2%} VIX={cv:.1f}")
            self._log_state("PRE-SWITCH->S3+S1")
        elif not bull and prev:
            reason=("VIX>25" if cv>=25 else "VIX>80pct" if cv>=v80
                    else "SPY<50MA" if sc<=s50 else "SPY<200MA" if sc<=s200 else "20d<0")
            self.Log(f"[SWITCH] S3+S1->S1+S2 {self.Time:%Y-%m-%d} reason={reason} spy={sc:.2f} VIX={cv:.1f}")
            self._log_state("PRE-SWITCH->S1+S2")

        if bull:
            # Bull mode: S3=80%, S1=20% (BRK.B 15% + NEM 5%), S2=0%
            self.s1_spy_weight    = S1_BULL_BUDGET * S1_BULL_SPY_FRAC   # 0.15 BRK.B
            self.s1_gld_weight    = S1_BULL_BUDGET * S1_BULL_GLD_FRAC   # 0.05 NEM
            self.s2_sleeve_budget = 0.0
            self._log_budgets("S3+S1-BULL")
            self._liquidate_sleeve2()
            self._safe_set_macro()   # buy BRK.B + NEM
            if bull and not prev:
                # Fresh bull entry — always force full rebalance
                self.Log("[S3] CheckSignal: fresh bull entry — forcing full S3 rebalance")
                self.RebalanceSleeve3()
                self._log_state("POST-DEPLOY-S3")
            elif self._sleeve3_is_empty():
                self.Log("[S3] CheckSignal: empty — seeding")
                self.RebalanceSleeve3()
                self._log_state("POST-DEPLOY-S3")
            else:
                # Check if S3 is meaningfully underdeployed (e.g. positions sold
                # externally, lot-size skips, or partial fills on entry).
                # Trigger a rebalance if more than 5% below budget.
                tv = self.Portfolio.TotalPortfolioValue
                s3_actual = (
                    sum(self.Portfolio[s].HoldingsValue / tv
                        for s in self.s3_symbols
                        if s in self.Portfolio and self.Portfolio[s].Invested)
                    if tv > 0 else 0.0
                )
                if s3_actual < S3_BULL_BUDGET - 0.05:
                    self.Log(
                        f"[S3] CheckSignal: underdeployed "
                        f"({s3_actual:.1%} vs {S3_BULL_BUDGET:.0%} target) — rebalancing"
                    )
                    self.RebalanceSleeve3()
                    self._log_state("POST-DEPLOY-S3")
                else:
                    self.Log(f"[S3] CheckSignal: invested ({s3_actual:.1%})")
                    self._log_state("S3 steady")
        else:
            self.s1_spy_weight=rs; self.s1_gld_weight=rg
            self.s2_sleeve_budget=max(0.0,1.0-rs-rg)
            self._log_budgets(f"S1+S2/{rn}")
            # Genuine S3->S1+S2 transition: force-close all S3 positions including
            # dual-listed stocks. Steady-state daily calls use default transition=False.
            if not bull and prev:
                self._liquidate_sleeve3(transition=True)
            else:
                self._liquidate_sleeve3()
            res=any(self.Portfolio[s].Invested for s in self.s3_symbols if s in self.Portfolio)
            self.Log(f"[SWITCH] S3 post-liq residual={res}")
            self._safe_set_macro()
            self.Log(f"[S1] {rn} vix={cv:.1f} v80={v80:.1f} spy_w={rs:.3f} gld_w={rg:.3f} S2={self.s2_sleeve_budget:.3f} ml={ml}")
            if not sa:
                self._liquidate_sleeve2(); self.Log(f"[S2] OFF ({rn})")
            else:
                if self._sleeve2_is_empty():
                    self.Log("[S2] empty — seeding"); self.RebalanceSleeve2()
                    self._log_state("POST-DEPLOY-S2")
                else:
                    self.Log(f"[S2] invested budget={self.s2_sleeve_budget:.3f}")
            if not bull and prev: self._log_state("POST-SWITCH-S3->S1+S2 final")

    # ── Macro helpers ─────────────────────────────────────────────────────────
    def _safe_set_macro(self):
        """S1 hedge: BRK.B (15%) + NEM (5%) in live. SPY/GLD in backtest.
        Skips execution outside market hours to prevent MOO order pile-up."""
        # Same market hours guard as S2/S3 — prevents MOO conversion and
        # cumulative pending order cash reservation issues from IB.
        if not self.Securities[self.spy_hist].Exchange.DateTimeIsOpen(self.Time):
            self.Log("[S1] Market closed — deferring S1 hedge to next session")
            return
        for sym, wt in [(self.spy_hedge, self.s1_spy_weight),
                        (self.gld_hedge, self.s1_gld_weight)]:
            if wt <= 0: continue
            try:
                # 95% buffer: accounts for GBP/USD FX conversion overhead
                buffered_wt = wt * 0.95 if self.LiveMode else wt
                qty = int(self.CalculateOrderQuantity(sym, buffered_wt))
                if qty == 0:
                    self.Log(f"[S1] {sym.Value} qty=0 at wt={wt:.3f} — skipping")
                    continue
                self.MarketOrder(sym, qty)
                self.Log(f"[S1] ORDER {sym.Value}={wt:.3f}(buf={buffered_wt:.3f}) qty={qty:+d} px={self.Securities[sym].Price:.2f}")
            except Exception as e:
                self.Log(f"[S1] {sym.Value} error: {e}")

    def _liquidate_sleeve1(self):
        liq=[]
        for sym in [self.spy_hedge, self.gld_hedge]:
            if sym in self.Portfolio and self.Portfolio[sym].Invested:
                qty = self.Portfolio[sym].Quantity
                if qty != 0:
                    self.MarketOrder(sym, -qty)
                    liq.append(f"{sym.Value}({qty} shares, ${self.Portfolio[sym].HoldingsValue:,.0f})")
        self.Log(f"[S1] LIQ: {' '.join(liq) if liq else 'nothing'}")

    # ── Sleeve 2 ──────────────────────────────────────────────────────────────

    def _sleeve2_is_empty(self):
        return not any(s in self.Portfolio and self.Portfolio[s].Invested for s in self._s2_candidates)

    def _liquidate_sleeve2(self):
        liq=[]
        for sym in list(self._s2_candidates):
            # In bull mode preserve positions that are also S3 candidates —
            # they belong to S3 and must not be swept by the S2 liquidation loop.
            if self._s3_bull_market and sym in self.s3_symbols:
                continue
            if sym in self.Securities and self.Portfolio[sym].Invested:
                liq.append(f"{sym.Value}(${self.Portfolio[sym].HoldingsValue:,.0f})")
                self.Liquidate(sym)
        self.Log(f"[S2] LIQ: {' '.join(liq) if liq else 'nothing'}")

    def RebalanceSleeve2(self):
        if self.IsWarmingUp: return
        # Skip execution outside market hours — daily resolution MarketOrders
        # get converted to MOO by QC, which IB then rejects at the next open.
        if not self.Securities[self.spy_hist].Exchange.DateTimeIsOpen(self.Time):
            self.Log("[S2] Market closed — deferring to next session"); return
        try: self._RebalanceSleeve2Inner()
        except Exception as e: self.Log(f"[S2] error: {e}")

    def _RebalanceSleeve2Inner(self):
        # S2 is OFF in bull mode (S1=20% BRK.B/NEM takes the hedge slot).
        # S2 runs only in S1+S2 mode at cash_sleeve_weight budget.
        if self._s3_bull_market:
            self._liquidate_sleeve2(); self.Log("[S2] BLOCKED — bull mode (S1 hedge active)"); return
        if not self._sleeves_active or not self._s2_candidates:
            self._liquidate_sleeve2()
            self.Log(f"[S2] OFF sa={self._sleeves_active} cand={len(self._s2_candidates)}"); return
        now=self.Time; cands=[]; sk={"ns":0,"np":0,"tn":0,"nr":0,"nm":0}
        for sym in self._s2_candidates:
            if sym not in self.Securities: sk["ns"]+=1; continue
            sec=self.Securities[sym]
            if not sec.HasData or sec.Price<=0 or not sec.IsTradable: sk["np"]+=1; continue
            ad=self._s2_added_date.get(sym)
            if ad and (now-ad).days<self.S2_MIN_HISTORY_DAYS: sk["tn"]+=1; continue
            roc=self._s2_momentum.get(sym)
            if roc is None or not roc.IsReady: sk["nr"]+=1; continue
            if float(roc.Current.Value)<self.S2_MOMENTUM_MIN_RETURN: sk["nm"]+=1; continue
            cands.append((sym,float(roc.Current.Value)))
        self.Log(f"[S2] Filter pool={len(self._s2_candidates)} qual={len(cands)} skip={sk}")
        cands=sorted(cands,key=lambda x:-x[1])[:self.S2_MAX_POSITIONS]
        if not cands: self._liquidate_sleeve2(); self.Log("[S2] No cands — liquidated"); return
        n=len(cands)
        pp=min(self.s2_sleeve_budget/n, self.S2_MAX_POSITION_WEIGHT*self.s2_sleeve_budget)
        self.Log(f"[S2] REBAL n={n} budget={self.s2_sleeve_budget:.3f} per_pos={pp:.3f}")
        self.Log(f"  {'Sym':<8} {'Wt%':>6} {'ROC63':>8}")
        for sym,rv in cands:
            qty = int(self.CalculateOrderQuantity(sym, pp))
            if qty == 0:
                self.Log(f"  {sym.Value:<8} SKIP (qty=0 at pp={pp:.3f})")
                continue
            self.Log(f"  {sym.Value:<8} {pp*100:>5.1f}% {rv*100:>+7.2f}% qty={qty:+d}")
            self.MarketOrder(sym, qty)
        tgt={sym for sym,_ in cands}
        for sym in self._s2_candidates:
            if sym not in tgt and sym in self.Portfolio and self.Portfolio[sym].Invested:
                self.Log(f"[S2] CLOSE stale {sym.Value}"); self.Liquidate(sym)
        eq=self.Portfolio.TotalPortfolioValue
        if eq>0:
            act=sum(self.Portfolio[s].HoldingsValue/eq for s in tgt if s in self.Portfolio and self.Portfolio[s].Invested)
            self.Log(f"[S2] Post-rebal target={self.s2_sleeve_budget:.3f} actual={act:.3f} d={act-self.s2_sleeve_budget:+.3f}")

    # ── Sleeve 3 ──────────────────────────────────────────────────────────────

    def _sleeve3_is_empty(self):
        return not any(s in self.Portfolio and self.Portfolio[s].Invested for s in self.s3_symbols)

    def _liquidate_sleeve3(self, transition=False):
        liq=[]
        for sym in list(self.s3_symbols):
            # Steady-state guard (transition=False): skip symbols that are also S2
            # candidates — they are legitimate S2 positions and S3 has no authority
            # over them while S1+S2 mode is active. Only a genuine S3->S1+S2
            # transition (transition=True) should force-close everything.
            if not transition and sym in self._s2_candidates:
                continue
            if sym in self.Securities and self.Portfolio[sym].Invested:
                liq.append(f"{sym.Value}(${self.Portfolio[sym].HoldingsValue:,.0f})")
                self.Liquidate(sym)
        self.Log(f"[S3] LIQ: {' '.join(liq) if liq else 'nothing'}")

    def RebalanceSleeve3(self):
        if self.IsWarmingUp: return
        # Skip execution outside market hours — daily resolution MarketOrders
        # get converted to MOO by QC, which IB then rejects at the next open.
        if not self.Securities[self.spy_hist].Exchange.DateTimeIsOpen(self.Time):
            self.Log("[S3] Market closed — deferring to next session"); return
        try: self._RebalanceSleeve3Inner()
        except Exception as e: self.Log(f"[S3] error: {e}")

    def _RebalanceSleeve3Inner(self):
        if not self._s3_bull_market:
            self._liquidate_sleeve3(transition=True); self.Log(f"[S3] BLOCKED bull={self._s3_bull_market}"); return
        ds=self.Time.strftime("%Y-%m-%d")
        idxs=list(self.s3_band_idx.values())
        if len(idxs)<50:
            if len(self.s3_symbols)>=50:
                # Symbols subscribed but OnData hasn't populated band indices yet
                # (first deploy after universe loads). Skip breadth, proceed to
                # momentum — breadth will be available on next rebalance.
                self.Log(
                    f"[S3] Band indices not yet populated ({len(idxs)} of "
                    f"{len(self.s3_symbols)}) — skipping breadth, running momentum only"
                )
                bf=0.0; self.s3_allow=True
            else:
                self.Log(f"[S3] Universe too small ({len(idxs)})"); return
        else:
            bf=sum(i in self.s3_BOTTOM_LEVELS for i in idxs)/len(idxs)
        self.s3_max_stress=max(self.s3_max_stress,bf)
        if bf>=0.40:
            if not self.s3_was_risk_off: self.s3_risk_off_date=self.Time; self.Log(f"[S3] RISK-OFF {ds} stress={bf:.1%}")
            self.s3_allow=False; self.s3_was_risk_off=True
        elif self.s3_was_risk_off:
            denom=max(self.s3_max_stress,0.10); imp=(self.s3_max_stress-bf)/denom
            doff=(self.Time-self.s3_risk_off_date).days if self.s3_risk_off_date else 0
            self.Log(f"[S3] RISK-OFF check {ds} stress={bf:.1%} imp={imp:.1%} days={doff}")
            if imp>=0.60 or bf<0.15 or doff>180:
                trig="60pct" if imp>=0.60 else "stress<15" if bf<0.15 else "180d"
                self.Log(f"[S3] RECOVERY {ds} trigger={trig}")
                for s in self.s3_symbols:
                    if s in self.s3_band_hist: self.s3_band_hist[s]=RollingWindow[int](self.s3_hist_len)
                self.s3_allow=True; self.s3_was_risk_off=False
                self.s3_max_stress=0.0; self.s3_risk_off_date=None
            else:
                self.Log(f"[S3] RISK-OFF {ds} stress={bf:.1%} imp={imp:.1%} days={doff}")
        else:
            self.s3_allow=True
        if not self.s3_allow: self._liquidate_sleeve3(); return

        a3=list(self.s3_symbols)
        if not a3: return
        hist=self.History(a3,max(self.s3_lookbacks)+1,Resolution.Daily)
        if hist.empty: self.Log("[S3] History empty"); return
        cl=hist["close"].unstack(0); mom={}; sk={"adx":0,"ema":0,"mc":0,"neg":0,"nh":0,"lot":0}
        tv=self.Portfolio.TotalPortfolioValue
        min_pos_val = (tv * S3_BULL_BUDGET / self.s3_stock_count) if tv > 0 else 0
        for s in a3:
            if s not in cl: sk["nh"]+=1; continue
            px=cl[s]
            if len(px)<max(self.s3_lookbacks)+1: sk["nh"]+=1; continue
            if not self.s3_adx[s].IsReady or self.s3_adx[s].Current.Value>self.s3_adx_limit: sk["adx"]+=1; continue
            mv=np.mean([px.iloc[-1]/px.iloc[-lb-1]-1 for lb in self.s3_lookbacks])
            if not self.s3_ma[s].IsReady: continue
            if self.Securities[s].Price<=self.s3_ma[s].Current.Value: sk["ema"]+=1; continue
            fn=self.Securities[s].Fundamentals
            if fn is None or fn.MarketCap<5_000_000_000: sk["mc"]+=1; continue
            # Exclude stocks where even 1 share costs more than the per-position budget.
            # At small account sizes (e.g. $10k) high-price stocks like FIX ($1700)
            # or GEV ($1100) cannot be meaningfully sized — skip and take next ranked stock.
            if self.Securities[s].Price > min_pos_val: sk["lot"]+=1; continue
            if mv>0: mom[s]=mv
            else: sk["neg"]+=1
        self.Log(f"[S3] MOMENTUM {ds} uni={len(a3)} qual={len(mom)} skip={sk}")
        if not mom: self.Log("[S3] No momentum — liquidating"); self._liquidate_sleeve3(); return

        top=sorted(mom,key=mom.get,reverse=True)[:self.s3_stock_count]
        sc2={}; sm={}
        for s in top:
            if not self.s3_ma[s].IsReady or not self.s3_stretch_ema[s].IsReady: continue
            dev=np.std(list(self.s3_close_win[s]))
            if dev<=0: continue
            mid=self.s3_ma[s].Current.Value; lm=self.s3_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]
            px2=self.Securities[s].Price; bi=self._s3_band_index(px2,bands)
            self.s3_band_hist[s].Add(bi)
            hi2=list(self.s3_band_hist[s]); hh=max(hi2) if hi2 else bi
            scale=1.0 if hh<=0 else (0.0 if bi>=hh else max(0.15,1.0-bi/hh))
            ex=False
            if self.s3_stretch_win[s].IsReady:
                sl=list(self.s3_stretch_win[s]); cs=sl[0]; ps=max(sl)
                if bi>=10 and ps>0 and cs<ps*0.80:
                    scale=min(scale,0.15); ex=True
                    self.Log(f"[S3] EXHAUST {s.Value} band={bi} str={cs:.2f}/pk={ps:.2f} sc->{scale:.2f}")
            sc2[s]=mom[s]*scale; sm[s]=(scale,bi,ex)
        if not sc2: self.Log("[S3] Band sizing zero"); self._liquidate_sleeve3(); return

        ts=sum(sc2.values()); rw={s:v/ts for s,v in sc2.items()}
        cw={s:min(0.20,w) for s,w in rw.items()}; cs=sum(cw.values())
        sw={s:(w/cs)*S3_BULL_BUDGET for s,w in cw.items()} if cs>0 else {}

        eq=self.Portfolio.TotalPortfolioValue
        self.Log(f"[S3] REBAL {ds} Eq={eq:,.0f} stress={bf:.1%} pos={len(sw)} budget={S3_BULL_BUDGET:.0%}")
        self.Log(f"  {'Sym':<8} {'Wt%':>6} {'Mom%':>7} {'Scale':>6} {'Band':>4}")
        for s,w in sorted(sw.items(),key=lambda x:-x[1]):
            sc3,bi2,ex2=sm.get(s,(1.0,0,False))
            self.Log(f"  {s.Value:<8} {w*100:>5.1f}% {mom[s]*100:>+6.2f}% {sc3:>6.3f} {bi2:>4}{'EXHAUST' if ex2 else ''}")

        tv=self.Portfolio.TotalPortfolioValue
        if tv<=0: return
        cw2={kvp.Key:kvp.Value.HoldingsValue/tv for kvp in self.Portfolio
             if kvp.Value.Invested and kvp.Key in self.s3_symbols}
        trades=[]
        # Build full trade list then sort: sells first (-qty) so cash is
        # freed before buys execute, avoiding insufficient settled cash rejections.
        trade_list=[]
        for s in set(list(cw2)+list(sw)):
            tg=sw.get(s,0.0); cu=cw2.get(s,0.0); dl=tg-cu
            if abs(dl)<=self.s3_rebal_threshold: continue
            qty=int(self.CalculateOrderQuantity(s, tg))
            if qty==0: continue
            rn2="BUY" if cu==0 and tg>0 else "CLOSE" if tg==0 else "ADD" if dl>0 else "TRIM"
            px3=self.Securities[s].Price if s in self.Securities else 0
            trade_list.append((s, qty, rn2, px3, cu, tg, dl))
        # Sells (negative qty) first, then buys
        trade_list.sort(key=lambda x: x[1])
        for s, qty, rn2, px3, cu, tg, dl in trade_list:
            self.Log(f"[S3] {rn2} {s.Value} {cu*100:.1f}%->{tg*100:.1f}% (d{dl*100:+.1f}%) px={px3:.2f} qty={qty:+d}")
            self.MarketOrder(s, qty); trades.append(s.Value)
        if not trades: self.Log(f"[S3] No trades needed {ds}")

        eq=self.Portfolio.TotalPortfolioValue
        if eq>0:
            s3a=sum(self.Portfolio[s].HoldingsValue/eq for s in sw if s in self.Portfolio and self.Portfolio[s].Invested)
            s1a=sum(self.Portfolio[sym].HoldingsValue/eq for sym in [self.spy_hedge,self.gld_hedge]
                    if sym in self.Portfolio and self.Portfolio[sym].Invested)
            expected_s1=self.s1_spy_weight+self.s1_gld_weight
            self.Log(f"[S3] Post-rebal S3={s3a:.1%} S1={s1a:.1%}(exp={expected_s1:.1%}) cash={self.Portfolio.Cash/eq:.1%}")
            if abs(s1a-expected_s1)>0.05:
                self.Log(f"[S3] WARNING S1 drift={s1a-expected_s1:+.1%} — reapplying macro")
                self._safe_set_macro()

    # ── Warmup / End ──────────────────────────────────────────────────────────

    def OnWarmupFinished(self):
        self.Log("[INIT] Warmup complete")
        self._log_state("PRE-INIT")

        # Guard: if universe hasn't populated yet defer to first CheckSignal
        if len(self.s3_symbols) == 0 and len(self._s2_candidates) == 0:
            self.Log("[INIT] Universe not yet populated — deferring to first CheckSignal")
            self._initial_deploy_done = True
            return

        # Log existing positions — do NOT liquidate anything here.
        # OnWarmupFinished fires before the universe is fully stable, so
        # valid S3 positions (e.g. recently bought) may appear unrecognised.
        # Let CheckSignal and the rebalances handle all position management.
        if self.Portfolio.TotalHoldingsValue != 0:
            macro = {self.spy_hedge, self.gld_hedge}
            managed = macro | self._s2_candidates | self.s3_symbols
            kept, unknown = [], []
            for kvp in self.Portfolio:
                if not kvp.Value.Invested: continue
                if kvp.Key in managed: kept.append(kvp.Key.Value)
                else: unknown.append(kvp.Key.Value)
            if kept:    self.Log(f"[INIT] Existing managed positions: {kept}")
            if unknown: self.Log(f"[INIT] Existing unclassified positions (keeping): {unknown}")

        self._initial_deploy_done = True
        self.CheckSignal()
        self._log_state("POST-INIT")

    def OnEndOfAlgorithm(self):
        eq=self.Portfolio.TotalPortfolioValue
        self.Log(f"[END] Eq={eq:,.2f} Ret={(eq/100_000-1)*100:+.2f}%")
        self._log_state("END"); self._log_budgets("END")


class CBOE(PythonData):
    def GetSource(self, config, date, isLive):
        urls={"VIX":"https://cdn.cboe.com/api/global/us_indices/daily_prices/VIX_History.csv",
              "VIX3M":"https://cdn.cboe.com/api/global/us_indices/daily_prices/VIX3M_History.csv"}
        return SubscriptionDataSource(urls.get(config.Symbol.Value,urls["VIX"]),SubscriptionTransportMedium.RemoteFile)

    def Reader(self, config, line, date, isLive):
        if not (line.strip() and line[0].isdigit()): return None
        cols=line.split(',')
        try:
            obj=CBOE(); obj.Symbol=config.Symbol
            obj.Time=datetime.strptime(cols[0],"%m/%d/%Y"); obj.Value=float(cols[4])
            obj["close"]=float(cols[4]); obj["open"]=float(cols[1])
            obj["high"]=float(cols[2]);  obj["low"]=float(cols[3])
            return obj
        except: return None
"""
S3 Momentum + Gold Sleeve + UPRO Crash Hedge
=============================================
Capital allocation:
  60% S3 momentum positions (sector-neutral large-cap)
  40% Gold sleeve — GLD in backtest, NEM in live
  Cash account — no margin, no shorting

Hedge overlay:
  When crash conditions fire, gold sleeve is swapped to UPRO (3x SPY long)
  in a single SetHoldings call. On hedge exit, UPRO swapped back to gold.
  S3 sleeve runs completely independently and is never touched by hedge logic.

S3 risk-off:
  When breadth stress triggers risk-off, S3 positions are liquidated.
  Gold sleeve is unaffected — remains at 40% allocation.
  Cash from S3 liquidation sits idle (earns IB interest in live).

Hedge entry (all must be true simultaneously):
  - Portfolio DD from HWM > 10%
  - SPY below 200-day SMA
  - Breadth stress >= 35% for 3 consecutive days
  - Not in 30-day cooldown after previous exit

Hedge exit (first condition wins):
  - SPY recrosses 200MA
  - Breadth stress drops below 25%
  - Stress drops below 3-day rolling mean
  - 30 days max hold

Gold instrument:
  - Backtest: FNV (Franco-Nevada Corporation)
  - Live: FNV (Franco-Nevada Corporation)

Brokerage: InteractiveBrokers Cash account (no margin)

EMAIL: replace YOUR_EMAIL@gmail.com before deploying live

RELIABILITY FIXES (this revision):
  1. fundamentals.MarketCap None-guard — prevents TypeError crash in _Rebalance
     that was silently halting the algorithm and freezing the portfolio.
  2. All three scheduled handlers (_Rebalance, _DailyHedgeCheck, _DailySnapshot)
     wrapped in try/except so one bad symbol/day/data hiccup can no longer
     kill the whole algorithm — errors are logged + emailed instead.
  3. Notify.Email calls wrapped so a notification failure can't crash the run.
  4. self.History() call wrapped so a data-feed hiccup can't crash the run.
  5. Stale-rebalance self-heal: if no successful rebalance has completed in
     > 31 days (persisted via ObjectStore, survives redeploys), a rebalance
     is forced automatically.
  6. One-time hardcoded forced rebalance on 2026-08-10.
  7. SetEndDate extended so live deployment doesn't terminate before the
     forced rebalance date — see NOTE below.
"""

from AlgorithmImports import *
from datetime import date, datetime
from collections import defaultdict, deque
import numpy as np
import traceback

# -- Operational constants --------------------------------------------------
STRESS_AMBER        = 0.35
STRESS_RED          = 0.45
RECOVERY_THRESHOLD  = 0.60
FREE_CASH_PCT       = 0.025
YOUR_EMAIL          = "abc@gmail.com"

# -- Capital allocation -----------------------------------------------------
S3_BUDGET           = 0.60   # S3 momentum sleeve
GOLD_BUDGET          = 0.40   # Gold / hedge sleeve

# -- Drawdown circuit breaker -----------------------------------------------
DD_THRESHOLD        = 0.15
DD_FLOOR            = 0.50

# -- Hedge parameters -------------------------------------------------------
HEDGE_ENABLED       = True  # Set True to enable UPRO crash hedge, False to run gold-only
HEDGE_TOLERANCE     = 0.02
SPY_HEDGE_MAX       = 0.90
SPY_HEDGE_STEP      = 0.30
STRESS_CRASH        = 0.35
DD_CRASH            = -0.10
STRESS_PERSIST_D    = 3
COOLDOWN_DAYS       = 30

# -- Fix 3: rolling window size for stretch ceiling -------------------------
STRETCH_WIN_LEN     = 126

# -- Rebalance reliability constants -----------------------------------------
FORCE_REBALANCE_DATE = date(2026, 8, 10)   # one-time hardcoded forced rebalance
STALE_REBALANCE_DAYS = 31                   # self-heal if no rebalance in this many days
FORCE_REBALANCE_KEY  = "forced_rebalance_2026_08_10_done"
LAST_REBALANCE_KEY   = "last_rebalance_date"


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 not f.price or f.price <= 5: continue
            if not f.market_cap or f.market_cap < 5_000_000_000: continue
            sector = f.asset_classification.morningstar_sector_code
            if sector: 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 StockOnlyMomentum(QCAlgorithm):

    def Initialize(self):
        self.SetStartDate(2011, 1, 1)
        # NOTE: extended from 2026-01-01 so live deployment doesn't terminate
        # before the hardcoded FORCE_REBALANCE_DATE (2026-08-10). Adjust as needed.
        self.SetEndDate(2026, 1, 1)
        self.SetCash(100_000)
        self.SetBrokerageModel(
            BrokerageName.InteractiveBrokersBrokerage, AccountType.Cash)  # Cash account
        self.Settings.FreePortfolioValuePercentage = FREE_CASH_PCT

        # -- Schedule anchor ------------------------------------------------
        self.anchor = self.AddEquity("SPY", Resolution.Daily).Symbol
        self.SetBenchmark("SPY")
        self.SetSecurityInitializer(
            lambda s: (s.SetFeeModel(InteractiveBrokersFeeModel()),
                       s.SetFillModel(ImmediateFillModel()),
                       s.SetSlippageModel(ConstantSlippageModel(0.001))))  # Deploy book: 10bps slippage

        # -- Gold sleeve ----------------------------------------------------
        # FNV (Franco-Nevada royalty model) used in both backtest and live
        gold_ticker        = "FNV"
        self.gold          = self.AddEquity(gold_ticker, Resolution.Daily).Symbol
        self.hedge_instrument = self.AddEquity("UPRO", Resolution.Daily).Symbol

        # -- Momentum parameters --------------------------------------------
        self.lookbacks   = [21, 63, 126, 189, 252]
        self.stock_count = 10
        self.max_weight  = 0.20

        # -- Band parameters ------------------------------------------------
        self.band_len = 189
        self.hist_len = 126

        self.UniverseSettings.Resolution            = Resolution.Daily
        self.UniverseSettings.DataNormalizationMode = DataNormalizationMode.TotalReturn

        # -- Breadth / regime state -----------------------------------------
        self.allow_universe   = True
        self.was_risk_off     = False
        self.risk_off_date    = None
        self.max_stress       = 0.0
        self.current_band_idx: dict = {}
        self.BOTTOM_LEVELS    = {0, 1, 2, 3, 4}

        # -- Per-symbol indicators ------------------------------------------
        self.symbols:      set  = set()
        self.ma:           dict = {}
        self.adx:          dict = {}
        self.close_win:    dict = {}
        self.stretch_ema:  dict = {}
        self.stretch_win:  dict = {}
        self.band_hist:    dict = {}

        self.adx_limit  = 35
        self.adx_period = 14

        # -- Daily snapshot state -------------------------------------------
        self._hwm        = 0.0
        self._prev_value = None
        self._daily_rets = deque(maxlen=252)

        # -- Hedge state ----------------------------------------------------
        self._hedge_active       = False
        self._hedge_entry_price  = None
        self._hedge_entry_date   = None
        self._hedge_entry_stress = None
        self._hedge_entry_dd     = None
        self._hedge_last_target  = 0.0
        self._hedge_trades       = []
        self.current_hedge_target = 0.0
        self.last_hedge_exit     = None

        # -- SMA200 + stress window -----------------------------------------
        self.spy_sma200   = self.SMA(self.anchor, 200, Resolution.Daily)
        self.WarmUpIndicator(self.anchor, self.spy_sma200, Resolution.Daily)
        self.stress_window = RollingWindow[float](STRESS_PERSIST_D)

        # -- FIX 2: pending weights for MOO drain ---------------------------
        self._pending_weights = None

        # -- Universe + schedule --------------------------------------------
        self.SetWarmUp(300)
        self.SetUniverseSelection(
            SectorTopUniverse(self, blacklist={"GME", "AMC"}))

        self.Schedule.On(
            self.DateRules.MonthEnd(self.anchor),
            self.TimeRules.BeforeMarketClose(self.anchor, 5),
            self._Rebalance)
        self.Schedule.On(
            self.DateRules.EveryDay(self.anchor),
            self.TimeRules.AfterMarketOpen(self.anchor, 10),
            self._DailyHedgeCheck)
        self.Schedule.On(
            self.DateRules.EveryDay(self.anchor),
            self.TimeRules.BeforeMarketClose(self.anchor, 1),
            self._DailySnapshot)
        # -- Reliability: daily check for stale / hardcoded forced rebalance
        self.Schedule.On(
            self.DateRules.EveryDay(self.anchor),
            self.TimeRules.AfterMarketOpen(self.anchor, 15),
            self._CheckPendingForceRebalance)

    # =========================================================================
    # SECURITIES CHANGED
    # =========================================================================

    def OnSecuritiesChanged(self, changes):
        excl = {self.anchor, self.gold, self.hedge_instrument}

        for sec in changes.RemovedSecurities:
            s = sec.Symbol
            if s in excl: continue
            self.symbols.discard(s)
            for d in [self.ma, self.adx, self.close_win,
                      self.stretch_ema, self.stretch_win, self.band_hist,
                      self.current_band_idx]:
                d.pop(s, None)

        for sec in changes.AddedSecurities:
            s = sec.Symbol
            if s in excl: continue
            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.stretch_win[s]  = RollingWindow[float](STRETCH_WIN_LEN)
            self.band_hist[s]    = RollingWindow[int](self.hist_len)
            if self.LiveMode:
                try:
                    self.WarmUpIndicator(s, self.ma[s],          Resolution.Daily)
                    self.WarmUpIndicator(s, self.adx[s],         Resolution.Daily)
                    self.WarmUpIndicator(s, self.stretch_ema[s], Resolution.Daily)
                except: pass

    # =========================================================================
    # ON DATA
    # =========================================================================

    def OnData(self, data: Slice):
        # FIX 2: drain pending MOO rebalance weights placed by _Rebalance
        if self._pending_weights is not None and not self.IsWarmingUp:
            targets      = self._pending_weights
            self._pending_weights = None
            equity       = self.Portfolio.TotalPortfolioValue
            excl         = {self.anchor, self.gold, self.hedge_instrument}

            # Exit positions not in new targets
            for pos in list(self.Portfolio.Values):
                if pos.Invested and pos.Symbol not in targets and pos.Symbol not in excl:
                    if pos.Quantity != 0:
                        self.MarketOnOpenOrder(pos.Symbol, -pos.Quantity)

            # Enter / adjust to new targets via delta orders
            for sym, w in targets.items():
                if w <= 0 or not self.Securities.ContainsKey(sym): continue
                price = self.Securities[sym].Price
                if price <= 0: continue
                target_qty = int(equity * w / price)
                cur        = self.Portfolio[sym].Quantity if self.Portfolio.ContainsKey(sym) else 0
                delta      = target_qty - cur
                if abs(delta) > 0:
                    drift = abs(delta * price) / equity
                    if drift >= 0.02:   # FIX 2: skip trivial rebalances (< 2% drift)
                        self.MarketOnOpenOrder(sym, delta)

        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)

    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

    # =========================================================================
    # SAFE EMAIL HELPER
    # =========================================================================

    def _SafeEmail(self, subject, body):
        try:
            self.Notify.Email(YOUR_EMAIL, subject, body)
        except Exception as e:
            self.Error(f"[EMAIL ERROR] {e}")

    # =========================================================================
    # STALE / FORCED REBALANCE TRACKING
    # =========================================================================

    def _SaveLastRebalanceDate(self):
        try:
            self.ObjectStore.Save(LAST_REBALANCE_KEY, self.Time.date().strftime("%Y-%m-%d"))
        except Exception as e:
            self.Error(f"[STALE CHECK] Failed to save last rebalance date: {e}")

    def _ReadLastRebalanceDate(self):
        if not self.ObjectStore.ContainsKey(LAST_REBALANCE_KEY):
            return None
        try:
            return datetime.strptime(
                self.ObjectStore.Read(LAST_REBALANCE_KEY), "%Y-%m-%d").date()
        except Exception as e:
            self.Error(f"[STALE CHECK] Failed to parse last rebalance date: {e}")
            return None

    def _CheckPendingForceRebalance(self):
        try:
            if self.IsWarmingUp: return
            if not self.Securities[self.anchor].Exchange.DateTimeIsOpen(self.Time): return

            today = self.Time.date()

            # -- Hardcoded one-time forced rebalance --------------------------
            if today >= FORCE_REBALANCE_DATE and not self.ObjectStore.ContainsKey(FORCE_REBALANCE_KEY):
                msg = f"[FORCE REBALANCE] One-time forced rebalance ({FORCE_REBALANCE_DATE})"
                self.Debug(msg)
                if self.LiveMode:
                    self.Log(msg)
                    self._SafeEmail("[ALERT] Forced rebalance triggered", msg)
                self.ObjectStore.Save(FORCE_REBALANCE_KEY, "true")
                self._Rebalance_impl()
                return

            # -- Staleness check: no successful rebalance in > 31 days --------
            last_date = self._ReadLastRebalanceDate()
            stale = last_date is None or (today - last_date).days > STALE_REBALANCE_DAYS

            if stale:
                age = "unknown (no record)" if last_date is None else f"{(today - last_date).days}d"
                msg = f"[STALE CHECK] Last rebalance age={age} > {STALE_REBALANCE_DAYS}d — forcing rebalance"
                self.Debug(msg)
                if self.LiveMode:
                    self.Log(msg)
                    self._SafeEmail("[ALERT] Stale rebalance detected", msg)
                self._Rebalance_impl()

        except Exception as e:
            self.Error(f"[FORCE REBALANCE ERROR] {e}\n{traceback.format_exc()}")

    # =========================================================================
    # DAILY HEDGE CHECK -- AMO+10  (crash-guarded wrapper)
    # =========================================================================

    def _DailyHedgeCheck(self):
        try:
            self._DailyHedgeCheck_impl()
        except Exception as e:
            self.Error(f"[HEDGE CHECK ERROR] {e}\n{traceback.format_exc()}")
            if self.LiveMode:
                self._SafeEmail("[CRASH] Hedge check failed", f"{e}\n\n{traceback.format_exc()}")

    def _DailyHedgeCheck_impl(self):
        if self.IsWarmingUp: return
        if not self.Securities[self.anchor].Exchange.DateTimeIsOpen(self.Time): return
        if not self.spy_sma200.IsReady: return

        eq = self.Portfolio.TotalPortfolioValue
        self._hwm = max(self._hwm, eq)
        dd = (eq - self._hwm) / self._hwm if self._hwm > 0 else 0.0

        # breadth stress
        idxs = list(self.current_band_idx.values())
        if len(idxs) == 0: return
        bottom_frac = sum(i in self.BOTTOM_LEVELS for i in idxs) / len(idxs)
        self.stress_window.Add(bottom_frac)
        if self.stress_window.Count < STRESS_PERSIST_D: return

        stress_persistent = all(v >= STRESS_CRASH for v in self.stress_window)

        # trend
        spy_price  = float(self.Securities[self.anchor].Price)
        sma200     = float(self.spy_sma200.Current.Value)
        trend_down = spy_price < sma200

        # cooldown
        if isinstance(self.last_hedge_exit, date):
            in_cooldown = (self.Time.date() - self.last_hedge_exit).days < COOLDOWN_DAYS
        else:
            in_cooldown = False

        crash_env = HEDGE_ENABLED and trend_down and stress_persistent and dd <= DD_CRASH and not in_cooldown
        current_upro_w = self.Portfolio[self.hedge_instrument].HoldingsValue / eq

        # ── HEDGE ON / SCALE UP ───────────────────────────────────────────
        if crash_env:
            target = 0.0
            if dd <= DD_CRASH:              target = SPY_HEDGE_STEP
            if dd <= DD_CRASH - 0.05:      target = 2 * SPY_HEDGE_STEP
            if dd <= DD_CRASH - 0.10:      target = SPY_HEDGE_MAX

            if target != self._hedge_last_target:
                upro_price = float(self.Securities[self.hedge_instrument].Price)
                self.Debug(f"[HEDGE ENTER] Swapping Gold → UPRO {target:.0%} "
                           f"dd={dd:.1%} stress={bottom_frac:.2f} "
                           f"spy={spy_price:.2f} sma200={sma200:.2f} upro={upro_price:.2f}")
                # Single SetHoldings: sell gold, buy UPRO simultaneously
                self.SetHoldings([
                    PortfolioTarget(self.gold,             0.0),
                    PortfolioTarget(self.hedge_instrument, target)
                ])
                self._hedge_last_target   = target
                self.current_hedge_target = target
                if self.LiveMode:
                    self.Log(f"[HEDGE ENTER] Gold→UPRO {target:.0%} dd={dd:.1%}")
                    self._SafeEmail(
                        f"[ALERT] Hedge Entered {self.Time:%d %b %Y}",
                        f"Gold swapped to UPRO {target:.0%}\n"
                        f"DD={dd:.1%} Stress={bottom_frac:.1%}\n"
                        f"Portfolio: GBP{eq/1.27:,.0f}")

            if not self._hedge_active:
                self._hedge_active       = True
                self._hedge_entry_price  = float(self.Securities[self.hedge_instrument].Price)
                self._hedge_entry_date   = self.Time
                self._hedge_entry_stress = bottom_frac
                self._hedge_entry_dd     = dd
            return

        # ── HEDGE OFF / EXIT ──────────────────────────────────────────────
        stress_mean  = np.mean([float(x) for x in self.stress_window])
        max_duration = False
        if HEDGE_ENABLED and self._hedge_active and self._hedge_entry_date is not None:
            max_duration = (self.Time.date() - self._hedge_entry_date.date()).days >= 30

        exit_signal = (
            spy_price > sma200 or
            bottom_frac < 0.25 or
            bottom_frac < stress_mean or
            max_duration
        )

        if exit_signal and current_upro_w > HEDGE_TOLERANCE:
            # guard against None state
            if self._hedge_entry_price is None:
                self.SetHoldings([
                    PortfolioTarget(self.hedge_instrument, 0.0),
                    PortfolioTarget(self.gold,             GOLD_BUDGET)
                ])
                self._hedge_active        = False
                self._hedge_last_target   = 0.0
                self.current_hedge_target = 0.0
                self.last_hedge_exit      = self.Time.date()
                return

            exit_price = float(self.Securities[self.hedge_instrument].Price)
            ret = (exit_price - self._hedge_entry_price) / self._hedge_entry_price

            self.Debug(f"[HEDGE EXIT] Swapping UPRO → Gold "
                       f"px:{self._hedge_entry_price:.2f}→{exit_price:.2f} "
                       f"ret:{ret:+.2%} dd:{self._hedge_entry_dd:.1%}→{dd:.1%} "
                       f"stress:{self._hedge_entry_stress:.2f}→{bottom_frac:.2f}")

            # Single SetHoldings: sell UPRO, buy gold simultaneously
            self.SetHoldings([
                PortfolioTarget(self.hedge_instrument, 0.0),
                PortfolioTarget(self.gold,             GOLD_BUDGET)
            ])

            if self.LiveMode:
                self.Log(f"[HEDGE EXIT] UPRO→Gold ret={ret:+.2%} dd={dd:.1%}")
                self._SafeEmail(
                    f"[ALERT] Hedge Exited {self.Time:%d %b %Y}",
                    f"UPRO swapped back to Gold\n"
                    f"Return: {ret:+.2%}\n"
                    f"DD: {self._hedge_entry_dd:.1%} → {dd:.1%}\n"
                    f"Portfolio: GBP{eq/1.27:,.0f}")

            self._hedge_trades.append({
                "entry_date":   self._hedge_entry_date,
                "exit_date":    self.Time,
                "entry_price":  self._hedge_entry_price,
                "exit_price":   exit_price,
                "return":       ret,
                "entry_stress": self._hedge_entry_stress,
                "exit_stress":  bottom_frac,
                "entry_dd":     self._hedge_entry_dd,
                "exit_dd":      dd,
            })

            self._hedge_active        = False
            self._hedge_entry_price   = None
            self._hedge_entry_date    = None
            self._hedge_entry_stress  = None
            self._hedge_entry_dd      = None
            self._hedge_last_target   = 0.0
            self.current_hedge_target = 0.0
            self.last_hedge_exit      = self.Time.date()

    # =========================================================================
    # REBALANCE -- MonthEnd BMC-5  (crash-guarded wrapper)
    # =========================================================================

    def _Rebalance(self):
        try:
            self._Rebalance_impl()
        except Exception as e:
            self.Error(f"[REBALANCE ERROR] {e}\n{traceback.format_exc()}")
            if self.LiveMode:
                self._SafeEmail("[CRASH] Rebalance failed", f"{e}\n\n{traceback.format_exc()}")

    def _Rebalance_impl(self):
        if self.IsWarmingUp: return
        if not self.Securities[self.anchor].Exchange.DateTimeIsOpen(self.Time): return

        # Reliability: record that a rebalance attempt actually ran this far,
        # so the daily staleness check (_CheckPendingForceRebalance) resets.
        self._SaveLastRebalanceDate()

        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 = max(self.max_stress, bottom_frac)

        # -- Breadth regime -------------------------------------------------
        if bottom_frac >= STRESS_RED:
            if not self.was_risk_off:
                self.risk_off_date = self.Time
            self.allow_universe = False
            self.was_risk_off   = True
            msg = f"[STRESS-RED] RISK-OFF bottom_frac={bottom_frac:.1%}"
            self.Debug(msg)
            if self.LiveMode:
                self.Log(msg)
                self._SafeEmail(
                    f"[ALERT] S3 Risk-Off {self.Time:%d %b %Y}",
                    f"Breadth stress {bottom_frac:.1%} >= {STRESS_RED:.0%}\n"
                    f"S3 liquidated. Gold sleeve unaffected.\n"
                    f"Portfolio: GBP{self.Portfolio.TotalPortfolioValue/1.27:,.0f}")

        elif bottom_frac >= STRESS_AMBER and self.allow_universe:
            msg = f"[STRESS-AMBER] bottom_frac={bottom_frac:.1%}"
            self.Debug(msg)
            if self.LiveMode:
                self.Log(msg)
                self._SafeEmail(
                    f"[ALERT] S3 Amber {self.Time:%d %b %Y}",
                    f"Stress {bottom_frac:.1%} approaching {STRESS_RED:.0%}\n"
                    f"Portfolio: GBP{self.Portfolio.TotalPortfolioValue/1.27:,.0f}")

        elif self.was_risk_off:
            denom = max(self.max_stress, 0.10)
            imp   = (self.max_stress - bottom_frac) / denom
            doff  = (self.Time - self.risk_off_date).days if self.risk_off_date else 0
            if imp >= RECOVERY_THRESHOLD or bottom_frac < 0.15 or doff > 180:
                trig = ("60pct" if imp >= RECOVERY_THRESHOLD
                        else "stress<15" if bottom_frac < 0.15 else "180d")
                msg = f"[RECOVERY] trigger={trig} stress={bottom_frac:.1%}"
                self.Debug(msg)
                if self.LiveMode:
                    self.Log(msg)
                    self._SafeEmail(
                        f"[ALERT] S3 Recovery {self.Time:%d %b %Y}",
                        f"Breadth recovered. trigger={trig}\n"
                        f"Re-entering market.")
                for s in self.symbols:
                    if s in self.band_hist:
                        self.band_hist[s] = RollingWindow[int](self.hist_len)
                for s in self.symbols:
                    if s in self.stretch_win:
                        self.stretch_win[s] = RollingWindow[float](STRETCH_WIN_LEN)
                self.allow_universe = True
                self.was_risk_off   = False
                self.max_stress     = 0.0
                self.risk_off_date  = None
            else:
                self.Debug(f"[RISK-OFF] stress={bottom_frac:.1%} imp={imp:.1%} days={doff}")
        else:
            self.allow_universe = True

        # S3 risk-off: liquidate S3 only, preserve gold and hedge instrument
        if not self.allow_universe:
            for k in list(self.Portfolio):
                if (k.Value.Invested
                        and k.Key != self.anchor
                        and k.Key != self.gold
                        and k.Key != self.hedge_instrument):
                    self.Liquidate(k.Key)
            self.Debug(f"[RISK-OFF] S3 liquidated. Gold sleeve intact. stress={bottom_frac:.1%}")
            return

        # -- Exposure scale from breadth ------------------------------------
        # Full deployment (1.0) held until bottom_frac = 0.25, then taper
        # linearly to 0 at STRESS_RED (0.45). np.interp clamps at endpoints.
        target_exposure = float(np.interp(
            bottom_frac, [0.25, STRESS_RED], [1.0, 0.0]))
        target_exposure = float(round(target_exposure, 2))

        # -- DD circuit breaker ---------------------------------------------
        if self._hwm > 0 and bottom_frac < STRESS_AMBER:
            dd = (self.Portfolio.TotalPortfolioValue - self._hwm) / self._hwm
            if dd < -DD_THRESHOLD:
                dd_scale = max(DD_FLOOR, 1.0 + dd)
                target_exposure *= dd_scale
                self.Debug(f"[DD BREAKER] dd={dd:.1%} scale={dd_scale:.2f} "
                           f"exposure→{target_exposure:.2f}")
                if self.LiveMode:
                    self.Log(f"[DD BREAKER] dd={dd:.1%} scale={dd_scale:.2f} "
                             f"exposure→{target_exposure:.2f}")

        # -- Momentum ranking -----------------------------------------------
        try:
            hist = self.History(
                list(self.symbols), max(self.lookbacks) + 1, Resolution.Daily)
        except Exception as e:
            self.Error(f"[HISTORY ERROR] {e}")
            return
        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
            if self.Securities[s].Price <= self.ma[s].Current.Value: continue
            # FIX 6 (deploy book): re-check market cap at rebalance, not just at universe selection
            fundamentals = self.Securities[s].Fundamentals
            if (fundamentals is None
                    or fundamentals.MarketCap is None
                    or fundamentals.MarketCap < 5_000_000_000):
                continue
            if mom > 0: momentum[s] = mom

        if not momentum:
            for k in list(self.Portfolio):
                if (k.Value.Invested
                        and k.Key != self.anchor
                        and k.Key != self.gold
                        and k.Key != self.hedge_instrument):
                    self.Liquidate(k.Key)
            return

        top = sorted(momentum, key=momentum.get, reverse=True)[:self.stock_count]

        # -- Band ceiling + exhaustion scaling ------------------------------
        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_h = max(hist_idx) if hist_idx else idx
            scale = (1.0 if historical_h <= 0
                     else 0.0 if idx >= historical_h
                     else max(0.2, 1.0 - idx / historical_h))

            # FIX 3: exhaustion uses rolling window peak, not lifetime stretch_max
            if self.stretch_win[s].IsReady:
                sw     = list(self.stretch_win[s])
                cur_s  = sw[0]
                peak_s = max(sw)   # FIX 3: 126-day rolling peak, not lifetime max
                if idx >= 10 and peak_s > 0 and cur_s < peak_s * 0.80:
                    scale = min(scale, 0.2)
                    self.Debug(f"[ANTICIPATION] {s.Value}")

            scaled[s] = (momentum[s] * self.adx[s].Current.Value) * scale

        if not scaled:
            for k in list(self.Portfolio):
                if (k.Value.Invested
                        and k.Key != self.anchor
                        and k.Key != self.gold
                        and k.Key != self.hedge_instrument):
                    self.Liquidate(k.Key)
            return

        # -- Final weighting: scale to S3_BUDGET ---------------------------
        total_scaled = sum(scaled.values())
        raw_weights  = {s: v / total_scaled for s, v in scaled.items()}
        capped       = {s: min(self.max_weight, w) for s, w in raw_weights.items()}
        cur_sum      = sum(capped.values())

        final_weights = {}
        if cur_sum > 0:
            for s, w in capped.items():
                final_weights[s] = (w / cur_sum) * S3_BUDGET * target_exposure

        # -- FIX 2: queue S3 targets for MOO drain in OnData ---------------
        # Gold and hedge are still managed directly via SetHoldings (intraday timing matters)
        s3_targets_dict = {s: w * (0.95 if self.LiveMode else 1.0)
                           for s, w in final_weights.items() if w > 0}
        self._pending_weights = s3_targets_dict

        # Gold target — only if hedge not active (hedge manages gold/UPRO swap)
        if not self._hedge_active:
            self.SetHoldings([PortfolioTarget(self.gold, GOLD_BUDGET)])

        hedge_tag = " [HEDGED]" if self._hedge_active else ""
        self.Debug(f"[S3] REBAL {self.Time:%Y-%m-%d} stress={bottom_frac:.1%} "
                   f"exp={target_exposure:.0%} pos={len(final_weights)}{hedge_tag} "
                   f"gold={'UPRO' if self._hedge_active else 'FNV'} "
                   f"[MOO queued]")
        if self.LiveMode:
            self.Log(f"[S3] REBAL {self.Time:%Y-%m-%d} stress={bottom_frac:.1%} "
                     f"exp={target_exposure:.0%} pos={len(final_weights)}{hedge_tag} "
                     f"stocks={[s.Value for s in final_weights]}")

    # =========================================================================
    # DAILY SNAPSHOT  (crash-guarded wrapper)
    # =========================================================================

    def _DailySnapshot(self):
        try:
            self._DailySnapshot_impl()
        except Exception as e:
            self.Error(f"[SNAPSHOT ERROR] {e}\n{traceback.format_exc()}")
            if self.LiveMode:
                self._SafeEmail("[CRASH] Daily snapshot failed", f"{e}\n\n{traceback.format_exc()}")

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

        eq = self.Portfolio.TotalPortfolioValue
        self._hwm = max(self._hwm, eq)
        dd = (eq - self._hwm) / self._hwm if self._hwm > 0 else 0.0
        dr = (eq - self._prev_value) / self._prev_value if self._prev_value else 0.0
        self._prev_value = eq
        self._daily_rets.append(dr)

        sh = ""
        if len(self._daily_rets) >= 20:
            r   = np.array(self._daily_rets)
            sig = np.std(r) * np.sqrt(252)
            sh  = f" Sh={np.mean(r)*252/sig if sig>0 else 0:+.2f}"

        mode  = "BULL" if self.allow_universe else "RISK-OFF"
        hedge = "|HEDGED" if self._hedge_active else ""
        gold_sym = "UPRO" if self._hedge_active else "FNV"
        self.Debug(f"[SNAP] {self.Time:%Y-%m-%d} Eq={eq:,.0f} DD={dd:.2%} "
                   f"D={dr:+.2%}{sh} [{mode}{hedge}] "
                   f"Gold={gold_sym} Cash={self.Portfolio.Cash/eq:.1%}")

        if not self.LiveMode: return

        positions = sorted(
            [(k.Key.Value, k.Value.HoldingsValue, k.Value.UnrealizedProfitPercent)
             for k in self.Portfolio if k.Value.Invested],
            key=lambda x: -x[1])
        pos_lines = "\n".join(
            f"  {s:<8} GBP {v/1.27:>8,.0f}  {p:>+.1%}"
            for s, v, p in positions)
        subject = (f"{'[UP]' if dr>=0 else '[DN]'} EOD {self.Time:%d %b %Y} "
                   f"{dr:+.2%} GBP{eq/1.27:,.0f} [{mode}{hedge}]")
        body = (f"Mode: {mode}{hedge} | Gold: {gold_sym}\n"
                f"Portfolio: GBP{eq/1.27:,.0f}\n"
                f"Day: {dr:+.2%}  DD: {dd:+.2%}{sh}\n\n{pos_lines}")
        self._SafeEmail(subject, body)

    # =========================================================================
    # WARMUP FINISHED
    # =========================================================================

    def OnWarmupFinished(self):
        self.Debug("[INIT] Warmup complete")
        if self.LiveMode: self.Log("[INIT] Warmup complete")

        eq = self.Portfolio.TotalPortfolioValue
        self._hwm        = eq
        self._prev_value = eq

        if self.Portfolio.TotalHoldingsValue != 0:
            excl    = {self.anchor, self.gold, self.hedge_instrument}
            managed = self.symbols | excl

            s3_inv = sum(1 for s in self.symbols
                         if s in self.Portfolio and self.Portfolio[s].Invested)
            if s3_inv >= 5:
                self.allow_universe = True
                msg = f"[INIT] {s3_inv} S3 positions found -- inferring BULL mode"
                self.Debug(msg)
                if self.LiveMode: self.Log(msg)

            unknown = [k.Key for k in self.Portfolio
                       if k.Value.Invested and k.Key not in managed]
            for sym in unknown:
                self.symbols.add(sym)
                if sym not in self.ma:
                    self.ma[sym]           = self.EMA(sym, self.band_len, Resolution.Daily)
                    self.adx[sym]          = self.ADX(sym, self.adx_period, Resolution.Daily)
                    self.stretch_ema[sym]  = self.EMA(sym, self.band_len, Resolution.Daily)
                    self.close_win[sym]    = RollingWindow[float](self.band_len)
                    self.stretch_win[sym]  = RollingWindow[float](STRETCH_WIN_LEN)
                    self.band_hist[sym]    = RollingWindow[int](self.hist_len)
                    if self.LiveMode:
                        try:
                            self.WarmUpIndicator(sym, self.ma[sym],          Resolution.Daily)
                            self.WarmUpIndicator(sym, self.adx[sym],         Resolution.Daily)
                            self.WarmUpIndicator(sym, self.stretch_ema[sym], Resolution.Daily)
                        except: pass
                msg = f"[INIT] Adopted orphan: {sym.Value}"
                self.Debug(msg)
                if self.LiveMode: self.Log(msg)

        # Initialise gold position if not already held and not in hedge
        if not self._hedge_active:
            gold_held = self.Portfolio[self.gold].Invested
            if not gold_held:
                self.SetHoldings(self.gold, GOLD_BUDGET)
                self.Debug(f"[INIT] Initialised gold position at {GOLD_BUDGET:.0%}")

        self.Debug(f"[INIT] Symbols={len(self.symbols)} "
                   f"Invested={sum(1 for k in self.Portfolio if k.Value.Invested)} "
                   f"Gold={'UPRO(hedge)' if self._hedge_active else 'FNV'}")

    # =========================================================================
    # END OF ALGORITHM
    # =========================================================================

    def OnEndOfAlgorithm(self):
        eq = self.Portfolio.TotalPortfolioValue
        self.Debug(f"[END] Eq={eq:,.2f} Ret={(eq/100_000-1)*100:+.2f}%")
        self.Debug(f"[END] Hedge trades: {len(self._hedge_trades)}")
        for t in self._hedge_trades:
            self.Debug(
                f"[HEDGE TRADE] "
                f"{t['entry_date']:%Y-%m-%d}→{t['exit_date']:%Y-%m-%d} "
                f"px:{t['entry_price']:.2f}→{t['exit_price']:.2f} "
                f"ret:{t['return']:+.2%} "
                f"stress:{t['entry_stress']:.2f}→{t['exit_stress']:.2f} "
                f"dd:{t['entry_dd']:.2%}→{t['exit_dd']:.2%}"
            )