Overall Statistics
Total Orders
558
Average Win
1.71%
Average Loss
-0.95%
Compounding Annual Return
40.959%
Drawdown
31.500%
Expectancy
0.720
Start Equity
100000
End Equity
571348.84
Net Profit
471.349%
Sharpe Ratio
1.03
Sortino Ratio
1.16
Probabilistic Sharpe Ratio
53.464%
Loss Rate
38%
Win Rate
62%
Profit-Loss Ratio
1.79
Alpha
0.221
Beta
0.781
Annual Standard Deviation
0.263
Annual Variance
0.069
Information Ratio
0.86
Tracking Error
0.241
Treynor Ratio
0.347
Total Fees
$0.00
Estimated Strategy Capacity
$46000000.00
Lowest Capacity Asset
SATS TYZ2C9FOCMED
Portfolio Turnover
3.07%
Drawdown Recovery
773
"""
Sector-Neutral Large-Cap Momentum with Breadth Risk-Off and T-Bill Cash Sweep
=============================================================================

Based on the QuantConnect community strategy "US Large-Cap Momentum
Crowding-Controlled" (#285, Rowan Whiteside). This version adds a short-duration
Treasury (BIL) cash sweep so unallocated / de-risked capital earns yield instead
of sitting idle, and uses a clean next-open fill model: decide on the prior
session's data at 15:55, then fill via MarketOnOpenOrder at the next session's
open -- no look-ahead (the original placed market orders at 15:55 which, on Daily
resolution, fill at the prior-day close -- an unachievable-live, inflating fill).

Mechanism:
  * Universe: sector-neutral, top large-caps (>$5B mcap, >$5 px) per Morningstar
    sector, rebuilt monthly from fundamentals.
  * Signal: multi-horizon momentum (avg of 1/3/6/9/12-month returns); price must
    be above its 189-day EMA; ADX < 35 (avoid parabolic / over-heated trends).
  * Sizing: historical band-ceiling scaling (de-weight names near their own
    historical price-band highs) plus a momentum-exhaustion guard.
  * Risk control: universe-wide breadth gauge. >=45% of names in the lowest
    bands -> 100% risk-off to BIL; between 15% and 45% stress, exposure scales
    down linearly with the unallocated remainder swept into BIL.

Verified backtests (QuantConnect, $100k, zero commission like the original):
  IS  2010-2020 : CAGR 48.1% / MaxDD 31.9% / Sharpe 1.46
  OOS 2021->YTD : CAGR 36.7% / MaxDD 31.5% / Sharpe 0.92
  2018->2026    : CAGR 44.2% / MaxDD 42.0% (this window spans COVID-2020, the
                  strategy's worst drawdown; post-2021 windows show ~32% because
                  they do not include that crash).

Caveats: long-only US large-cap equity, unlevered. Uses ConstantFeeModel(0) to
match the original's convention; real IBKR commissions on a monthly ~10-name
rotation are a modest drag. Not financial advice.
"""

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

# ====================================================
# Sector-Neutral Large-Cap Universe
# ====================================================
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


