Overall Statistics
Total Orders
197
Average Win
5.04%
Average Loss
-1.30%
Compounding Annual Return
93.902%
Drawdown
25.500%
Expectancy
2.725
Start Equity
100000
End Equity
2734362.34
Net Profit
2634.362%
Sharpe Ratio
2.05
Sortino Ratio
2.418
Probabilistic Sharpe Ratio
97.479%
Loss Rate
24%
Win Rate
76%
Profit-Loss Ratio
3.89
Alpha
0.567
Beta
0.729
Annual Standard Deviation
0.296
Annual Variance
0.088
Information Ratio
1.968
Tracking Error
0.28
Treynor Ratio
0.834
Total Fees
$1141.22
Estimated Strategy Capacity
$640000000.00
Lowest Capacity Asset
IGW S6BDJ8ONH2ZP
Portfolio Turnover
2.37%
Drawdown Recovery
212
# Big Tech + AI hardware throttle rotation strategy.
# Version 20: VDE in defensive + inverse-vol weighting for Sharpe.

# region imports
from AlgorithmImports import *
# endregion


class BigTechThrottleRotation(QCAlgorithm):

    def initialize(self):
        self.set_cash(100000)
        self.set_start_date(self.end_date - timedelta(5 * 365))
        self._tech_tickers = ["FNGS", "QQQ", "IYW", "SMH", "TSM", "STX", "EWY", "VDE", "NVDA", "SOXX", "XLK", "VUG", "IWF", "PLTR", "WDC"]
        self._defensive_tickers = ["BIL", "SHY", "IEF", "GLD", "TLT", "XLE", "XLV", "VDE", "XON"]
        # Deduplicate while preserving order, since VDE appears in both sleeves.
        self._all_tickers = []
        for ticker in self._tech_tickers + self._defensive_tickers:
            if ticker not in self._all_tickers:
                self._all_tickers.append(ticker)
        self._symbol_by_ticker = {ticker: self.add_equity(ticker, Resolution.DAILY).symbol for ticker in self._all_tickers}
        self._market = self._symbol_by_ticker["QQQ"]
        self._risk_check = self._symbol_by_ticker["FNGS"]
        self._lookback_days = 252
        self._target_exposure = 1.0
        self._last_target_by_ticker = {ticker: 0 for ticker in self._all_tickers}
        self.set_warm_up(self._lookback_days + 5, Resolution.DAILY)
        self.schedule.on(self.date_rules.month_start(self._market), self.time_rules.at(8, 0), self._rebalance)

    def on_warmup_finished(self):
        self._rebalance()

    def _rebalance(self):
        if self.is_warming_up:
            return
        history = self.history(list(self._symbol_by_ticker.values()), self._lookback_days, Resolution.DAILY)
        if history.empty:
            bil_only = self._zero_weights()
            bil_only["BIL"] = self._target_exposure
            self._apply_targets(bil_only)
            return
        regime = self._market_regime(history)
        tech_scores = self._score_candidates(history, self._tech_tickers)
        defensive_scores = self._score_candidates(history, self._defensive_tickers)
        vol_scale = self._volatility_scale(history)
        if regime == "bull":
            target_weights = self._bull_market_weights(tech_scores, vol_scale)
        elif regime == "neutral":
            target_weights = self._neutral_market_weights(tech_scores, defensive_scores, vol_scale)
        else:
            target_weights = self._bear_market_weights(history, defensive_scores)
        # Skip the rebalance when no single target moved more than the minimum change threshold.
        if max(abs(target_weights.get(ticker, 0) - self._last_target_by_ticker[ticker]) for ticker in self._all_tickers) < 0.10:
            return
        self._apply_targets(target_weights)

    def _market_regime(self, history):
        qqq_close = self._get_close_series(history, self._market)
        if qqq_close is None or qqq_close.size < 200:
            return "neutral"
        fngs_close = self._get_close_series(history, self._risk_check)
        qqq_now = qqq_close.iloc[-1]
        qqq_sma_200 = qqq_close.tail(200).mean()
        if qqq_now < qqq_sma_200:
            return "bear"
        if fngs_close is not None and fngs_close.size >= 100 and qqq_now < qqq_close.tail(100).mean() and fngs_close.iloc[-1] < fngs_close.tail(100).mean():
            return "bear"
        if qqq_now < qqq_close.tail(50).mean() and qqq_close.size >= 21 and qqq_now / qqq_close.iloc[-21] - 1 < -0.08:
            return "bear"
        if qqq_now > qqq_sma_200 * 1.03:
            return "bull"
        return "neutral"

    def _bull_market_weights(self, tech_scores, vol_scale):
        # Bull: score-weight the three strongest tech names, supplementing with QQQ if needed.
        strong_tech = {ticker: score for ticker, score in tech_scores.items() if score > 0.10}
        if len(strong_tech) >= 3:
            selected = sorted(strong_tech, key=lambda t: strong_tech[t])[-3:]
        elif strong_tech:
            selected = list(strong_tech.keys())
            if "QQQ" not in selected:
                selected.append("QQQ")
        else:
            selected = ["QQQ"]
        target_weights = self._zero_weights()
        for ticker, weight in self._score_weights(tech_scores, selected, self._target_exposure * vol_scale).items():
            target_weights[ticker] = weight
        return target_weights

    def _neutral_market_weights(self, tech_scores, defensive_scores, vol_scale):
        # Neutral: split exposure evenly between two tech names and three defensive names.
        strong_tech = {ticker: score for ticker, score in tech_scores.items() if score > 0.10}
        if len(strong_tech) >= 2:
            selected_tech = sorted(strong_tech, key=lambda t: strong_tech[t])[-2:]
        elif strong_tech:
            selected_tech = list(strong_tech.keys())
            if "QQQ" not in selected_tech and len(selected_tech) < 2:
                selected_tech.append("QQQ")
        else:
            selected_tech = ["QQQ"]
        selected_defensive = self._select_top_tickers(defensive_scores, 3)
        if not selected_defensive:
            selected_defensive = ["BIL"]
        target_weights = self._zero_weights()
        target_weights = self._merge_weights(target_weights, self._score_weights(tech_scores, selected_tech, self._target_exposure * 0.50 * vol_scale))
        target_weights = self._merge_weights(target_weights, self._score_weights(defensive_scores, selected_defensive, self._target_exposure * 0.50))
        return target_weights

    def _bear_market_weights(self, history, defensive_scores):
        # Bear: inverse-vol weight the three strongest defensive names for stability.
        selected_defensive = self._select_top_tickers(defensive_scores, 3)
        if not selected_defensive:
            selected_defensive = ["BIL"]
        target_weights = self._zero_weights()
        for ticker, weight in self._inv_vol_weights(history, selected_defensive, self._target_exposure).items():
            target_weights[ticker] = weight
        return target_weights

    def _score_weights(self, scores, tickers, total_weight):
        valid = {ticker: max(scores.get(ticker, 0), 0) for ticker in tickers}
        total_score = sum(valid.values())
        if total_score <= 0:
            return self._equal_weights(tickers, total_weight)
        return {ticker: total_weight * valid[ticker] / total_score for ticker in valid}

    def _inv_vol_weights(self, history, tickers, total_weight):
        vols = {}
        for ticker in tickers:
            close = self._get_close_series(history, self._symbol_by_ticker[ticker])
            if close is None or close.size < 21:
                continue
            vol = close.pct_change().dropna().tail(63).std()
            if vol and vol > 0:
                vols[ticker] = vol
        if not vols:
            return self._equal_weights(tickers, total_weight)
        inv_vols = {ticker: 1.0 / vol for ticker, vol in vols.items()}
        total_inv = sum(inv_vols.values())
        return {ticker: total_weight * inv_vols[ticker] / total_inv for ticker in inv_vols}

    def _volatility_scale(self, history):
        qqq_close = self._get_close_series(history, self._market)
        if qqq_close is None or len(qqq_close) < 252:
            return 1.0
        returns = qqq_close.pct_change().dropna()
        current_vol = returns.tail(21).std() * (252 ** 0.5)
        avg_vol = returns.tail(252).std() * (252 ** 0.5)
        if avg_vol <= 0:
            return 1.0
        return min(1.0, max(0.5, avg_vol / current_vol))

    def _score_candidates(self, history, candidate_tickers):
        # Score by vol-adjusted blended momentum, a trend term, and a recent drawdown term.
        score_by_ticker = {}
        for ticker in candidate_tickers:
            close = self._get_close_series(history, self._symbol_by_ticker[ticker])
            if close is None or close.size < 126:
                continue
            price_now = close.iloc[-1]
            if price_now <= 0:
                continue
            one_month = price_now / close.iloc[-21] - 1 if close.size >= 21 else 0
            three_month = price_now / close.iloc[-63] - 1 if close.size >= 63 else 0
            six_month = price_now / close.iloc[-126] - 1
            momentum = one_month * 0.3 + three_month * 0.4 + six_month * 0.3
            volatility = close.pct_change().dropna().tail(126).std() * (252 ** 0.5)
            if volatility <= 0:
                continue
            trend = price_now / close.tail(100).mean() - 1
            drawdown = price_now / close.tail(63).max() - 1
            score_by_ticker[ticker] = momentum / volatility + trend + drawdown
        return score_by_ticker

    def _equal_weights(self, tickers, total_weight):
        return {ticker: total_weight / len(tickers) for ticker in tickers}

    def _merge_weights(self, base_weights, new_weights):
        for ticker in new_weights:
            base_weights[ticker] = base_weights.get(ticker, 0) + new_weights[ticker]
        return base_weights

    def _zero_weights(self):
        return {ticker: 0 for ticker in self._all_tickers}

    def _apply_targets(self, target_weights):
        targets = []
        for ticker in self._all_tickers:
            weight = target_weights.get(ticker, 0)
            targets.append(PortfolioTarget(self._symbol_by_ticker[ticker], weight))
            self._last_target_by_ticker[ticker] = weight
        self.set_holdings(targets)

    def _select_top_tickers(self, score_by_ticker, count):
        return sorted(score_by_ticker, key=score_by_ticker.get)[-count:]

    def _get_close_series(self, history, symbol):
        if symbol not in history.index.get_level_values(0):
            return None
        return history.loc[symbol]["close"].dropna()