Overall Statistics
Total Orders
634
Average Win
0.58%
Average Loss
-0.26%
Compounding Annual Return
21.830%
Drawdown
18.600%
Expectancy
1.274
Start Equity
100000
End Equity
268493.11
Net Profit
168.493%
Sharpe Ratio
0.869
Sortino Ratio
1.007
Probabilistic Sharpe Ratio
61.198%
Loss Rate
30%
Win Rate
70%
Profit-Loss Ratio
2.27
Alpha
0.072
Beta
0.757
Annual Standard Deviation
0.131
Annual Variance
0.017
Information Ratio
0.707
Tracking Error
0.082
Treynor Ratio
0.15
Total Fees
$724.54
Estimated Strategy Capacity
$130000000.00
Lowest Capacity Asset
QUAL VIBZ5HTB7N8L
Portfolio Turnover
2.68%
Drawdown Recovery
227
# region imports
from AlgorithmImports import *
# endregion


class IhaventdecidedTUESDAY(QCAlgorithm):
    _SHORT_LB = 5
    _LONG_LB = 30
    _FF_ENTER_RISK_OFF = 0.20
    _FF_ENTER_RISK_ON = 0.05
    _MOM_LOOKBACK_D = 90
    _MOM_REBALANCE_DAYS = 30
    _CORE_REBALANCE_DAYS = 30
    _SLEEVE_NEUTRAL = 0.10
    _OPT_LOOKBACK_D = 126
    _OPT_MAX_WEIGHT = 0.50
    _SMA_LONG = 200
    _CASH_BUFFER = 0.98

    def initialize(self):
        self.set_start_date(self.end_date - timedelta(5 * 365))
        self.set_cash(100000)
        np.random.seed(7)
        self._spy = self.add_equity("SPY", Resolution.DAILY)
        self._splv = self.add_equity("SPLV", Resolution.DAILY)
        self._qual = self.add_equity("QUAL", Resolution.DAILY)
        self._qqq = self.add_equity("QQQ", Resolution.DAILY)
        self._nvda = self.add_equity("NVDA", Resolution.DAILY)
        self._app = self.add_equity("APP", Resolution.DAILY)
        self._gld = self.add_equity("GLD", Resolution.DAILY)
        self._ief = self.add_equity("IEF", Resolution.DAILY)
        self._shy = self.add_equity("SHY", Resolution.DAILY)
        self._cls = self.add_equity("CLS", Resolution.DAILY)
        self._fix = self.add_equity("FIX", Resolution.DAILY)
        self._core_symbols = {
            "SPY": self._spy, "SPLV": self._splv, "QUAL": self._qual, 
            "QQQ": self._qqq, "NVDA": self._nvda, "APP": self._app, 
            "GLD": self._gld, "IEF": self._ief, "SHY": self._shy, 
            "CLS": self._cls, "FIX": self._fix
        }
        self._spy_prev_close = None
        self._spy_ret_short = []
        self._spy_ret_long = []
        self._current_ff = None
        self._momo_symbols = [
            self.add_equity(ticker, Resolution.DAILY) for ticker in 
            ["AAPL", "MSFT", "GOOGL", "AMZN", "META", "QQQ", "NVDA", "AVGO", "ORCL", "COST", "LLY"]
        ]
        self._short_sma = self.sma(self._spy, 50, Resolution.DAILY)
        self._long_sma = self.sma(self._spy, self._SMA_LONG, Resolution.DAILY)
        self._next_core_rebalance = self.time
        self._next_momentum_rebalance = self.time
        self._momo_high = {}
        self._momo_hold = []
        self._last_selection = []
        self.set_warm_up(max(self._LONG_LB + 2, self._SMA_LONG + 5))
        self.set_benchmark(self._spy)
        # Prime rolling return windows from history to avoid a cold start.
        history = self.history([self._spy.symbol], self._LONG_LB + 2, Resolution.DAILY)
        if not history.empty:
            symbol_key = str(self._spy.symbol)
            if symbol_key in history.index.get_level_values(0):
                closes = list(history.loc[symbol_key]["close"])
                for i in range(1, len(closes)):
                    if len(self._spy_ret_long) >= self._LONG_LB:
                        self._spy_ret_long.pop(0)
                    self._spy_ret_long.append(closes[i] / closes[i - 1] - 1.0)
                if len(self._spy_ret_long) >= self._SHORT_LB:
                    self._spy_ret_short = self._spy_ret_long[-self._SHORT_LB:]
                if closes:
                    self._spy_prev_close = closes[-1]
        self.schedule.on(self.date_rules.every_day(self._spy), self.time_rules.at(8, 0), self._scheduled_rebalance)

    def on_warmup_finished(self):
        self._next_core_rebalance = self.time
        self._next_momentum_rebalance = self.time
        if not self._is_tradable(self._spy):
            return
        self._rebalance_core()
        self._rebalance_momentum(force=True)
        self._next_core_rebalance = self.time + timedelta(days=self._CORE_REBALANCE_DAYS)
        self._next_momentum_rebalance = self.time + timedelta(days=self._MOM_REBALANCE_DAYS)

    def on_data(self, data: Slice):
        if self._spy.symbol in data.bars:
            close = data.bars[self._spy.symbol].close
            if self._spy_prev_close is not None and self._spy_prev_close > 0:
                spy_return = close / self._spy_prev_close - 1.0
                if len(self._spy_ret_long) >= self._LONG_LB:
                    self._spy_ret_long.pop(0)
                self._spy_ret_long.append(spy_return)
                if len(self._spy_ret_short) >= self._SHORT_LB:
                    self._spy_ret_short.pop(0)
                self._spy_ret_short.append(spy_return)
            self._spy_prev_close = close
            # Recompute the Fama-French volatility-ratio regime factor inline.
            if len(self._spy_ret_short) < self._SHORT_LB or len(self._spy_ret_long) < self._LONG_LB:
                self._current_ff = None
            else:
                sigma_short = np.std(self._spy_ret_short, ddof=1)
                self._current_ff = None if sigma_short <= 0 else (np.std(self._spy_ret_long, ddof=1) - sigma_short) / sigma_short
        if not self.is_warming_up and self._momo_hold:
            # Update trailing highs and liquidate any holding that breaches its stop.
            for security in list(self._momo_hold):
                if not self._is_tradable(security):
                    continue
                price = security.price
                self._momo_high[security] = max(self._momo_high.get(security, price), price)
                if price <= self._momo_high[security] * (1.0 - 0.20):
                    self.liquidate(security)
                    self._momo_hold.remove(security)
                    if security in self._momo_high:
                        del self._momo_high[security]

    def _current_sleeve_size(self):
        if self._current_ff is None:
            return self._SLEEVE_NEUTRAL
        if self._current_ff >= self._FF_ENTER_RISK_OFF:
            return 0.00
        if self._current_ff < self._FF_ENTER_RISK_ON:
            return 0.30
        return self._SLEEVE_NEUTRAL

    def _rebalance_core(self):
        # Pick the FF-regime weight set inline and normalize it to sum to one.
        if self._current_ff is None or self._FF_ENTER_RISK_ON <= self._current_ff < self._FF_ENTER_RISK_OFF:
            weights = {
                "SPY": 0.51, "SPLV": 0.20, "QUAL": 0.08, "QQQ": 0.06, "NVDA": 0.04, 
                "APP": 0.02, "GLD": 0.03, "IEF": 0.01, "SHY": 0.00, "CLS": 0.04, "FIX": 0.01
            }
        elif self._current_ff >= self._FF_ENTER_RISK_OFF:
            weights = {
                "SPY": 0.15, "SPLV": 0.25, "QUAL": 0.05, "QQQ": 0.00, "NVDA": 0.00, 
                "APP": 0.00, "GLD": 0.25, "IEF": 0.20, "SHY": 0.10, "CLS": 0.00, "FIX": 0.00
            }
        else:
            weights = {
                "SPY": 0.64, "SPLV": 0.00, "QUAL": 0.08, "QQQ": 0.10, "NVDA": 0.07, "APP": 0.03, 
                "GLD": 0.00, "IEF": 0.00, "SHY": 0.00, "CLS": 0.06, "FIX": 0.02
            }
        total_weight = sum(weights.values())
        if total_weight > 0:
            for ticker in weights:
                weights[ticker] = weights[ticker] / total_weight
        core_scale = (1.0 - self._current_sleeve_size()) * self._CASH_BUFFER
        # Keep only tradeable core names, then scale by the non-sleeve budget.
        targets = {}
        for ticker in weights:
            security = self._core_symbols[ticker]
            if self._is_tradable(security):
                targets[security] = weights[ticker]
        if not targets:
            targets[self._spy] = 1.0
        total_target = sum(targets.values())
        if total_target <= 0:
            targets = {self._spy: 1.0}
            total_target = 1.0
        for security in targets:
            targets[security] = targets[security] / total_target * core_scale
        # Liquidate dropped names, then set the scaled core targets.
        for security in self._core_symbols.values():
            if security not in targets and security.holdings.invested:
                self.liquidate(security)
        for security in targets:
            if self._is_tradable(security):
                self.set_holdings(security, targets[security])

    def _scheduled_rebalance(self):
        if self.is_warming_up:
            return
        if not self._is_tradable(self._spy):
            return
        if self.time >= self._next_core_rebalance:
            self._rebalance_core()
            self._next_core_rebalance = self.time + timedelta(days=self._CORE_REBALANCE_DAYS)
        if self.time >= self._next_momentum_rebalance:
            self._rebalance_momentum(force=False)
            self._next_momentum_rebalance = self.time + timedelta(days=self._MOM_REBALANCE_DAYS)

    def _rebalance_momentum(self, force: bool):
        if self.is_warming_up and not force:
            return
        sleeve = self._current_sleeve_size()
        # Liquidate and clear the sleeve when fully risk-off or the SPY trend is weak.
        if sleeve <= 0 or not (self._short_sma.is_ready and self._long_sma.is_ready and self._short_sma.current.value >= self._long_sma.current.value):
            for security in list(self._momo_hold):
                if security.holdings.invested:
                    self.liquidate(security)
            self._momo_hold = []
            self._momo_high = {}
            self._last_selection = []
            return
        # Rank the momentum universe by annualized log-price regression slope.
        momentum = {}
        for security in self._momo_symbols:
            history = self.history(security.symbol, self._MOM_LOOKBACK_D, Resolution.DAILY)
            if history.empty:
                continue
            closes = history["close"].values
            if len(closes) < self._MOM_LOOKBACK_D * 0.8:
                continue
            close_array = np.asarray(closes, dtype=float)
            momentum[security] = float(np.polyfit(np.arange(len(close_array), dtype=float), np.log(np.maximum(close_array, 1e-8)), 1)[0] * 252.0)
        ranked = [security for security, score in sorted(momentum.items(), key=lambda item: item[1], reverse=True)]
        if not ranked:
            return
        selection = ranked[:min(4, len(ranked))]
        if set(selection) == set(self._last_selection) and all(security.holdings.invested for security in selection) and not force:
            return
        # Size the selection with a Monte Carlo Sharpe search over recent returns.
        price_data = []
        for security in selection:
            history = self.history(security.symbol, self._OPT_LOOKBACK_D + 1, Resolution.DAILY)
            if history.empty:
                continue
            closes = history["close"].values
            if len(closes) < self._OPT_LOOKBACK_D:
                continue
            returns = [closes[i] / closes[i - 1] - 1.0 for i in range(1, len(closes)) if closes[i - 1] > 0]
            if len(returns) < self._OPT_LOOKBACK_D * 0.8:
                continue
            price_data.append((security, returns))
        if not price_data:
            optimized_weights = {security: sleeve * self._CASH_BUFFER / len(selection) for security in selection}
        else:
            securities = [security for security, returns in price_data]
            returns_list = [returns for security, returns in price_data]
            min_len = min(len(returns) for returns in returns_list)
            returns_matrix = np.asarray([returns[-min_len:] for returns in returns_list], dtype=float).T
            best_score = -1000000
            best_weights = None
            count = len(securities)
            for i in range(4000):
                weights = np.random.dirichlet(np.ones(count))
                for j in range(count):
                    if weights[j] > self._OPT_MAX_WEIGHT:
                        weights[j] = self._OPT_MAX_WEIGHT
                total_weight = np.sum(weights)
                if total_weight <= 0:
                    continue
                weights = weights / total_weight
                portfolio_returns = np.dot(returns_matrix, weights)
                volatility = np.std(portfolio_returns, ddof=1) * np.sqrt(252)
                if volatility <= 0:
                    continue
                score = (np.mean(portfolio_returns) * 252) / volatility
                if score > best_score:
                    best_score = score
                    best_weights = weights
            if best_weights is None:
                optimized_weights = {security: sleeve * self._CASH_BUFFER / len(securities) for security in securities}
            else:
                optimized_weights = {securities[i]: float(best_weights[i]) * sleeve * self._CASH_BUFFER for i in range(len(securities))}
        for security in list(self._momo_hold):
            if security not in selection:
                self.liquidate(security)
                self._momo_hold.remove(security)
                if security in self._momo_high:
                    del self._momo_high[security]
        for security in optimized_weights:
            if not self._is_tradable(security):
                continue
            self.set_holdings(security, optimized_weights[security])
            price = security.price
            self._momo_high[security] = max(self._momo_high.get(security, price), price)
            if security not in self._momo_hold:
                self._momo_hold.append(security)
        self._last_selection = selection

    def _is_tradable(self, security):
        return security.has_data and security.is_tradable and security.price > 0 and not np.isnan(security.price)