Overall Statistics
Total Orders
418
Average Win
3.21%
Average Loss
-1.05%
Compounding Annual Return
37.971%
Drawdown
42.200%
Expectancy
1.025
Start Equity
10000
End Equity
51200.31
Net Profit
412.003%
Sharpe Ratio
0.853
Sortino Ratio
0.936
Probabilistic Sharpe Ratio
34.517%
Loss Rate
50%
Win Rate
50%
Profit-Loss Ratio
3.07
Alpha
0.208
Beta
1.116
Annual Standard Deviation
0.32
Annual Variance
0.102
Information Ratio
0.77
Tracking Error
0.279
Treynor Ratio
0.245
Total Fees
$444.42
Estimated Strategy Capacity
$32000000.00
Lowest Capacity Asset
SATS TYZ2C9FOCMED
Portfolio Turnover
3.17%
Drawdown Recovery
1071
# region imports
from AlgorithmImports import *
from collections import defaultdict, deque
import calendar
import numpy as np
import pandas as pd
# endregion

# =============================================================================
# FROZEN — imports only.  The mutator owns the ENTIRE algorithm: universe model,
# every hook, every method, the class itself.  Maximum flexibility.
#
# Conventions (not hash-enforced; sys_msg.md flags them as expectations):
#   - Read backtest window from QC parameters via self.get_parameter so one
#     file covers IS / OOS (evaluate.py drives the window).
#   - Keep a deployable cost model + PLAN long-only / no-margin posture; the
#     score is what is judged.
# =============================================================================