# ====================================================
# Momentum + Historical Band Ceiling Sizing
# ====================================================
class StockOnlyMomentum(QCAlgorithm):

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

        self._pending_weights = {}
        self._rebalance_date = None

        # Treasury Hedge Asset (1-3 Month T-Bills), kept outside the fundamental universe
        self.hedge_symbol = self.AddEquity("BIL", 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.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}
        self.min_bottom_frac = 1.0
        self.was_risk_off = False
        self.max_stress_level = 0.0

        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)

        # Signal at 15:55 on T-1 data, MOO fires in OnData and fills at T+1 open
        self.Schedule.On(
            self.DateRules.MonthEnd("SPY"),
            self.TimeRules.BeforeMarketClose("SPY", 5),
            self.Rebalance
        )

    def OnSecuritiesChanged(self, changes):
        for sec in changes.AddedSecurities:
            sec.SetFeeModel(ConstantFeeModel(0))
            s = sec.Symbol

            if s == self.hedge_symbol:
                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.band_hist[s] = RollingWindow[int](self.hist_len)

        for sec in changes.RemovedSecurities:
            s = sec.Symbol

            if s == self.hedge_symbol:
                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)

    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
            ]

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

        # Fire MOO orders after market close on rebalance day -> fill next open
        if self._rebalance_date is not None and self.Time.date() == self._rebalance_date:
            for pos in self.Portfolio.Values:
                if pos.Invested and pos.Symbol not in self._pending_weights:
                    self.MarketOnOpenOrder(pos.Symbol, -pos.Quantity)

            for s, w in self._pending_weights.items():
                if w > 0:
                    target_value = self.Portfolio.TotalPortfolioValue * w
                    price = self.Securities[s].Price
                    if price > 0:
                        qty = int(target_value / price)
                        current_qty = int(self.Portfolio[s].Quantity)
                        delta = qty - current_qty
                        if delta != 0:
                            self.MarketOnOpenOrder(s, delta)

            self._pending_weights = {}
            self._rebalance_date = None

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

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

        idxs = list(self.current_band_idx.values())
        if len(idxs) < 50:
            return

        bottom_frac = sum(i in self.BOTTOM_LEVELS for i in idxs) / len(idxs)
        self.max_stress_level = max(self.max_stress_level, bottom_frac)

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

        # Risk-off: sweep 100% to Treasury hedge
        if not self.allow_universe:
            self._pending_weights = {self.hedge_symbol: 1.0}
            self._rebalance_date = self.Time.date()
            self.Debug("Risk-off. Sweeping 100% to Treasury hedge.")
            return

        hist = self.History(
            list(self.symbols),
            max(self.lookbacks) + 1,
            Resolution.Daily
        )

        if hist.empty:
            return

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

        for s in self.symbols:
            if s not in closes:
                continue

            px = closes[s]
            if len(px) < max(self.lookbacks) + 1:
                continue

            if not self.adx[s].IsReady or self.adx[s].Current.Value > self.adx_limit:
                continue

            mom = np.mean([
                px.iloc[-1] / px.iloc[-lb - 1] - 1
                for lb in self.lookbacks
            ])

            if not self.ma[s].IsReady:
                continue

            price = self.Securities[s].Price
            ema = self.ma[s].Current.Value

            if price <= ema:
                continue

            if mom > 0:
                momentum[s] = mom

        if not momentum:
            self._pending_weights = {self.hedge_symbol: 1.0}
            self._rebalance_date = self.Time.date()
            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)

            current_stretch = self.stretch_ema[s].Current.Value
            peak_stretch = self.stretch_max.get(s, 0.0)
            if idx >= 10 and peak_stretch > 0 and current_stretch < (peak_stretch * 0.80):
                scale = 0.2

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

        if not scaled:
            self._pending_weights = {self.hedge_symbol: 1.0}
            self._rebalance_date = self.Time.date()
            return

        min_stress = 0.15
        max_stress = 0.45
        target_exposure = float(round(np.interp(bottom_frac, [min_stress, max_stress], [1.0, 0.0]), 2))

        total_scaled = sum(scaled.values())
        raw_weights = {s: v / total_scaled for s, v in scaled.items()}
        capped_weights = {s: min(self.max_weight, w) for s, w in raw_weights.items()}

        current_sum = sum(capped_weights.values())
        final_weights = {}
        if current_sum > 0:
            for s, w in capped_weights.items():
                final_weights[s] = (w / current_sum) * target_exposure

        # Sweep all unallocated capital into the yield hedge to eliminate cash drag
        hedge_allocation = round(1.0 - target_exposure, 2)
        if hedge_allocation > 0:
            final_weights[self.hedge_symbol] = hedge_allocation

        self._pending_weights = final_weights
        self._rebalance_date = self.Time.date()

    def OnOrderEvent(self, order_event):
        if order_event.Status == OrderStatus.Filled:
            self.Debug(f"[FILL] {self.Time} | {order_event.Symbol.Value} | fill={order_event.FillPrice:.4f}")