Overall Statistics
Total Orders
5125
Average Win
0.19%
Average Loss
-0.18%
Compounding Annual Return
24.842%
Drawdown
33.700%
Expectancy
0.130
Start Equity
100000
End Equity
198401.45
Net Profit
98.401%
Sharpe Ratio
0.644
Sortino Ratio
0.759
Probabilistic Sharpe Ratio
33.864%
Loss Rate
44%
Win Rate
56%
Profit-Loss Ratio
1.02
Alpha
-0.011
Beta
1.519
Annual Standard Deviation
0.227
Annual Variance
0.051
Information Ratio
0.342
Tracking Error
0.124
Treynor Ratio
0.096
Total Fees
$5219.95
Estimated Strategy Capacity
$57000000.00
Lowest Capacity Asset
TFLO VNTW0AC8LAHX
Portfolio Turnover
4.27%
Drawdown Recovery
266
#region imports
from AlgorithmImports import *
from collections import deque
#endregion

class LeapOptionsScreenerStrategy(QCAlgorithm):
    """
    Strategy that screens for stocks based on volatility and options data,
    then purchases long-term call options (LEAPs) on them.
    12/4/2025 - minor tweaks to mx stock price, imp vol, stop loss and vix threshold to resemble the 2 year backtest that did 30%. 
    also added ETFs. also rememeber that every 7 days is a weekend on screening and no trades will trigger.
    SPX- YTD up 12.5% as of 12/4/2025
    spx - 2024 perf - 24.01 SPX 2023 Annaul Perf - 24.73%
    so if im starting in 1//1/2023 i need to beat ...70% return and 20.5% drawdown in spring 2025... so i can hit 50% return and 10% drawdown or 
    other combination.
    """

    def Initialize(self):
        """Initialize the algorithm."""
        self.SetStartDate(2022, 11, 1)
        self.SetEndDate(2025, 12, 1)
        self.SetCash(100000)
        self.SetWarmup(timedelta(days=30))

        # === Strategy Parameters ===
        self.MIN_STOCK_PRICE = 2.50
        self.MAX_STOCK_PRICE = 350.0
        self.MIN_IMPLIED_VOLATILITY = 0.50   # 100%
        self.STOP_LOSS_PERCENT = -0.10  # Stop Loss at 200% loss
        self.TAKE_PROFIT_PERCENT = 0.33  # Take Profit at 300% gain
        self.POSITION_ALLOCATION = 0.02 # 2% per new position
        self.SPY_ALLOCATION = 0.80
        self.XLU_ALLOCATION = 0.20
        self.MAX_PORTFOLIO_ALLOCATION = 1.6 # Max 85% of portfolio in stocks but my PM always under 40%
        self.VIX_THRESHOLD = 20.50 # VIX level to define a BEAR market regime
        
        
        # === Internal State ===
        self.trade_dates = {}
        self.last_week_iv = {} # To store the previous week's IV for comparison
        self.option_symbols = {} # To track equity -> option symbol mapping
        self.last_screening_date = None
        self.screening_frequency_days = 14  # Every 2 weeks (change to 21 for 3 weeks)
        self.vix = None # To hold the VIX symbol
        self.market_regime = 'BULL' # Start with a BULL assumption

        # Set universe settings
        self.UniverseSettings.Asynchronous = True
        self.UniverseSettings.Resolution = Resolution.Daily
        # Add the universe selection function. The timing of the screening is handled by a scheduled event.
        self.AddUniverse(self.UniverseSelectionFunction)
        
        # Add VIX data to use as a market regime filter
        # TEMPLATE NOTE: VIX must be added as an Index, not an Equity, to ensure data is loaded correctly.
        self.vix = self.AddIndex("VIX", Resolution.Daily).Symbol
        self.spy = self.AddEquity("SPY", Resolution.Daily).Symbol
        self.xlu = self.AddEquity("XLU", Resolution.Daily).Symbol
    def UniverseSelectionFunction(self, coarse: List[CoarseFundamental]) -> List[Symbol]:
        """
        Select top 50 most liquid stocks by dollar volume.
        This runs once a week on Monday morning.
        """
        self.Log(f"Coarse selection running at {self.Time}...")
        # Filter for price, fundamental data, and minimum volume
        filtered = [
            c for c in coarse # Remove 'HasFundamentalData' to include ETFs
            if self.MIN_STOCK_PRICE < c.Price < self.MAX_STOCK_PRICE and # Price filter
               c.Volume > 500000 # Liquidity filter
        ]

        # Sort by dollar volume and take the top 50
        top_50 = sorted(filtered, key=lambda c: c.DollarVolume, reverse=True)[:50]
        
        self.Log(f"Selected top {len(top_50)} liquid securities for screening.")
        return [c.Symbol for c in top_50]

    def OnData(self, data):
        """Main trading logic - executed on data events."""
        # TEMPLATE NOTE: The main logic is called from OnData, not a scheduled event.
        # This is CRITICAL because it provides the 'data' slice, which is the only
        # way to access fresh OptionChains data for IV calculation.
        if self.IsWarmingUp:
            return
        
        self.WeeklyScreeningAndTrading(data)
        #Maintain base spy and xlu pos    
        self.SetHoldings(self.spy, self.SPY_ALLOCATION)
        self.SetHoldings(self.xlu, self.XLU_ALLOCATION)
    def WeeklyScreeningAndTrading(self, data):
        """
        Runs periodically (every `screening_frequency_days`) to rank universe stocks
        and manage a portfolio of the top 10.
        - First run: Ranks by absolute IV (or price as fallback).
        - Subsequent runs: Ranks by IV % increase from the previous screening.
        """
        # First, manage existing positions with the current data slice
        self.ManagePositions(data)

        # Only run if enough days have passed since last screening
        if self.last_screening_date is not None:
            days_since = (self.Time.date() - self.last_screening_date).days
            if days_since < self.screening_frequency_days:
                return  # Skip this screening
        
        # --- VIX Market Regime Filter ---
        vix_security = self.Securities[self.vix]
        if not vix_security.HasData:
            self.Log("VIX data not ready yet. Skipping screening cycle.")
            return

        vix_price = vix_security.Price
        if vix_price > self.VIX_THRESHOLD:
            self.market_regime = 'BEAR'
        else:
            self.market_regime = 'BULL'

        if self.market_regime == 'BEAR':
            self.Log(f"VIX is at {vix_price:.2f} (>{self.VIX_THRESHOLD}). Market regime is BEAR. Pausing new trades.")
            return # Stop looking for new trades in a bear market
        '''
        self.last_screening_date = self.Time.date()
        self.Log(f"--- Starting Weekly Screening Trading on {self.Time.date()} ---")
        self.Log("=" * 80)
        self.Log("IV DIAGNOSTIC LOG")
        self.Log("=" * 80)
        '''

        # Step 1: Get current IV for all active securities and filter for a high IV pool
        # CORRECT: Use the helper function to get true Implied Volatility from option chains
        current_iv_data = {}
        for sec in self.ActiveSecurities.Values:
            # Skip securities that don't have data or are the VIX index itself
            # TEMPLATE NOTE: Must filter for Equity or ETF type. self.ActiveSecurities contains stocks, ETFs, and their options.
            if sec.Type != SecurityType.Equity:
                continue
                
            if not sec.HasData or sec.Symbol == self.vix:
                continue
                
            # Use our new helper function to get true IV
            iv = self.GetImpliedVolatility(sec.Symbol, data)
            
            # Only consider valid IVs
            if iv > 0:
                current_iv_data[sec.Symbol] = iv

        high_iv_pool = {s: iv for s, iv in current_iv_data.items() if iv > self.MIN_IMPLIED_VOLATILITY}
        self.Log(f"Found {len(high_iv_pool)} stocks with IV > {self.MIN_IMPLIED_VOLATILITY:.0%}. Now checking for bullish sentiment.")

        # Step 2: Filter the high IV pool for stocks with a bullish call/put volume ratio
        bullish_sentiment_pool = {}
        for symbol, iv in high_iv_pool.items():
            # TEMPLATE NOTE: Always check for the chain's existence in the current data slice.
            if not data.OptionChains or symbol not in data.OptionChains:
                continue
            try:
                option_chain = data.OptionChains[symbol] # Access with bracket notation.
            except:
                continue
            
            if not option_chain or len(option_chain) == 0:
                continue
            
            total_call_volume = sum(c.Volume for c in option_chain if c.Right == OptionRight.Call)
            total_put_volume = sum(c.Volume for c in option_chain if c.Right == OptionRight.Put)

            if total_put_volume == 0:
                # If put volume is zero, any call volume makes the ratio effectively infinite (very bullish)
                call_put_ratio = float('inf') if total_call_volume > 0 else 0
            else:
                call_put_ratio = total_call_volume / total_put_volume

            if call_put_ratio >= 1.10:
                bullish_sentiment_pool[symbol] = iv
                self.Log(f"  -> {symbol.Value} passed sentiment check. C/P Ratio: {call_put_ratio:.2f} ({total_call_volume}/{total_put_volume})")

        self.Log(f"Found {len(bullish_sentiment_pool)} stocks with bullish sentiment. Now checking for IV increase.")

        # If no stocks passed the sentiment filter, fall back to the original high IV pool
        if not bullish_sentiment_pool:
            self.Log("No stocks passed sentiment filter. Falling back to high IV pool.")
            bullish_sentiment_pool = high_iv_pool

        # Step 3: Rank candidates. If it's the first run, rank by absolute IV. Otherwise, rank by IV increase.
        top_sorted = []
        if not self.last_week_iv:
            self.Log("First screening run. Ranking candidates by absolute Implied Volatility.")
            top_sorted = sorted(bullish_sentiment_pool.items(), key=lambda item: item[1], reverse=True)
        else:
            # Calculate the IV increase since the last screening
            iv_increase_candidates = {}
            for symbol, current_iv in bullish_sentiment_pool.items():
                previous_iv = self.last_week_iv.get(symbol)
                if previous_iv and previous_iv > 0:
                    iv_increase = (current_iv - previous_iv) / previous_iv
                    iv_increase_candidates[symbol] = iv_increase
            
            if iv_increase_candidates:
                self.Log("Ranking candidates by percentage increase in Implied Volatility.")
                top_sorted = sorted(iv_increase_candidates.items(), key=lambda item: item[1], reverse=True)

        # Log the top 5 for debugging
        log_metric = "IV" if not self.last_week_iv else "IV Inc"
        top_5_log = [f"{s.Value}: {val:.2%}" for s, val in top_sorted[:5]]
        #self.Log(f"Top 5 candidates by {log_metric}: {', '.join(top_5_log)}")

        # Add a check for an empty list of candidates
        if not top_sorted:
            self.Log("No candidates passed all filters. Skipping this screening cycle.")
            # Still save IV data for the next cycle
            self.last_week_iv = current_iv_data
            return

        # Select the top 25 symbols to trade
        top_25_symbols = [symbol for symbol, _ in top_sorted[:25]]
        
        # --- "Add-Only" Portfolio Management ---

        # Iterate through ranked candidates and add new positions if conditions are met
        for symbol in top_25_symbols:
            # Recalculate current allocation before each trade decision for accuracy
            current_stock_allocation = sum(
                h.HoldingsValue for h in self.Portfolio.Values if h.Invested and h.Type == SecurityType.Equity
            ) / self.Portfolio.TotalPortfolioValue

            # Check if we have room for a new position under the 85% cap
            if current_stock_allocation + self.POSITION_ALLOCATION > self.MAX_PORTFOLIO_ALLOCATION:
                self.Log(f"Max portfolio allocation of {self.MAX_PORTFOLIO_ALLOCATION:.0%} reached. No more positions will be added this cycle.")
                break

            if self.Portfolio[symbol].Invested:
                self.Log(f"[SKIP] {symbol.Value}: Already invested.")
                continue
            
            self.Log(f"[BUY] {symbol.Value}: Current allocation {current_stock_allocation:.2%}. Adding new {self.POSITION_ALLOCATION:.0%} position.")
            self.SetHoldings(symbol, self.POSITION_ALLOCATION)
            self.trade_dates[symbol] = self.Time.date()
        
        
        # Save current IV data for next week's comparison
        self.last_week_iv = current_iv_data

        invested = len([h for h in self.Portfolio.Values if h.Invested])
        self.Log(f"[POSITIONS] Currently invested in: {invested} positions.")

    def GetImpliedVolatility(self, symbol: Symbol, data) -> float:
        """
        Retrieves the Implied Volatility (IV) for a given stock symbol by looking at
        At-The-Money (ATM) options expiring in roughly 30-45 days.
        """
        
        # TEMPLATE NOTE: Must use the 'data' slice to access fresh option chains.
        if not data.OptionChains or symbol not in data.OptionChains:
            self.Log(f"  - {symbol.Value}: No option chain in data slice.")
            return 0.0
        
        option_chain = data.OptionChains[symbol]
        
        if not option_chain or len(option_chain) == 0:
            self.Log(f"  - {symbol.Value}: Option chain is empty in data slice.")
            return 0.0
        
        # 2. Filter for Call options only
        calls = [x for x in option_chain if x.Right == OptionRight.Call]
        if not calls:
            self.Log(f"  - {symbol.Value}: No call contracts found in the chain.")
            return 0.0
        
        # 3. Select contracts closest to 30-45 days out
        target_date = self.Time + timedelta(days=30)
        near_term_contracts = [
            x for x in calls
            if 25 <= (x.Expiry - self.Time).days <= 45
        ]
        
        if not near_term_contracts:
            # Fallback: grab closest to 30 days
            near_term_contracts = sorted(calls, key=lambda x: abs((x.Expiry - target_date).days))[:5]
        
        if not near_term_contracts:
            self.Log(f"  - {symbol.Value}: No near-term contracts found.")
            return 0.0
        
        # 4. Select ATM contract
        underlying_price = self.Securities[symbol].Price
        if underlying_price <= 0:
            self.Log(f"  - {symbol.Value}: Underlying price is zero or invalid.")
            return 0.0
        
        atm_contract = min(near_term_contracts, key=lambda x: abs(x.Strike - underlying_price))
        
        # 5. Return IV
        iv = atm_contract.ImpliedVolatility

        # Add this line BEFORE the return
        if iv == 0.0:
            self.Log(f"⚠️ {symbol.Value}: IV is 0.0 - Greeks not calculating")
        else:
            self.Log(f"✓ {symbol.Value}: IV={iv:.2%}")

        return max(0.0, iv)

    def OnSecuritiesChanged(self, changes: SecurityChanges):
        """Minimal - only clean up data for removed securities."""
        # Add option subscriptions for new equities
        for security in changes.AddedSecurities:
            if security.Type == SecurityType.Equity:
                self.Log(f"New stock in universe: {security.Symbol.Value}. Subscribing to options.")
                option = self.AddOption(security.Symbol, resolution=Resolution.Hour)
                option.SetFilter(lambda u: u.IncludeWeeklys().Strikes(-3, 3).Expiration(0, 45))
                # TEMPLATE NOTE: Both SetPricingModel() and EnableGreekApproximation are required
                # For American-style options (equities), BlackScholes is not suitable. Use a model that supports early exercise.
                option.PriceModel = OptionPriceModels.CrankNicolsonFD()
                option.EnableGreekApproximation = True
                self.option_symbols[security.Symbol] = option.Symbol

        # Remove subscriptions for equities that have left the universe
        for security in changes.RemovedSecurities:
            if security.Type == SecurityType.Equity and security.Symbol in self.option_symbols:
                self.Log(f"Stock left universe: {security.Symbol.Value}. Unsubscribing from options.")
                option_symbol_to_remove = self.option_symbols.pop(security.Symbol)
                self.RemoveSecurity(option_symbol_to_remove)

            # Clean up any historical data
            if security.Symbol in self.last_week_iv:
                del self.last_week_iv[security.Symbol]
                
    def ManagePositions(self, data):
        """Scheduled function to manage open positions (e.g., exit)."""
        for holding in self.Portfolio.Values:
            
            if not holding.Invested or holding.Type != SecurityType.Equity or holding.Symbol in [self.spy, self.xlu]:
                continue

            symbol = holding.Symbol
            unrealized_profit_percent = holding.UnrealizedProfitPercent
            '''
            # 3. IV Stop Loss: Exit if the reason for entry (high IV) is gone
            current_iv = self.GetImpliedVolatility(symbol, data)
            if 0 < current_iv < 45:
                self.Log(f"IV STOP LOSS for {symbol.Value}. Current IV {current_iv:.2%} < Threshold {self.MIN_IMPLIED_VOLATILITY:.2%}. Liquidating.")
                self.Liquidate(symbol)
                if symbol in self.trade_dates: del self.trade_dates[symbol]
                continue # Move to next holding
            '''        
            # 1. Stop Loss
            if unrealized_profit_percent <= self.STOP_LOSS_PERCENT:
                self.Log(f"STOP LOSS TRIGGERED for {symbol.Value}. Unrealized P&L: {unrealized_profit_percent:.2%}. Liquidating.")
                self.Liquidate(symbol)
                if symbol in self.trade_dates: del self.trade_dates[symbol]
                continue # Move to next holding

            # 2. Take Profit
            if unrealized_profit_percent >= self.TAKE_PROFIT_PERCENT:
                self.Log(f"TAKE PROFIT TRIGGERED for {symbol.Value}. Unrealized P&L: {unrealized_profit_percent:.2%}. Liquidating.")
                self.Liquidate(symbol)
                if symbol in self.trade_dates: del self.trade_dates[symbol]
                continue # Move to next holding\

    def OnOrderEvent(self, orderEvent: OrderEvent):
        """Handle order events for logging."""
        if orderEvent.Status != OrderStatus.Filled:
            return

        order = self.Transactions.GetOrderById(orderEvent.OrderId)
        self.Log(f"FILLED: {order.Direction} {order.Quantity} of {order.Symbol.Value} at ${orderEvent.FillPrice:.2f}")

    def OnEndOfAlgorithm(self):
        """Final logging at the end of the algorithm."""
        self.Log("LEAP Options Screener Strategy Ended")
        self.Log(f"Final Portfolio Value: ${self.Portfolio.TotalPortfolioValue:,.2f}")