Overall Statistics
Total Orders
169
Average Win
2.56%
Average Loss
-1.22%
Compounding Annual Return
44.650%
Drawdown
16.400%
Expectancy
1.102
Start Equity
100000
End Equity
296599.07
Net Profit
196.599%
Sharpe Ratio
1.354
Sortino Ratio
1.667
Probabilistic Sharpe Ratio
81.733%
Loss Rate
32%
Win Rate
68%
Profit-Loss Ratio
2.10
Alpha
0.153
Beta
0.956
Annual Standard Deviation
0.192
Annual Variance
0.037
Information Ratio
0.993
Tracking Error
0.148
Treynor Ratio
0.273
Total Fees
$376.30
Estimated Strategy Capacity
$4100000.00
Lowest Capacity Asset
ADI R735QTJ8XC9X
Portfolio Turnover
2.68%
Drawdown Recovery
185
# region imports
from AlgorithmImports import *
# endregion

class TechMomentumStrategy(QCAlgorithm):
    """Tech stock momentum strategy - top 5 by market cap with best momentum"""

    def initialize(self):
        self.set_start_date(2023, 1, 1)
        self.set_cash(100000)
        
        self.set_warm_up(126)
        self.settings.seed_initial_prices = True
        
        # Strategy parameters
        self._num_positions = 5
        self._momentum_period = 126  # 6 months
        self._volatility_period = 30  # 30 days for volatility calculation
        self._correlation_period = 60  # 60 days for correlation calculation
        self._rebalance_flag = False
        self._momentum_indicators = {}
        self._volatility_indicators = {}
        self._return_history = {}  # Track daily returns for correlation
        
        # Trailing stop parameters
        self._trailing_stop_percent = 0.15  # 15% trailing stop
        self._hard_stop_percent = 0.25  # 25% hard stop from entry
        self._position_highs = {}  # Track highest price for each position
        self._position_entry_prices = {}  # Track entry prices
        
        # Add tech stock universe
        self._universe = self.add_universe(self._select_tech_stocks)
        
        # Schedule monthly rebalancing
        self.schedule.on(self.date_rules.month_start(),
                        self.time_rules.after_market_open("SPY", 30),
                        self._set_rebalance_flag)
        
        # Check trailing stops daily
        self.schedule.on(self.date_rules.every_day(),
                        self.time_rules.after_market_open("SPY", 15),
                        self._check_trailing_stops)
        
        # Track returns daily for correlation
        self.schedule.on(self.date_rules.every_day(),
                        self.time_rules.after_market_open("SPY", 1),
                        self._track_returns)
    
    def _select_tech_stocks(self, fundamentals: List[Fundamental]) -> List[Symbol]:
        """Select tech stocks with positive momentum"""
        
        # Filter for technology sector stocks
        tech_stocks = [
            f for f in fundamentals
            if f.has_fundamental_data
            and f.asset_classification.morningstar_sector_code == MorningstarSectorCode.TECHNOLOGY
            and f.market_cap > 1e9  # > $1B market cap
            and f.dollar_volume > 5e6  # > $5M daily volume
        ]
        
        if len(tech_stocks) < 20:
            return []
        
        # Return top 50 tech stocks by market cap for momentum filtering
        sorted_by_cap = sorted(tech_stocks, key=lambda x: x.market_cap, reverse=True)
        return [x.symbol for x in sorted_by_cap[:50]]
    
    def on_securities_changed(self, changes: SecurityChanges):
        """Add momentum and volatility indicators for new securities"""
        for security in changes.added_securities:
            if security.symbol not in self._momentum_indicators:
                self._momentum_indicators[security.symbol] = self.momp(security.symbol, self._momentum_period, Resolution.DAILY)
            if security.symbol not in self._volatility_indicators:
                self._volatility_indicators[security.symbol] = self.std(security.symbol, self._volatility_period, Resolution.DAILY)
            if security.symbol not in self._return_history:
                self._return_history[security.symbol] = RollingWindow[float](self._correlation_period)
        
        for security in changes.removed_securities:
            if security.symbol in self._momentum_indicators:
                self.remove_security(security.symbol)
    
    def _track_returns(self):
        """Track daily returns for correlation calculation"""
        for symbol in self._return_history.keys():
            if symbol in self.securities and self.securities[symbol].has_data:
                history = self.history(symbol, 2, Resolution.DAILY)
                if len(history) == 2:
                    returns = (history.iloc[-1]['close'] / history.iloc[-2]['close']) - 1
                    self._return_history[symbol].add(returns)
    
    def _calculate_correlation(self, symbol1: Symbol, symbol2: Symbol) -> float:
        """Calculate correlation between two symbols"""
        if symbol1 not in self._return_history or symbol2 not in self._return_history:
            return 0.5  # Default moderate correlation
        
        window1 = self._return_history[symbol1]
        window2 = self._return_history[symbol2]
        
        if not window1.is_ready or not window2.is_ready:
            return 0.5
        
        # Get returns as lists
        returns1 = [window1[i] for i in range(window1.count)]
        returns2 = [window2[i] for i in range(window2.count)]
        
        # Calculate correlation
        import numpy as np
        corr = np.corrcoef(returns1, returns2)[0, 1]
        
        return corr if not np.isnan(corr) else 0.5
    
    def _set_rebalance_flag(self):
        """Set flag to trigger rebalancing"""
        self._rebalance_flag = True
    
    def _check_trailing_stops(self):
        """Check trailing stops daily and exit positions if triggered"""
        for symbol, holding in self.portfolio.items():
            if not holding.invested:
                continue
            
            current_price = self.securities[symbol].price
            
            # Update position high
            if symbol not in self._position_highs:
                self._position_highs[symbol] = current_price
            else:
                self._position_highs[symbol] = max(self._position_highs[symbol], current_price)
            
            # Check trailing stop (15% from peak)
            peak_price = self._position_highs[symbol]
            trailing_stop_price = peak_price * (1 - self._trailing_stop_percent)
            
            if current_price <= trailing_stop_price:
                self.liquidate(symbol)
                self.log(f"Trailing stop hit for {symbol}: {current_price:.2f} <= {trailing_stop_price:.2f} (peak: {peak_price:.2f})")
                if symbol in self._position_highs:
                    del self._position_highs[symbol]
                if symbol in self._position_entry_prices:
                    del self._position_entry_prices[symbol]
                continue
            
            # Check hard stop (25% from entry price)
            if symbol in self._position_entry_prices:
                entry_price = self._position_entry_prices[symbol]
                hard_stop_price = entry_price * (1 - self._hard_stop_percent)
                
                if current_price <= hard_stop_price:
                    self.liquidate(symbol)
                    self.log(f"Hard stop hit for {symbol}: {current_price:.2f} <= {hard_stop_price:.2f} (entry: {entry_price:.2f})")
                    if symbol in self._position_highs:
                        del self._position_highs[symbol]
                    if symbol in self._position_entry_prices:
                        del self._position_entry_prices[symbol]
    
    def on_data(self, data: Slice):
        """Rebalance portfolio monthly with volatility-adjusted weights"""
        if not self._rebalance_flag:
            return
        
        self._rebalance_flag = False
        
        # Get active tech stocks from universe
        active_symbols = list(self._universe.selected)
        
        if len(active_symbols) < 5:
            return
        
        # Calculate momentum for each stock
        momentum_scores = []
        for symbol in active_symbols:
            if symbol in self._momentum_indicators:
                indicator = self._momentum_indicators[symbol]
                if indicator.is_ready and indicator.current.value > 0:
                    security = self.securities[symbol]
                    momentum_scores.append({
                        'symbol': symbol,
                        'momentum': indicator.current.value,
                        'market_cap': security.fundamentals.market_cap
                    })
        
        if len(momentum_scores) < 5:
            return
        
        # Sort by momentum (highest first)
        sorted_by_momentum = sorted(momentum_scores, key=lambda x: x['momentum'], reverse=True)
        
        # Take top 20 momentum stocks for correlation filtering
        top_momentum = sorted_by_momentum[:20]
        sorted_by_cap = sorted(top_momentum, key=lambda x: x['market_cap'], reverse=True)
        candidates = [x['symbol'] for x in sorted_by_cap[:15]]
        
        # Select stocks with lowest average correlation (greedy algorithm)
        final_stocks = []
        
        # Start with highest market cap stock
        if len(candidates) > 0:
            final_stocks.append(candidates[0])
        
        # Add stocks with lowest average correlation to already selected stocks
        while len(final_stocks) < self._num_positions and len(candidates) > len(final_stocks):
            best_candidate = None
            lowest_avg_correlation = float('inf')
            
            for candidate in candidates:
                if candidate in final_stocks:
                    continue
                
                # Calculate average correlation with already selected stocks
                correlations = []
                for selected in final_stocks:
                    corr = self._calculate_correlation(candidate, selected)
                    correlations.append(abs(corr))
                
                avg_corr = sum(correlations) / len(correlations) if correlations else 0
                
                if avg_corr < lowest_avg_correlation:
                    lowest_avg_correlation = avg_corr
                    best_candidate = candidate
            
            if best_candidate:
                final_stocks.append(best_candidate)
                self.log(f"Added {best_candidate.value} with avg correlation {lowest_avg_correlation:.2f}")
            else:
                break
        
        # Calculate inverse volatility weights (more allocation to lower volatility stocks)
        weights = {}
        total_inverse_vol = 0
        
        for symbol in final_stocks:
            if symbol in self._volatility_indicators:
                vol_indicator = self._volatility_indicators[symbol]
                if vol_indicator.is_ready and vol_indicator.current.value > 0:
                    # Inverse volatility - lower vol gets higher weight
                    inverse_vol = 1.0 / vol_indicator.current.value
                    weights[symbol] = inverse_vol
                    total_inverse_vol += inverse_vol
                else:
                    # Default equal weight if volatility not ready
                    weights[symbol] = 1.0
                    total_inverse_vol += 1.0
            else:
                weights[symbol] = 1.0
                total_inverse_vol += 1.0
        
        # Normalize weights to sum to 1.0
        if total_inverse_vol > 0:
            for symbol in weights:
                weights[symbol] = weights[symbol] / total_inverse_vol
        
        # Create portfolio targets with volatility-adjusted weights
        targets = [PortfolioTarget(symbol, weights[symbol]) for symbol in final_stocks]
        
        # Execute rebalancing
        self.set_holdings(targets, liquidate_existing_holdings=True)
        
        # Update entry prices and reset highs for new positions
        for symbol in final_stocks:
            current_price = self.securities[symbol].price
            if symbol not in self.portfolio or not self.portfolio[symbol].invested:
                self._position_entry_prices[symbol] = current_price
                self._position_highs[symbol] = current_price
        
        # Log weights for monitoring
        weights_str = ", ".join([f"{s.value}: {w:.1%}" for s, w in weights.items()])
        self.log(f"Rebalanced with volatility weights: {weights_str}")