Overall Statistics
Total Orders
198
Average Win
5.10%
Average Loss
-1.46%
Compounding Annual Return
95.199%
Drawdown
25.500%
Expectancy
2.425
Start Equity
100000
End Equity
2837392.52
Net Profit
2737.393%
Sharpe Ratio
2.141
Sortino Ratio
2.636
Probabilistic Sharpe Ratio
98.628%
Loss Rate
24%
Win Rate
76%
Profit-Loss Ratio
3.49
Alpha
0.564
Beta
0.683
Annual Standard Deviation
0.284
Annual Variance
0.08
Information Ratio
2.01
Tracking Error
0.27
Treynor Ratio
0.889
Total Fees
$1150.78
Estimated Strategy Capacity
$1300000000.00
Lowest Capacity Asset
IGW S6BDJ8ONH2ZP
Portfolio Turnover
2.41%
Drawdown Recovery
212
# Big Tech + AI hardware throttle rotation strategy.
# Version 20: VDE in defensive + inverse-vol weighting for Sharpe.

from AlgorithmImports import *
from datetime import timedelta


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"
        ]

        # Deduplicated list preserving order
        seen = set()
        self._all_tickers = []
        for t in self._tech_tickers + self._defensive_tickers:
            if t not in seen:
                seen.add(t)
                self._all_tickers.append(t)

        self._symbols = {}

        for ticker in self._all_tickers:
            security = self.add_equity(ticker, Resolution.DAILY)
            security.set_data_normalization_mode(DataNormalizationMode.ADJUSTED)
            self._symbols[ticker] = security.symbol

        self._market = self._symbols["QQQ"]
        self._risk_check = self._symbols["FNGS"]

        self._lookback_days = 252
        self._target_exposure = 1.0
        self._minimum_rebalance_change = 0.10
        self._last_regime = "neutral"
        self._cooldown_months = 0

        self._last_target_by_ticker = {}

        for ticker in self._all_tickers:
            self._last_target_by_ticker[ticker] = 0

        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 on_data(self, data):
        pass

    def _rebalance(self):
        if self.is_warming_up:
            return

        history = self.history(
            list(self._symbols.values()),
            self._lookback_days,
            Resolution.DAILY
        )

        if history.empty:
            target_weights = self._single_asset_weights("BIL")
            self._apply_targets(target_weights)
            return

        regime = self._market_regime(history)

        tech_scores = self._score_candidates(history, self._tech_tickers)
        defensive_scores = self._score_candidates(history, self._defensive_tickers)

        # Volatility scaling: reduce exposure in high-vol regimes
        vol_scale = self._volatility_scale(history)

        if regime == "bull":
            target_weights = self._bull_market_weights(
                history, tech_scores, vol_scale
            )
        elif regime == "neutral":
            target_weights = self._neutral_market_weights(
                history, tech_scores, defensive_scores, vol_scale
            )
        else:
            target_weights = self._bear_market_weights(
                history, defensive_scores
            )

        if self._should_skip_rebalance(target_weights):
            return

        self._apply_targets(target_weights)

    def _market_regime(self, history):
        qqq_close = self._get_close_series(history, self._market)
        fngs_close = self._get_close_series(history, self._risk_check)

        if qqq_close is None or qqq_close.size < 200:
            return "neutral"

        qqq_now = qqq_close.iloc[-1]
        qqq_sma_50 = qqq_close.tail(50).mean()
        qqq_sma_100 = qqq_close.tail(100).mean()
        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:
            fngs_now = fngs_close.iloc[-1]
            fngs_sma_100 = fngs_close.tail(100).mean()

            if qqq_now < qqq_sma_100 and fngs_now < fngs_sma_100:
                return "bear"

        if qqq_now < qqq_sma_50 and qqq_close.size >= 21:
            one_month = qqq_now / qqq_close.iloc[-21] - 1
            if one_month < -0.08:
                return "bear"

        if qqq_now > qqq_sma_200 * 1.03:
            return "bull"

        return "neutral"

    def _bull_market_weights(self, history, tech_scores, vol_scale):
        """Bull: 3 best tech with strong momentum, score-weighted for max Sharpe."""
        target_weights = self._zero_weights()

        # Only select tech with positive momentum > 5% annualized
        min_momentum = 0.10
        strong_tech = {t: s for t, s in tech_scores.items() if s > min_momentum}
        
        if len(strong_tech) >= 3:
            # Enough strong candidates, pick top 3
            selected_tickers = sorted(strong_tech.keys(), key=lambda t: strong_tech[t], reverse=True)[:3]
        elif len(strong_tech) > 0:
            # Some strong candidates, supplement with QQQ
            selected_tickers = list(strong_tech.keys())
            if "QQQ" not in selected_tickers:
                selected_tickers.append("QQQ")
        else:
            # No strong momentum, default to QQQ
            selected_tickers = ["QQQ"]

        weighted_targets = self._score_weights(
            tech_scores, selected_tickers, self._target_exposure * vol_scale
        )

        for ticker in weighted_targets:
            target_weights[ticker] = weighted_targets[ticker]

        return target_weights

    def _neutral_market_weights(self, history, tech_scores, defensive_scores, vol_scale):
        """Neutral: 2 tech + 3 defensive, score-weighted for better selection."""
        target_weights = self._zero_weights()

        # Only select tech with positive momentum > 10% in neutral markets
        min_momentum = 0.10
        strong_tech = {t: s for t, s in tech_scores.items() if s > min_momentum}
        
        if len(strong_tech) >= 2:
            selected_tech = sorted(strong_tech.keys(), key=lambda t: strong_tech[t], reverse=True)[:2]
        elif len(strong_tech) > 0:
            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 len(selected_tech) == 0:
            selected_tech = ["QQQ"]

        if len(selected_defensive) == 0:
            selected_defensive = ["BIL"]

        tech_weight = self._target_exposure * 0.50 * vol_scale
        defensive_weight = self._target_exposure * 0.50

        tech_targets = self._score_weights(tech_scores, selected_tech, tech_weight)
        defensive_targets = self._score_weights(
            defensive_scores, selected_defensive, defensive_weight
        )

        target_weights = self._merge_weights(target_weights, tech_targets)
        target_weights = self._merge_weights(target_weights, defensive_targets)

        return target_weights

    def _bear_market_weights(self, history, defensive_scores):
        """Bear: 3 defensive, inverse-vol weighted for stability."""
        target_weights = self._zero_weights()

        selected_defensive = self._select_top_tickers(defensive_scores, 3)

        if len(selected_defensive) == 0:
            selected_defensive = ["BIL"]

        weighted_targets = self._inv_vol_weights(
            history, selected_defensive, self._target_exposure
        )

        for ticker in weighted_targets:
            target_weights[ticker] = weighted_targets[ticker]

        return target_weights

    def _score_weights(self, scores, tickers, total_weight):
        """Weight proportional to score magnitude for max Sharpe.
        Scores are already vol-adjusted (momentum/vol + trend + dd),
        so higher-score assets naturally have better risk-return."""
        valid = {t: max(scores.get(t, 0), 0) for t in tickers}
        total_score = sum(valid.values())

        if total_score <= 0:
            return self._equal_weights(tickers, total_weight)

        return {t: total_weight * valid[t] / total_score for t in valid}

    def _inv_vol_weights(self, history, tickers, total_weight):
        """Inverse-vol weighting for defensive positions."""
        vols = {}
        for ticker in tickers:
            symbol = self._symbols[ticker]
            close = self._get_close_series(history, symbol)
            if close is None or close.size < 21:
                continue
            returns = close.pct_change().dropna()
            vol = returns.tail(63).std()
            if vol and vol > 0:
                vols[ticker] = vol

        if len(vols) == 0:
            return self._equal_weights(tickers, total_weight)

        inv_vols = {t: 1.0 / v for t, v in vols.items()}
        total_inv = sum(inv_vols.values())

        return {t: total_weight * inv_vols[t] / total_inv for t in inv_vols}

    def _volatility_scale(self, history):
        """Scale factor based on market volatility (0.5 to 1.0).
        Reduces exposure when QQQ vol is above historical average."""
        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
        
        # Scale down when current vol exceeds average
        vol_ratio = current_vol / avg_vol
        scale = min(1.0, max(0.5, 1.0 / vol_ratio))
        return scale

    def _score_candidates(self, history, candidate_tickers):
        score_by_ticker = {}

        for ticker in candidate_tickers:
            symbol = self._symbols[ticker]
            close = self._get_close_series(history, symbol)

            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

            returns = close.pct_change().dropna()
            recent_returns = returns.tail(126)
            volatility = recent_returns.std() * (252 ** 0.5)

            if volatility <= 0:
                continue

            sma_100 = close.tail(100).mean()
            trend = price_now / sma_100 - 1

            recent_high = close.tail(63).max()
            drawdown = price_now / recent_high - 1

            score = momentum / volatility + trend + drawdown
            score_by_ticker[ticker] = score

        return score_by_ticker

    def _equal_weights(self, tickers, total_weight):
        weight_per_ticker = total_weight / len(tickers)
        return {ticker: weight_per_ticker 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 _single_asset_weights(self, ticker):
        target_weights = self._zero_weights()
        target_weights[ticker] = self._target_exposure
        return target_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:
            symbol = self._symbols[ticker]
            weight = target_weights.get(ticker, 0)
            targets.append(PortfolioTarget(symbol, weight))
            self._last_target_by_ticker[ticker] = weight

        self.set_holdings(targets)

    def _should_skip_rebalance(self, target_weights):
        max_change = 0

        for ticker in self._all_tickers:
            old_weight = self._last_target_by_ticker[ticker]
            new_weight = target_weights.get(ticker, 0)
            change = abs(new_weight - old_weight)
            max_change = max(max_change, change)

        return max_change < self._minimum_rebalance_change

    def _select_positive_top_tickers(self, score_by_ticker, count):
        ranked_tickers = self._select_top_tickers(score_by_ticker, count)
        return [t for t in ranked_tickers if score_by_ticker[t] > 0]

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

    def _get_close_series(self, history, symbol):
        symbols_in_history = history.index.get_level_values(0)

        if symbol not in symbols_in_history:
            return None

        return history.loc[symbol]["close"].dropna()