Overall Statistics
Total Orders
7028
Average Win
0.38%
Average Loss
-0.36%
Compounding Annual Return
12.747%
Drawdown
44.900%
Expectancy
0.253
Start Equity
100000
End Equity
1697025.10
Net Profit
1597.025%
Sharpe Ratio
0.458
Sortino Ratio
0.48
Probabilistic Sharpe Ratio
0.558%
Loss Rate
40%
Win Rate
60%
Profit-Loss Ratio
1.08
Alpha
0.044
Beta
0.658
Annual Standard Deviation
0.175
Annual Variance
0.031
Information Ratio
0.171
Tracking Error
0.151
Treynor Ratio
0.122
Total Fees
$32729.39
Estimated Strategy Capacity
$81000000.00
Lowest Capacity Asset
FSM V037Z4TQ6F6T
Portfolio Turnover
3.65%
Drawdown Recovery
1100
# Quality Value Momentum Strategy for QuantConnect
# This algorithm implements a quality (ROIC + low leverage) + value + momentum strategy with regime filter

from AlgorithmImports import *

class QualityValueMomentumStrategy(QCAlgorithm):
    
    def Initialize(self):
        """Initialize the algorithm with parameters and universe settings"""
        
        # Set start date, end date, and cash
        self.SetStartDate(2002, 1, 1)   # Start from 2002 (when IEF became available)
        self.SetEndDate(2025, 8, 31)    # End of August 2025
        self.SetCash(100000)            # Set Strategy Cash
        
        # Strategy Parameters
        self.universe_size = 500        # Q500US universe
        self.quality_stocks = 50        # Top decile by quality composite score
        self.momentum_stocks = 20       # Final portfolio size
        self.momentum_lookback = 252    # ~1 year for momentum calculation
        self.momentum_skip = 10         # Skip last 10 days for mean reversion
        self.regime_lookback = 126      # 6 months for SPY regime filter
        
        # TORQUE ENHANCEMENTS
        # 1. Concentration (set to True for conviction weighting)
        self.use_conviction_weighting = True
        self.top_conviction_stocks = 5   # Number of highest conviction positions
        self.top_conviction_weight = 0.08  # 8% each for top positions
        
        # 2. Sector Momentum Filter (set to True to enable)
        self.use_sector_momentum = True
        self.sector_momentum_lookback = 63  # 3 months for sector momentum
        self.top_sectors_to_use = 6  # Use top 6 performing sectors
        
        # 3. Dynamic Leverage based on market conditions
        self.use_dynamic_leverage = True
        self.max_leverage = 1.3  # Maximum 130% invested
        self.min_leverage = 0.7  # Minimum 70% invested (30% cash/bonds)
        
        # RSI Parameters (set use_rsi to True to enable)
        self.use_rsi = False            # Toggle RSI adjustment on/off
        self.rsi_period = 14            # Standard RSI period
        self.rsi_oversold = 30          # Oversold threshold
        self.rsi_overbought = 70        # Overbought threshold
        
        # Quality and Value weights (adjust these to change factor emphasis)
        self.roic_weight = 0.4          # Weight for ROIC in quality score
        self.leverage_weight = 0.3      # Weight for low leverage in quality score  
        self.value_weight = 0.3         # Weight for value metrics in quality score
        
        # Benchmark and regime filter ETF
        self.spy = self.AddEquity("SPY", Resolution.Daily).Symbol
        self.ief = self.AddEquity("IEF", Resolution.Daily).Symbol
        self.SetBenchmark("SPY")
        
        # 4. Options Overlay for Income (set to True to enable)
        self.use_options_overlay = False  # Toggle covered calls on/off
        self.option_strike_otm = 1.05    # 5% OTM for covered calls
        self.option_days_to_expiry = 30  # Target ~30 days to expiration
        self.min_rsi_for_calls = 65      # Only sell calls if RSI > 65
        
        # Track our universe
        self.universe_stocks = []
        self.quality_data = {}
        self.selected_stocks = []
        self.covered_call_positions = {}  # Track which positions have calls
        self.month = -1
        
        # Schedule rebalancing - 6 days before month end
        self.Schedule.On(
            self.DateRules.MonthEnd("SPY", 6),
            self.TimeRules.BeforeMarketClose("SPY", 30),
            self.Rebalance
        )
        
        # Set universe selection
        self.UniverseSettings.Resolution = Resolution.Daily
        self.UniverseSettings.Leverage = 2
        self.AddUniverse(self.CoarseSelectionFunction, self.FineSelectionFunction)
        
        # Warmup period for momentum calculations
        self.SetWarmUp(365)
        
        # Debug flag
        self.debug_enabled = True
        
    def CoarseSelectionFunction(self, coarse):
        """
        Select top 500 US stocks by market cap with fundamental data and price > $5
        """
        # Only run monthly (around rebalance time)
        if self.Time.month == self.month:
            return Universe.Unchanged
        
        if self.IsWarmingUp:
            return []
            
        # Filter for stocks with fundamental data
        filtered = [x for x in coarse if x.HasFundamentalData 
                    and x.Price > 5 
                    and x.DollarVolume > 5000000]  # $5M daily volume
        
        # Sort by dollar volume (liquidity proxy)
        sorted_by_dollar_volume = sorted(filtered, key=lambda x: x.DollarVolume, reverse=True)
        
        # Take top 500
        top_500 = sorted_by_dollar_volume[:min(self.universe_size, len(sorted_by_dollar_volume))]
        
        # Store symbols for fine selection
        self.universe_stocks = [x.Symbol for x in top_500]
        
        if self.debug_enabled and len(self.universe_stocks) > 0:
            self.Debug(f"{self.Time.date()}: Coarse selected {len(self.universe_stocks)} stocks")
        
        return self.universe_stocks
    
    def FineSelectionFunction(self, fine):
        """
        Calculate quality metrics (ROIC, leverage) and value metrics for universe stocks
        """
        # Update month tracker
        self.month = self.Time.month
        
        # Calculate quality and value scores for all stocks
        self.quality_data = {}
        
        # First, filter by sector momentum if enabled
        valid_sectors = self.GetTopSectors(fine) if self.use_sector_momentum else None
        
        for stock in fine:
            try:
                # Skip if not in a top-performing sector (when sector filter is enabled)
                if self.use_sector_momentum and valid_sectors is not None:
                    if hasattr(stock.AssetClassification, 'MorningstarSectorCode'):
                        sector = stock.AssetClassification.MorningstarSectorCode
                        if sector not in valid_sectors:
                            continue
                
                quality_score = self.CalculateQualityScore(stock)
                if quality_score is not None:
                    self.quality_data[stock.Symbol] = quality_score
            except:
                continue
        
        if self.debug_enabled and len(self.quality_data) > 0:
            self.Debug(f"{self.Time.date()}: Found quality scores for {len(self.quality_data)} stocks")
            if self.use_sector_momentum and valid_sectors:
                self.Debug(f"  Using top sectors: {valid_sectors}")
        
        # If we have quality data, perform the selection
        if len(self.quality_data) > 0:
            self.PerformSelection()
        
        # Return all symbols with valid quality scores
        return list(self.quality_data.keys())
    
    def GetTopSectors(self, fine):
        """
        Identify top performing sectors by momentum
        """
        try:
            sector_performance = {}
            
            # Group stocks by sector and calculate sector momentum
            for stock in fine:
                if hasattr(stock.AssetClassification, 'MorningstarSectorCode'):
                    sector = stock.AssetClassification.MorningstarSectorCode
                    if sector not in sector_performance:
                        sector_performance[sector] = []
                    
                    # Get recent performance for this stock
                    momentum = self.CalculateSectorMomentum(stock.Symbol)
                    if momentum != float('-inf'):
                        sector_performance[sector].append(momentum)
            
            # Calculate average momentum for each sector
            sector_averages = {}
            for sector, performances in sector_performance.items():
                if performances:
                    sector_averages[sector] = sum(performances) / len(performances)
            
            # Get top performing sectors
            if sector_averages:
                sorted_sectors = sorted(sector_averages.items(), key=lambda x: x[1], reverse=True)
                top_sectors = [sector for sector, _ in sorted_sectors[:self.top_sectors_to_use]]
                return set(top_sectors)
            
            return None
            
        except Exception as e:
            if self.debug_enabled:
                self.Debug(f"Sector filter error: {str(e)}")
            return None
    
    def CalculateSectorMomentum(self, symbol):
        """
        Calculate shorter-term momentum for sector filtering
        """
        try:
            history = self.History(symbol, self.sector_momentum_lookback + 5, Resolution.Daily)
            
            if history.empty or len(history) < self.sector_momentum_lookback:
                return float('-inf')
            
            if 'close' in history.columns:
                closes = history['close']
            else:
                return float('-inf')
            
            # Simple 3-month return
            if len(closes) >= 2:
                return (closes.iloc[-1] / closes.iloc[0]) - 1
            
            return float('-inf')
            
        except:
            return float('-inf')
    
    def CalculateQualityScore(self, stock):
        """
        Calculate composite quality score using ROIC, leverage, and value metrics
        """
        scores = {}
        
        # 1. ROIC (Return on Invested Capital)
        # ROIC = NOPAT / Invested Capital
        # Alternative: Operating Income / (Total Assets - Current Liabilities)
        try:
            if hasattr(stock.OperationRatios, 'ROIC') and stock.OperationRatios.ROIC:
                roic = float(stock.OperationRatios.ROIC.Value)
            else:
                # Calculate ROIC manually
                operating_income = stock.FinancialStatements.IncomeStatement.OperatingIncome.TwelveMonths
                total_assets = stock.FinancialStatements.BalanceSheet.TotalAssets.TwelveMonths
                current_liabilities = stock.FinancialStatements.BalanceSheet.CurrentLiabilities.TwelveMonths
                
                if total_assets and current_liabilities and operating_income:
                    invested_capital = total_assets - current_liabilities
                    if invested_capital > 0:
                        roic = operating_income / invested_capital
                    else:
                        return None
                else:
                    # Try using ROA as a proxy if ROIC can't be calculated
                    if hasattr(stock.OperationRatios, 'ROA') and stock.OperationRatios.ROA:
                        roic = float(stock.OperationRatios.ROA.Value)
                    else:
                        return None
            
            # Filter out unreasonable ROIC values
            if roic <= 0 or roic > 2:  # 0% to 200%
                return None
            scores['roic'] = roic
        except:
            return None
        
        # 2. Leverage (Long-term Debt to Equity Ratio) - LOWER IS BETTER
        try:
            if hasattr(stock.OperationRatios, 'LongTermDebtEquityRatio') and stock.OperationRatios.LongTermDebtEquityRatio:
                ltd_equity = float(stock.OperationRatios.LongTermDebtEquityRatio.Value)
            else:
                # Calculate manually
                long_term_debt = stock.FinancialStatements.BalanceSheet.LongTermDebt.TwelveMonths
                equity = stock.FinancialStatements.BalanceSheet.ShareholdersEquity.TwelveMonths
                
                if equity and equity > 0:
                    ltd_equity = (long_term_debt if long_term_debt else 0) / equity
                else:
                    return None
            
            # Convert to a score where lower leverage = higher score
            # Using 1/(1+x) transformation so 0 debt = score of 1, high debt = score near 0
            leverage_score = 1 / (1 + max(0, ltd_equity))
            scores['leverage'] = leverage_score
        except:
            # If we can't calculate leverage, use a neutral score
            scores['leverage'] = 0.5
        
        # 3. Value Metrics (P/E and P/B ratios) - LOWER IS BETTER
        value_scores = []
        
        try:
            # P/E Ratio
            if hasattr(stock.ValuationRatios, 'PERatio') and stock.ValuationRatios.PERatio:
                pe_ratio = float(stock.ValuationRatios.PERatio)
                if 0 < pe_ratio < 100:  # Reasonable P/E range
                    # Convert to score where lower P/E = higher score
                    pe_score = 1 / (1 + pe_ratio/20)  # P/E of 20 gives score of 0.5
                    value_scores.append(pe_score)
        except:
            pass
        
        try:
            # P/B Ratio
            if hasattr(stock.ValuationRatios, 'PBRatio') and stock.ValuationRatios.PBRatio:
                pb_ratio = float(stock.ValuationRatios.PBRatio)
                if 0 < pb_ratio < 20:  # Reasonable P/B range
                    # Convert to score where lower P/B = higher score
                    pb_score = 1 / (1 + pb_ratio/3)  # P/B of 3 gives score of 0.5
                    value_scores.append(pb_score)
        except:
            pass
        
        # Average value scores if we have any
        if value_scores:
            scores['value'] = sum(value_scores) / len(value_scores)
        else:
            scores['value'] = 0.5  # Neutral score if no value metrics available
        
        # Calculate composite quality score
        # Weighted average of ROIC, leverage score, and value score
        composite_score = (
            scores['roic'] * self.roic_weight +
            scores['leverage'] * self.leverage_weight +
            scores['value'] * self.value_weight
        )
        
        return composite_score
    
    def PerformSelection(self):
        """
        Perform the actual stock selection and trigger rebalancing if needed
        """
        if self.IsWarmingUp or not self.quality_data:
            return
        
        # This method is called from FineSelectionFunction
        # The actual rebalancing happens in the Rebalance method scheduled 6 days before month end
        # We just prepare the data here
        pass
    
    def Rebalance(self):
        """
        Rebalancing logic - called 6 days before month end
        """
        if self.IsWarmingUp or not self.quality_data:
            return
        
        # Step 1: Get top 50 stocks by composite quality score
        quality_sorted = sorted(self.quality_data.items(), key=lambda x: x[1], reverse=True)
        top_quality_stocks = [symbol for symbol, score in quality_sorted[:min(self.quality_stocks, len(quality_sorted))]]
        
        if self.debug_enabled:
            self.Debug(f"{self.Time.date()}: Top quality stocks: {len(top_quality_stocks)}")
            # Log top 5 for verification
            for i in range(min(5, len(quality_sorted))):
                symbol, score = quality_sorted[i]
                self.Debug(f"  {i+1}. {symbol.Value}: {score:.4f}")
        
        # Step 2: Calculate momentum for quality stocks
        momentum_scores = {}
        for symbol in top_quality_stocks:
            momentum = self.CalculateMomentum(symbol)
            if momentum != float('-inf') and momentum != float('inf'):
                # Optionally adjust momentum score by RSI
                if self.use_rsi:
                    rsi = self.CalculateRSI(symbol)
                    if rsi is not None:
                        # Adjust momentum based on RSI
                        if rsi <= self.rsi_oversold:
                            # Oversold - boost score by 10%
                            momentum = momentum * 1.1
                        elif rsi >= self.rsi_overbought:
                            # Overbought - reduce score by 10%
                            momentum = momentum * 0.9
                        # Neutral RSI (30-70) - no adjustment
                
                momentum_scores[symbol] = momentum
        
        if len(momentum_scores) == 0:
            self.Debug(f"{self.Time.date()}: No valid momentum scores found")
            return
        
        # Step 3: Get top 20 stocks by momentum
        momentum_sorted = sorted(momentum_scores.items(), key=lambda x: x[1], reverse=True)
        new_selected_stocks = [symbol for symbol, momentum in momentum_sorted[:min(self.momentum_stocks, len(momentum_sorted))]]
        
        if self.debug_enabled:
            self.Debug(f"{self.Time.date()}: Selected {len(new_selected_stocks)} stocks by momentum")
        
        # Step 4: Check regime filter
        regime_positive = self.CheckRegimeFilter()
        
        if self.debug_enabled:
            self.Debug(f"{self.Time.date()}: Regime filter positive: {regime_positive}")
        
        # Step 5: Get current holdings
        current_holdings = []
        for kvp in self.Portfolio:
            if kvp.Value.Invested and kvp.Key != self.spy and kvp.Key != self.ief:
                current_holdings.append(kvp.Key)
        
        # Step 6: Execute rebalancing
        self.ExecuteRebalancing(new_selected_stocks, current_holdings, regime_positive)
    
    def ExecuteRebalancing(self, new_selected_stocks, current_holdings, regime_positive):
        """
        Execute the rebalancing trades with enhanced position sizing
        """
        # Determine what to buy and sell
        if regime_positive:
            stocks_to_buy = new_selected_stocks
            stocks_to_sell = [s for s in current_holdings if s not in new_selected_stocks]
        else:
            stocks_to_buy = []
            stocks_to_sell = [s for s in current_holdings if s not in new_selected_stocks]
        
        # Execute sells first
        for symbol in stocks_to_sell:
            self.Liquidate(symbol)
            if self.debug_enabled:
                self.Debug(f"{self.Time.date()}: Selling {symbol.Value}")
        
        # Calculate final positions
        stocks_to_keep = [s for s in current_holdings if s not in stocks_to_sell]
        stocks_to_add = [s for s in stocks_to_buy if s not in current_holdings]
        final_positions = stocks_to_keep + stocks_to_add
        num_positions = len(final_positions)
        
        # Determine leverage based on market conditions
        target_leverage = 1.0
        if self.use_dynamic_leverage and regime_positive:
            # Calculate market strength indicators
            spy_momentum = self.CalculateMarketStrength()
            
            if spy_momentum > 0.15:  # Strong uptrend (>15% 6-month return)
                target_leverage = self.max_leverage
                if self.debug_enabled:
                    self.Debug(f"Strong market: Using {target_leverage:.1f}x leverage")
            elif spy_momentum > 0:  # Weak uptrend
                target_leverage = 1.0
            else:  # Downtrend (shouldn't happen with regime filter, but safety check)
                target_leverage = self.min_leverage
        elif not regime_positive:
            target_leverage = self.min_leverage
        
        # Set positions with conviction-based or equal weighting
        if num_positions > 0:
            position_weights = self.CalculatePositionWeights(final_positions, num_positions)
            
            # Apply leverage to weights and set positions
            for i, symbol in enumerate(final_positions):
                adjusted_weight = position_weights[i] * target_leverage
                self.SetHoldings(symbol, adjusted_weight)
                if symbol in stocks_to_add and self.debug_enabled:
                    self.Debug(f"{self.Time.date()}: Buying {symbol.Value} at {adjusted_weight:.2%}")
                
                # Options overlay: Sell covered calls on overbought positions
                if self.use_options_overlay and adjusted_weight > 0:
                    self.ManageCoveredCalls(symbol, adjusted_weight)
        
        # Allocate remaining to IEF
        total_stock_allocation = sum(position_weights) * target_leverage if num_positions > 0 else 0
        ief_allocation = max(0, 1.0 - total_stock_allocation)
        
        if ief_allocation > 0.01:
            self.SetHoldings(self.ief, ief_allocation)
            if self.debug_enabled:
                self.Debug(f"{self.Time.date()}: Allocating {ief_allocation:.2%} to IEF")
        elif self.Portfolio[self.ief].Invested:
            self.Liquidate(self.ief)
        
        # Store selected stocks
        self.selected_stocks = final_positions
        
        if self.debug_enabled:
            self.Debug(f"{self.Time.date()}: Rebalance complete - {num_positions} positions at {target_leverage:.1f}x leverage")
    
    def CalculatePositionWeights(self, positions, num_positions):
        """
        Calculate position weights based on conviction (ranking) or equal weight
        """
        if not self.use_conviction_weighting or num_positions <= self.top_conviction_stocks:
            # Equal weighting
            return [1.0 / self.momentum_stocks] * num_positions
        else:
            # Conviction-based weighting
            weights = []
            
            # Top conviction positions get higher weight
            for i in range(num_positions):
                if i < self.top_conviction_stocks:
                    # Top 5 positions: 8% each
                    weights.append(self.top_conviction_weight)
                elif i < self.top_conviction_stocks * 2:
                    # Next 5 positions: 6% each
                    weights.append(0.06)
                else:
                    # Remaining positions: split the rest equally
                    remaining_allocation = 1.0 - (self.top_conviction_stocks * self.top_conviction_weight) - (self.top_conviction_stocks * 0.06)
                    remaining_positions = num_positions - (self.top_conviction_stocks * 2)
                    if remaining_positions > 0:
                        weights.append(remaining_allocation / remaining_positions)
                    else:
                        weights.append(0.03)
            
            # Normalize weights to ensure they sum to 1
            total_weight = sum(weights)
            if total_weight > 0:
                weights = [w / total_weight for w in weights]
            
            return weights
    
    def CalculateMarketStrength(self):
        """
        Calculate overall market strength for dynamic leverage
        """
        try:
            # Get SPY 6-month return (we already calculate this for regime filter)
            history = self.History(self.spy, self.regime_lookback + 20, Resolution.Daily)
            
            if history.empty or len(history) < self.regime_lookback:
                return 0
            
            if 'close' in history.columns:
                closes = history['close']
            else:
                return 0
            
            current = closes.iloc[-1]
            old = closes.iloc[0]
            
            if old > 0:
                return (current / old - 1)
            
            return 0
        except:
            return 0
    
    def CalculateMomentum(self, symbol):
        """
        Calculate momentum with skip period
        """
        try:
            # Request history for the symbol
            history = self.History(symbol, self.momentum_lookback + self.momentum_skip + 20, Resolution.Daily)
            
            if history.empty:
                return float('-inf')
            
            # Get close prices
            if 'close' in history.columns:
                closes = history['close']
            else:
                closes = history.close if hasattr(history, 'close') else None
                if closes is None:
                    return float('-inf')
            
            # Ensure we have enough data
            if len(closes) < (self.momentum_lookback + self.momentum_skip):
                return float('-inf')
            
            # Get the prices we need
            price_skip_ago = closes.iloc[-(self.momentum_skip + 1)]
            price_lookback_ago = closes.iloc[0]
            
            # Calculate momentum
            if price_lookback_ago > 0:
                momentum = (price_skip_ago / price_lookback_ago) - 1
                return momentum
            
            return float('-inf')
            
        except Exception as e:
            if self.debug_enabled:
                self.Debug(f"Momentum calculation error for {symbol}: {str(e)}")
            return float('-inf')
    
    def CalculateRSI(self, symbol):
        """
        Calculate RSI (Relative Strength Index) for a symbol
        """
        try:
            # Get price history for RSI calculation
            history = self.History(symbol, self.rsi_period + 5, Resolution.Daily)
            
            if history.empty or len(history) < self.rsi_period:
                return None
            
            # Get close prices
            if 'close' in history.columns:
                closes = history['close']
            else:
                return None
            
            # Calculate price changes
            delta = closes.diff()
            
            # Separate gains and losses
            gains = delta.where(delta > 0, 0)
            losses = -delta.where(delta < 0, 0)
            
            # Calculate average gains and losses
            avg_gains = gains.rolling(window=self.rsi_period).mean()
            avg_losses = losses.rolling(window=self.rsi_period).mean()
            
            # Calculate RS and RSI
            if avg_losses.iloc[-1] != 0:
                rs = avg_gains.iloc[-1] / avg_losses.iloc[-1]
                rsi = 100 - (100 / (1 + rs))
                return rsi
            else:
                return 100  # If no losses, RSI is 100
                
        except Exception as e:
            if self.debug_enabled:
                self.Debug(f"RSI calculation error for {symbol}: {str(e)}")
            return None
    
    def CheckRegimeFilter(self):
        """
        Check if SPY 6-month momentum is positive
        """
        try:
            history = self.History(self.spy, self.regime_lookback + 20, Resolution.Daily)
            
            if history.empty:
                return True
            
            if 'close' in history.columns:
                closes = history['close']
            else:
                closes = history.close if hasattr(history, 'close') else None
                if closes is None:
                    return True
            
            if len(closes) < self.regime_lookback:
                return True
            
            current = closes.iloc[-1]
            old = closes.iloc[0]
            
            if old > 0:
                return (current / old - 1) > 0
            
            return True
            
        except Exception as e:
            if self.debug_enabled:
                self.Debug(f"Regime filter error: {str(e)}")
            return True
    
    def ManageCoveredCalls(self, symbol, position_weight):
        """
        Sell covered calls on positions with high RSI for additional income
        """
        try:
            # Check if we should sell calls on this position
            rsi = self.CalculateRSI(symbol)
            
            if rsi and rsi > self.min_rsi_for_calls:
                # Get the option chain
                contracts = self.OptionChainProvider.GetOptionContractList(symbol, self.Time)
                
                if contracts:
                    # Filter for calls around 30 days to expiration and 5% OTM
                    current_price = self.Securities[symbol].Price
                    target_strike = current_price * self.option_strike_otm
                    target_expiry = self.Time + timedelta(days=self.option_days_to_expiry)
                    
                    # Find suitable call contract
                    calls = [c for c in contracts if c.ID.OptionRight == OptionRight.Call 
                            and c.ID.StrikePrice >= target_strike
                            and abs((c.ID.Date - target_expiry).days) <= 10]
                    
                    if calls:
                        # Sort by distance to target strike and expiry
                        calls.sort(key=lambda x: abs(x.ID.StrikePrice - target_strike))
                        selected_call = calls[0]
                        
                        # Add the option contract
                        option = self.AddOptionContract(selected_call)
                        
                        # Calculate number of contracts (1 contract per 100 shares)
                        shares_owned = position_weight * self.Portfolio.TotalPortfolioValue / current_price
                        contracts_to_sell = int(shares_owned / 100)
                        
                        if contracts_to_sell > 0:
                            # Sell the calls
                            self.Sell(selected_call, contracts_to_sell)
                            self.covered_call_positions[symbol] = selected_call
                            
                            if self.debug_enabled:
                                self.Debug(f"{self.Time.date()}: Sold {contracts_to_sell} covered calls on {symbol.Value} at strike {selected_call.ID.StrikePrice}")
            
            # Close expired or unnecessary covered calls
            if symbol in self.covered_call_positions:
                call_contract = self.covered_call_positions[symbol]
                if self.Securities.ContainsKey(call_contract):
                    # Buy back the call if RSI has normalized or near expiration
                    if (rsi and rsi < 50) or (call_contract.ID.Date - self.Time).days <= 1:
                        if self.Portfolio[call_contract].Invested:
                            self.Liquidate(call_contract)
                            del self.covered_call_positions[symbol]
                            if self.debug_enabled:
                                self.Debug(f"{self.Time.date()}: Closed covered call on {symbol.Value}")
                                
        except Exception as e:
            if self.debug_enabled:
                self.Debug(f"Options overlay error for {symbol}: {str(e)}")
    
    def OnData(self, data):
        """
        OnData event - manage options positions if needed
        """
        # Monitor and manage existing covered call positions
        if self.use_options_overlay and self.covered_call_positions:
            for symbol, call_contract in list(self.covered_call_positions.items()):
                if data.ContainsKey(call_contract):
                    # Check if we should close the position early
                    if self.Securities.ContainsKey(call_contract):
                        option_price = self.Securities[call_contract].Price
                        # Close if option has lost 80% of its value (take profit on the short call)
                        if self.Portfolio[call_contract].Invested:
                            entry_price = abs(self.Portfolio[call_contract].AveragePrice)
                            if option_price < entry_price * 0.2:
                                self.Liquidate(call_contract)
                                del self.covered_call_positions[symbol]
                                if self.debug_enabled:
                                    self.Debug(f"{self.Time.date()}: Closed profitable covered call on {symbol.Value}")