Overall Statistics
Total Orders
46
Average Win
0.63%
Average Loss
-0.03%
Compounding Annual Return
52.637%
Drawdown
29.300%
Expectancy
18.368
Start Equity
40000
End Equity
95560.69
Net Profit
138.902%
Sharpe Ratio
1.182
Sortino Ratio
1.508
Probabilistic Sharpe Ratio
60.341%
Loss Rate
22%
Win Rate
78%
Profit-Loss Ratio
23.90
Alpha
0.081
Beta
2.031
Annual Standard Deviation
0.29
Annual Variance
0.084
Information Ratio
0.962
Tracking Error
0.223
Treynor Ratio
0.169
Total Fees
$38.18
Estimated Strategy Capacity
$0
Lowest Capacity Asset
VEA TULITAWO3WPX
Portfolio Turnover
0.37%
#region imports
from AlgorithmImports import *
#endregion

class DynamicEtfMomentumStrategy(QCAlgorithm):
    '''
    This algorithm implements a dynamic momentum-based strategy for a portfolio of ETFs.
    It incorporates relative strength for selection and absolute momentum for crash protection.
    The logic is as follows:
    1. On a monthly schedule, the algorithm calculates the 6-month performance of all ETFs in the universe.
    2. It ranks the ETFs by this performance and selects the top 3 performers.
    3. CRASH PROTECTION: It checks if the top performer's 6-month momentum is positive.
       - If YES (market is generally rising), it allocates the portfolio equally among the top 3 ETFs.
       - If NO (market is likely falling), it allocates 100% of the portfolio to a safe-haven
         asset (long-term US Treasury bonds, TLT) to protect capital.
    4. This process repeats monthly, ensuring the portfolio is always positioned in the strongest
       assets or protected in cash during downturns.
    '''
    def Initialize(self):
        """
        Initializes the algorithm, sets up the universe, and schedules the rebalancing logic.
        """
        self.SetStartDate(2023, 1, 1)
        self.SetEndDate(2025, 1, 21)
        self.SetCash(40000)

        # 1. EXPANDED UNIVERSE: Add more diverse ETFs for better rotation opportunities.
        # User's list + broad market, international, and safe-haven assets.
        self.risk_on_symbols_str = ["CHAT", "FBCG", "FBOT", "FBTC", "FDIG", "FMET", "NUKZ", "QTUM",
                                    "SPY",  # S&P 500
                                    "QQQ",  # Nasdaq 100
                                    "VEA",  # Developed Markets ex-US
                                    "GLD"]  # Gold
        
        # Define the safe-haven asset for crash protection
        self.safe_haven_symbol_str = "TLT" # 20+ Year Treasury Bond ETF

        # Add data for all symbols
        self.all_symbols = [self.AddEquity(s, Resolution.Daily).Symbol for s in self.risk_on_symbols_str]
        self.safe_haven_symbol = self.AddEquity(self.safe_haven_symbol_str, Resolution.Daily).Symbol
        self.all_symbols.append(self.safe_haven_symbol)

        # --- Strategy Parameters ---
        self.lookback_period = 6 * 21 # Approx. 6 months in trading days
        self.top_n_etfs = 3           # Number of top ETFs to hold

        # Set a warm-up period to ensure we have enough data for our lookback calculation
        self.SetWarmUp(self.lookback_period)

        # Schedule the rebalancing logic to run once a month
        self.Schedule.On(self.DateRules.MonthStart(), self.TimeRules.AfterMarketOpen(self.risk_on_symbols_str[0], 5), self.MonthlyRebalance)
        
    def MonthlyRebalance(self):
        """
        This method is called once per month to execute the core strategy logic.
        """
        if self.IsWarmingUp:
            return

        # 2. ROBUST STRATEGY: Calculate momentum for each risk-on ETF
        momentum = {}
        history = self.History(self.all_symbols, self.lookback_period + 1, Resolution.Daily)
        if history.empty:
            return

        for symbol in self.risk_on_symbols_str:
            # Ensure we have history for the symbol
            if symbol not in history.index.levels[0]:
                continue
            
            # Get the price series for the symbol
            close_prices = history.loc[symbol]['close']
            
            # Calculate the percentage return over the lookback period.
            # We add a small value to the denominator to avoid division-by-zero errors.
            if len(close_prices) > self.lookback_period:
                momentum[symbol] = (close_prices[-1] / close_prices[0]) - 1

        if not momentum:
            return

        # Rank the ETFs by momentum in descending order
        ranked_etfs = sorted(momentum.items(), key=lambda x: x[1], reverse=True)

        # Select the top N performers
        top_performers = ranked_etfs[:self.top_n_etfs]
        self.Log(f"Top performers this month: {[etf[0] for etf in top_performers]}")
        
        # 3. PORTFOLIO BALANCING & CRASH PROTECTION
        # Get the momentum of the #1 ranked ETF to use as our market filter
        top_etf_symbol, top_etf_momentum = top_performers[0]
        
        # Absolute Momentum Rule (Crash Protection)
        if top_etf_momentum > 0:
            # If momentum is positive, invest in the top performers
            self.Log("Momentum is positive. Investing in top ETFs.")
            
            # Calculate equal weight for each of the top performers
            weight = 1.0 / self.top_n_etfs
            
            # Create a list of portfolio targets
            targets = [PortfolioTarget(etf[0], weight) for etf in top_performers]
            
            # Use SetHoldings to rebalance the portfolio to these new targets.
            # This will automatically liquidate any old positions that are no longer in the top N.
            self.SetHoldings(targets)

        else:
            # If momentum is negative, a market downturn is likely.
            # Liquidate all risk-on assets and move 100% to the safe-haven asset.
            self.Log("Momentum is negative. Moving to safe-haven asset (TLT).")
            self.Liquidate() # First, sell any existing risk-on positions
            self.SetHoldings(self.safe_haven_symbol, 1.0) # Then, allocate everything to TLT


    def OnData(self, data: Slice):
        """
        OnData is not used in this strategy, as all logic is handled
        in the scheduled MonthlyRebalance method.
        """
        pass