# EVOLVE-BLOCK-START
class SectorTopUniverse(FundamentalUniverseSelectionModel):
    """Sector-neutral large-cap universe: top-N by market cap per 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 f.price is None or f.price <= 5:
                continue
            if f.market_cap is None or f.market_cap < 5_000_000_000:
                continue
            sector = f.asset_classification.morningstar_sector_code
            if sector is None:
                continue
            buckets[sector].append(f)
        symbols = []
        for _, stocks in buckets.items():
            stocks.sort(key=lambda x: x.market_cap, reverse=True)
            symbols.extend(s.symbol for s in stocks[:100])
        return symbols
class StockOnlyMomentum(QCAlgorithm):
    def Initialize(self):
        sy = int(self.get_parameter("start_year", "2010"))
        sm = int(self.get_parameter("start_month", "1"))
        ey = int(self.get_parameter("end_year", "2020"))
        em = int(self.get_parameter("end_month", "12"))
        ed = calendar.monthrange(ey, em)[1]
        self.SetStartDate(sy, sm, 1)
        self.SetEndDate(ey, em, ed)
        self.SetCash(10_000)
        # gen316 proven: regime-adaptive lookback weights
        self.lookbacks = [21, 42, 63, 126, 189]
        self.mom_weights_bull = [0.25, 0.20, 0.20, 0.20, 0.15]
        self.mom_weights_choppy = [0.10, 0.15, 0.20, 0.25, 0.30]
        self.stock_count_base = 10
        self.stock_count_choppy = 5
        self.choppy_threshold = 0.25
        self.max_weight = 0.20
        self.band_len = 189
        self.hist_len = 126
        self.UniverseSettings.Resolution = Resolution.Daily
        self.UniverseSettings.DataNormalizationMode = DataNormalizationMode.TOTAL_RETURN
        self.allow_universe = True
        self.current_band_idx = {}
        self.BOTTOM_LEVELS = {0, 1, 2, 3, 4}
        self.was_risk_off = False
        self.max_stress_level = 0.0
        self.prev_bottom_frac = None
        self.bull_streak = 0
        self.portfolio_peak = 10_000.0
        # Time-adaptive recovery: months spent in risk-off (gen463 proven)
        self.risk_off_months = 0
        # Smooth breadth EMA: 3-month decay for exposure interpolation (gen602 idea)
        # Keeps raw bottom_frac for binary risk-off trigger; smooth version for exposure
        self.smooth_breadth = None
        self.smooth_breadth_alpha = 0.33  # ~3-month EMA
        self.SetUniverseSelection(
            SectorTopUniverse(self, blacklist={"GME", "AMC"})
        )
        self.symbols = set()
        self.symbol_to_sector = {}
        self.adx_limit = 35
        self.adx_period = 14
        self.ma = {}
        self.adx = {}
        self.stretch_max = {}
        self.close_win = {}
        self.stretch_ema = {}
        self.band_hist = {}
        # Safe-haven bond ETF: half the cash buffer rotates here during stress (inspiration proven)
        self.bond_syms = set()
        _ief = self.AddEquity("IEF", Resolution.Daily)
        _ief.SetFeeModel(InteractiveBrokersFeeModel()); _ief.SetSlippageModel(ConstantSlippageModel(0.001))
        self.bond_etf = _ief.Symbol
        self.bond_syms.add(self.bond_etf)
        _spy = self.AddEquity("SPY", Resolution.Daily)
        _spy.SetFeeModel(InteractiveBrokersFeeModel())
        self.spy_symbol = _spy.Symbol
        self.bond_syms.add(self.spy_symbol)
        self.spy_sma200 = self.SMA("SPY", 200, Resolution.Daily)
        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:
            s = sec.Symbol
            if s in self.bond_syms:
                continue
            sec.SetFeeModel(InteractiveBrokersFeeModel()); sec.SetSlippageModel(ConstantSlippageModel(0.001))
            self.symbols.add(s)
            self.stretch_max[s] = 0.0
            self.ma[s] = self.EMA(s, self.band_len, Resolution.Daily)
            self.adx[s] = self.ADX(s, self.adx_period, Resolution.Daily)
            self.stretch_ema[s] = self.EMA(s, self.band_len, Resolution.Daily)
            self.close_win[s] = RollingWindow[float](self.band_len)
            self.band_hist[s] = RollingWindow[int](self.hist_len)
            try:
                sector_code = sec.Fundamentals.AssetClassification.MorningstarSectorCode
                if sector_code is not None and sector_code != 0:
                    self.symbol_to_sector[s] = sector_code
            except Exception:
                pass
        for sec in changes.RemovedSecurities:
            s = sec.Symbol
            if s in self.bond_syms:
                continue
            self.symbols.discard(s)
            self.ma.pop(s, None)
            self.adx.pop(s, None)
            self.stretch_max.pop(s, None)
            self.stretch_ema.pop(s, None)
            self.close_win.pop(s, None)
            self.band_hist.pop(s, None)
            self.current_band_idx.pop(s, None)
            self.symbol_to_sector.pop(s, None)
    def OnData(self, data):
        for s in list(self.symbols):
            if not data.ContainsKey(s):
                continue
            bar = data[s]
            if bar is None:
                continue
            close = bar.Close
            self.close_win[s].Add(close)
            if not self.close_win[s].IsReady or not self.ma[s].IsReady:
                continue
            dev = np.std(list(self.close_win[s]))
            if dev <= 0:
                continue
            mid = self.ma[s].Current.Value
            stretch = abs(close - mid) / dev
            self.stretch_ema[s].Update(self.Time, stretch)
            if stretch > self.stretch_max[s]:
                self.stretch_max[s] = stretch
            bands = [
                mid - dev * 1.618, mid - dev * 1.382, mid - dev,
                mid - dev * 0.809, mid - dev * 0.5, mid - dev * 0.382, mid,
                mid + dev * 0.382, mid + dev * 0.5, mid + dev * 0.809,
                mid + dev, mid + dev * 1.382, mid + dev * 1.618
            ]
            self.current_band_idx[s] = self._band_index(close, bands)
    def _band_index(self, price, bands):
        for i in range(len(bands) - 1):
            if bands[i] <= price < bands[i + 1]:
                return i
        return len(bands) - 2
    def Rebalance(self):
        if self.IsWarmingUp:
            return
        idxs = list(self.current_band_idx.values())
        if len(idxs) < 50:
            return
        # Stock-level breadth
        stock_bottom_frac = sum(i in self.BOTTOM_LEVELS for i in idxs) / len(idxs)
        # Sector-level breadth (gen82 proven: 50/50 blend)
        sector_bottoms = defaultdict(list)
        for s, idx in self.current_band_idx.items():
            sector = self.symbol_to_sector.get(s)
            if sector is not None:
                sector_bottoms[sector].append(idx in self.BOTTOM_LEVELS)
        if sector_bottoms:
            sector_stress_count = sum(
                1 for flags in sector_bottoms.values()
                if len(flags) > 0 and sum(flags) / len(flags) > 0.50
            )
            sector_bottom_frac = sector_stress_count / len(sector_bottoms)
            bottom_frac = 0.5 * stock_bottom_frac + 0.5 * sector_bottom_frac
        else:
            bottom_frac = stock_bottom_frac
        # Update smoothed breadth EMA (3-month decay) for exposure interpolation
        # Smooth signal reduces whipsaw in exposure from single-month stress spikes
        if self.smooth_breadth is None:
            self.smooth_breadth = bottom_frac
        else:
            self.smooth_breadth = (self.smooth_breadth_alpha * bottom_frac
                                   + (1.0 - self.smooth_breadth_alpha) * self.smooth_breadth)
        # Stress rate of change (raw signal — used for EMA gate and RoC brake)
        stress_roc = 0.0
        if self.prev_bottom_frac is not None:
            stress_roc = bottom_frac - self.prev_bottom_frac
        self.prev_bottom_frac = bottom_frac
        self.max_stress_level = max(self.max_stress_level, bottom_frac)
        if bottom_frac < 0.20:
            self.bull_streak += 1
        else:
            self.bull_streak = 0
        # Dual recovery threshold (gen463 proven: velocity + time-adaptive)
        stress_improvement_velocity = max(0.0, -stress_roc)
        velocity_threshold = max(0.25, 0.55 - 3.0 * stress_improvement_velocity)
        time_threshold = max(0.35, 0.55 - 0.10 * max(0, self.risk_off_months - 1))
        adaptive_recovery_threshold = min(velocity_threshold, time_threshold)
        # Portfolio equity tracking
        current_value = self.Portfolio.TotalPortfolioValue
        self.portfolio_peak = max(self.portfolio_peak, current_value)
        # Risk-off state machine uses raw bottom_frac (need crisp trigger at 0.45)
        if bottom_frac >= 0.45:
            self.allow_universe = False
            self.was_risk_off = True
            self.risk_off_months += 1
        elif self.was_risk_off:
            denominator = max(self.max_stress_level, 0.10)
            improvement = (self.max_stress_level - bottom_frac) / denominator
            if improvement >= adaptive_recovery_threshold or bottom_frac < 0.15:
                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.prev_bottom_frac = None
                self.bull_streak = 0
                self.risk_off_months = 0
                # Reset smooth breadth and peak on re-entry
                self.smooth_breadth = bottom_frac
                self.portfolio_peak = current_value
            else:
                self.risk_off_months += 1
        else:
            self.allow_universe = True
            self.risk_off_months = 0
        if not self.allow_universe:
            self.Liquidate()
            return
        # Exposure uses SMOOTH breadth (gen602 synthesis): reduces single-month whipsaws
        # while raw bottom_frac still gates the binary risk-off trigger
        smooth_frac = self.smooth_breadth if self.smooth_breadth is not None else bottom_frac
        min_stress_eff = 0.25 if self.bull_streak >= 3 else 0.15
        target_exposure = float(np.interp(smooth_frac, [min_stress_eff, 0.45], [1.0, 0.0]))
        # RoC early warning on raw signal (gen524 proven: 0.60x cut)
        if stress_roc > 0.12:
            target_exposure *= 0.60
        # Unconditional equity DD brake (gen524 proven)
        eq_dd = (self.portfolio_peak - current_value) / self.portfolio_peak if self.portfolio_peak > 0 else 0.0
        if eq_dd > 0.15:
            brake_factor = max(0.50, 1.0 - (eq_dd - 0.15) / 0.10)
            target_exposure *= brake_factor
        if self.spy_sma200.IsReady:
            spy_price = self.Securities[self.spy_symbol].Price
            spy_sma_val = self.spy_sma200.Current.Value
            if spy_sma_val > 0 and spy_price < spy_sma_val:
                pct_below = (spy_sma_val - spy_price) / spy_sma_val
                spy_brake = max(0.40, 1.0 - pct_below * 6.0)
                target_exposure *= spy_brake
        # Fast crash gate: median 21d return of universe stocks — responds within
        # one rebalance cycle vs weeks for band migration (gen6 proven, faster than weekly trend overlay)
        pre_hist = self.History(list(self.symbols), 22, Resolution.Daily)
        if not pre_hist.empty:
            pre_closes = pre_hist["close"].unstack(0)
            if pre_closes.shape[0] >= 22:
                med_ret_21d = float((pre_closes.iloc[-1] / pre_closes.iloc[0]).median()) - 1.0
                if med_ret_21d < -0.05:
                    crash_mult = max(0.30, 1.0 + med_ret_21d * 5.0)
                    target_exposure *= crash_mult
        target_exposure = float(round(max(0.0, min(1.0, target_exposure)), 2))
        # Dynamic stock count: concentrate in choppy regimes (gen100 proven)
        stock_count = self.stock_count_choppy if bottom_frac >= self.choppy_threshold else self.stock_count_base
        hist = self.History(list(self.symbols), max(self.lookbacks) + 1, Resolution.Daily)
        if hist.empty:
            return
        closes = hist["close"].unstack(0)
        # Regime-adaptive momentum weights (gen316 proven)
        mom_weights = self.mom_weights_choppy if bottom_frac >= self.choppy_threshold else self.mom_weights_bull
        # Adaptive EMA penalty: remove during active breadth recovery so early-cycle leaders
        # below EMA aren't suppressed (inspiration proven: better than fixed 0.5x)
        recovering = stress_roc < 0.0
        ema_penalty = 1.0 if recovering else 0.5
        momentum = {}
        for s in self.symbols:
            if s not in closes:
                continue
            px = closes[s]
            if len(px) < max(self.lookbacks) + 1:
                continue
            # Adaptive ADX gate (gen293 proven)
            adx_limit_eff = 45 if bottom_frac >= self.choppy_threshold else self.adx_limit
            if not self.adx[s].IsReady or self.adx[s].Current.Value > adx_limit_eff:
                continue
            lb_returns = [px.iloc[-1] / px.iloc[-lb - 1] - 1 for lb in self.lookbacks]
            mom = sum(w * r for w, r in zip(mom_weights, lb_returns))
            if not self.ma[s].IsReady:
                continue
            price = self.Securities[s].Price
            ema = self.ma[s].Current.Value
            if mom > 0:
                if price <= ema:
                    momentum[s] = mom * ema_penalty
                else:
                    momentum[s] = mom
        if not momentum:
            self.Liquidate()
            return
        # Incumbent retention buffer (gen274 proven)
        INCUMBENT_BUFFER = 3
        current_invested = {pos.Symbol for pos in self.Portfolio.Values
                            if pos.Invested and pos.Quantity > 0}
        ranked = sorted(momentum, key=momentum.get, reverse=True)
        top_definite = ranked[:stock_count]
        top_incumbent = [s for s in ranked[stock_count:stock_count + INCUMBENT_BUFFER]
                         if s in current_invested]
        top = top_definite + top_incumbent
        # Band-scaled momentum sizing with dynamic scale floor
        scaled = {}
        vol_20d = {}
        for s in top:
            if not self.ma[s].IsReady or not self.stretch_ema[s].IsReady:
                continue
            dev = np.std(list(self.close_win[s]))
            if dev <= 0:
                continue
            mid = self.ma[s].Current.Value
            lm = self.stretch_ema[s].Current.Value
            lm2 = lm / 2.0
            lm3 = lm2 * 0.38196601
            lm4 = lm * 1.38196601
            lm5 = lm * 1.61803399
            lm6 = (lm + lm2) / 2.0
            bands = [
                mid - dev * lm5, mid - dev * lm4, mid - dev * lm,
                mid - dev * lm6, mid - dev * lm2, mid - dev * lm3, mid,
                mid + dev * lm3, mid + dev * lm2, mid + dev * lm6,
                mid + dev * lm, mid + dev * lm4, mid + dev * lm5
            ]
            price = self.Securities[s].Price
            idx = self._band_index(price, bands)
            self.band_hist[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:
                # Dynamic scale floor: raise during active recovery to capture rally leaders
                # still in lower bands post-crash (both parents proven)
                if stress_roc < -0.05:
                    dyn_floor = 0.35
                elif stress_roc < 0:
                    dyn_floor = 0.20
                else:
                    dyn_floor = 0.10
                scale = max(dyn_floor, 1.0 - idx / historical_high)
            current_stretch = self.stretch_ema[s].Current.Value
            peak_stretch = self.stretch_max.get(s, 0.0)
            if idx >= 10 and peak_stretch > 0:
                if current_stretch < (peak_stretch * 0.80):
                    scale = 0.2
            # No consistency multiplier (gen516 proven: cleaner signal ranking)
            scaled[s] = momentum[s] * scale
            px_s = closes[s]
            if len(px_s) >= 21:
                vol_20d[s] = float(px_s.pct_change().iloc[-20:].std())
            else:
                vol_20d[s] = 0.015
        if not scaled:
            self.Liquidate()
            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()}
        # Flat vol cap at 0.015 (gen293/314 proven)
        for s in list(capped_weights.keys()):
            vol = vol_20d.get(s, 0.015)
            if vol > 0:
                vol_cap_factor = min(1.0, 0.015 / vol)
                capped_weights[s] = min(capped_weights[s], self.max_weight * vol_cap_factor)
        current_sum = sum(capped_weights.values())
        final_weights = {}
        if current_sum > 0:
            for s, w in capped_weights.items():
                final_weights[s] = (w / current_sum) * target_exposure
        # Bond buffer: half the cash reserve → IEF during stress (inspiration proven)
        ief_share = 0.50
        if self.spy_sma200.IsReady:
            spy_price = self.Securities[self.spy_symbol].Price
            spy_sma_val = self.spy_sma200.Current.Value
            if spy_sma_val > 0 and spy_price < spy_sma_val:
                ief_share = 0.75
        bond_alloc = round((1.0 - target_exposure) * ief_share, 2)
        if bond_alloc > 0.01:
            final_weights[self.bond_etf] = bond_alloc
        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)
# EVOLVE-BLOCK-END