Overall Statistics
Total Orders
698
Average Win
0.82%
Average Loss
-0.35%
Compounding Annual Return
27.429%
Drawdown
17.300%
Expectancy
0.670
Start Equity
100000
End Equity
209411.19
Net Profit
109.411%
Sharpe Ratio
0.889
Sortino Ratio
1.01
Probabilistic Sharpe Ratio
58.516%
Loss Rate
50%
Win Rate
50%
Profit-Loss Ratio
2.34
Alpha
0.048
Beta
1.044
Annual Standard Deviation
0.166
Annual Variance
0.027
Information Ratio
0.541
Tracking Error
0.096
Treynor Ratio
0.141
Total Fees
$651.31
Estimated Strategy Capacity
$94000000.00
Lowest Capacity Asset
MRVL RVW87MBCBMUD
Portfolio Turnover
1.54%
Drawdown Recovery
168
#region imports
from AlgorithmImports import *
from collections import deque
#endregion

class MemeStocksStrategy(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.

    Developer Notes (12/08/2025):
    - Renamed class to MemeStocksStrategy.
    - Added slippage calculation logging for orders of 100+ shares to monitor execution costs.
    - Significantly reduced logging verbosity across the algorithm (screening, order events, exits) to lower memory usage and prevent crashes in a live environment.
    - Implemented a portfolio-wide daily loss limit (5%) to liquidate all positions and halt trading for the day as a major risk management feature.
   Developer Notes (12/09/2025): added logic to use last known price of vix and reduce checks of vix to one during screening by not using ondata
   Developer Notes (12/18/2025):
    - Refactored to use `AddOption` universe and directly extract `ImpliedVolatility` from `OptionContract` objects within `data.OptionChains`, replacing the manual `GetImpliedVolatility` function and the built-in `IV` indicator.
    - Implemented a `self.iv_cache` that is updated every tick from `data.OptionChains` to provide fresh IV values for screening.
    - Modified screening logic to use absolute IV for the initial run and the 14-day IV increase for subsequent runs.
    - Added a safety check for empty option chains before calculating the call/put ratio to prevent runtime errors.
    - **Debugging Lessons (IV Caching):** To fix empty IV data, we stripped down `CacheImpliedVolatility` to its simplest form. Logging at each step confirmed IV was being read but not stored. The fix was adding the single line to populate `self.iv_cache`.
    12-15-2025 - changed top 50 to top 30 and trades 15 instead of 25. fixed expiration filter mismatch and aligned IV calculation to those new 25-60 days  
    this will hopefully get some options chains to load correctly from live market. 
    """

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

        # Set commission for IB but comment it out as not ready for live money trading yet
        # self.SetBrokerageModel(BrokerageName.InteractiveBrokers, AccountType.Margin)
        # self.Log(f"Commission cost per trade: ~${len(self.Portfolio) * 0.10}")

        # === Strategy Parameters ===
        self.MIN_STOCK_PRICE = 2.50
        self.MAX_STOCK_PRICE = 350.00
        self.MIN_IMPLIED_VOLATILITY = 0.50   # 100%
        self.STOP_LOSS_PERCENT = -0.15  # Stop Loss at 15% 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.last_rebalance_date = None
        self.rebalance_frequency_days = 90  # Quarterly (every 90 days)
        self.trade_dates = {}
        self.previous_iv = {}  # To store the previous screening's IV for comparison
        self.iv_cache = {}     # To store the latest implied volatility for each underlying
        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

        # === Daily Loss Limit ===
        self.daily_loss_limit = -0.05 # -5%
        self.portfolio_value_at_start_of_day = 0
        self.last_loss_limit_date = None

        # Set universe settings
        self.UniverseSettings.Asynchronous = True
        self.UniverseSettings.Resolution = Resolution.Hour
        # Set data normalization to RAW. CRITICAL for options - without this, Greeks and IV won't calculate correctly.
        self.UniverseSettings.DataNormalizationMode = DataNormalizationMode.Raw

        # Add the universe selection function. The timing of the screening is handled by a scheduled event.
        # This will select our base equities.
        universe = self.AddUniverse(self.UniverseSelectionFunction)

        # Chain an options universe to the equity universe, filtering for front-month contracts +/- 10 strikes
        self.AddUniverseOptions(universe, self.OptionFilterFunction)
        # The iv_cache will store the latest implied volatility for each symbol, populated from OptionChains.
        
        # 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.Hour).Symbol
        self.xlu = self.AddEquity("XLU", Resolution.Hour).Symbol
        
       # === Slippage Tracking ===
        self.daily_slippage_dollars = 0.0  # Total $ slippage today
        self.daily_trades_count = 0         # Number of trades today
        self.last_slippage_reset_date = None

        #Email Notification
        self.last_email_date = None
        
        # === DEBUGGING ===
        self.last_diagnostic_time = None

        # === SCHEDULED EVENTS ===
        # Schedule the main screening and trading logic to run once per week.
        # This is the correct way to align data access for multiple securities.
        self.Schedule.On(self.DateRules.Every(DayOfWeek.Monday), 
                         self.TimeRules.At(10, 0), 
                         self.PerformWeeklyScreening)

    def OptionFilterFunction(self, option_filter_universe: OptionFilterUniverse) -> OptionFilterUniverse:
        """Option filter for selecting desired option contracts."""
        return option_filter_universe.Strikes(-10, +10).FrontMonth()

        # Note: In a longer-running algorithm, you might also want to handle `changes.RemovedSecurities` to clean up indicators.

    def UniverseSelectionFunction(self, coarse: List[CoarseFundamental]) -> List[Symbol]:
        
        price_filtered = [c for c in coarse 
                        if self.MIN_STOCK_PRICE < c.Price < self.MAX_STOCK_PRICE]
        self.Log(f"UNIVERSE: Passed price filter: {len(price_filtered)}")
        
        filtered = [c for c in coarse 
                    if self.MIN_STOCK_PRICE < c.Price < self.MAX_STOCK_PRICE 
                    and c.DollarVolume > 6700000]
        self.Log(f"UNIVERSE: Passed volume filter (>500k): {len(filtered)}")
        
        top_30 = sorted(filtered, key=lambda c: c.DollarVolume, reverse=True)[:30]
        self.Log(f"UNIVERSE: Final selection: {len(top_30)} stocks")
        
        return [c.Symbol for c in top_30]

    def OnSecuritiesChanged(self, changes: SecurityChanges):
        """
        Handles security changes in the algorithm.
        """
        pass

    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

        # === Initial Rebalancing (after warmup) ===
        if self.last_rebalance_date is None:
            self.Log("Warmup finished. Performing initial ETF rebalancing.")
            self.SetHoldings(self.spy, self.SPY_ALLOCATION)
            self.SetHoldings(self.xlu, self.XLU_ALLOCATION)
            self.last_rebalance_date = self.Time.date()

        # Cache implied volatility from option chains every tick
        self.CacheImpliedVolatility(data)

        # === Weekly Screening Gate ===
        # The scheduled event now just sets a flag. The actual screening happens in OnData
        # to ensure we have access to the 'data' slice with fresh option chains.
        self.WeeklyScreeningAndTrading(data)

        # === DAILY SUMMARY LOG (triggered ~30 min after market close) ===
        # Market closes at 4 PM EST. This runs on first data after 4:30 PM.
        if self.Time.hour == 16 and self.Time.minute >= 30 and self.last_email_date != self.Time.date():
            portfolio_value = self.Portfolio.TotalPortfolioValue
            unrealized_pnl = self.Portfolio.TotalUnrealizedProfit
            invested_count = len([h for h in self.Portfolio.Values if h.Invested])
            active_count = len([s for s in self.ActiveSecurities.Values if s.Type == SecurityType.Equity])
            self.Log(f"UNIVERSE (9:31 AM): Active securities: {active_count}")

            # Calculate daily P&L
            daily_pnl_pct = None
            if self.portfolio_value_at_start_of_day > 0:
                daily_pnl_pct = ((portfolio_value - self.portfolio_value_at_start_of_day)
                                / self.portfolio_value_at_start_of_day) * 100
            daily_pnl_str = "N/A" if daily_pnl_pct is None else f"{daily_pnl_pct:.2f}%"

            # Calculate average slippage per trade
            avg_slippage_per_trade = self.daily_slippage_dollars / self.daily_trades_count if self.daily_trades_count > 0 else 0

            # --- Build and Send Daily Summary Email ---
            summary_subject = f"Daily Summary: {self.Time.date().strftime('%Y-%m-%d')}"
            summary_message = f"""
            === DAILY SUMMARY ===
            Date: {self.Time.date().strftime('%Y-%m-%d')}
            Portfolio: ${portfolio_value:,.2f}
            Daily P&L: {daily_pnl_str}
            Unrealized: ${unrealized_pnl:,.2f}
            Positions: {invested_count}
            Cash: ${self.Portfolio.Cash:,.2f}
            Trades: {self.daily_trades_count} | Slippage: ${self.daily_slippage_dollars:.2f} | Avg: ${avg_slippage_per_trade:.2f}
            Market Regime: {self.market_regime}
            ====================
            """
            # Log the summary to the console
            self.Log(summary_message)
            
            # Send the email notification
            # IMPORTANT: Replace with your actual email address
            self.Notify.Email("dca.llc.md@gmail.com", summary_subject, summary_message)

            self.last_email_date = self.Time.date()

        # Initialize start-of-day portfolio value on the first run
        if self.portfolio_value_at_start_of_day == 0:
            self.portfolio_value_at_start_of_day = self.Portfolio.TotalPortfolioValue

        # Reset daily anchor at the start of a new trading day
        if self.last_loss_limit_date is None or self.Time.date() > self.last_loss_limit_date:
            self.portfolio_value_at_start_of_day = self.Portfolio.TotalPortfolioValue
            self.last_loss_limit_date = self.Time.date()

        # === Daily Loss Limit Check ===
        if self.portfolio_value_at_start_of_day > 0:
            daily_pnl_pct = (
                self.Portfolio.TotalPortfolioValue - self.portfolio_value_at_start_of_day
            ) / self.portfolio_value_at_start_of_day

            if daily_pnl_pct < self.daily_loss_limit:
                self.Log(f"EMERGENCY EXIT: Daily loss {daily_pnl_pct:.2%} < limit {self.daily_loss_limit:.2%}. Liquidating and pausing.")
                self.Liquidate()
                # Halt further trading for the day by setting a high VIX regime
                self.market_regime = 'BEAR' 
                return
        
        # Manage existing positions with the current data slice
        self.ManagePositions(data)

    def PerformWeeklyScreening(self):
        """
        Scheduled event that simply sets the screening date.
        This acts as a trigger for the logic inside OnData.
        """
        self.last_screening_date = self.Time.date()

    def WeeklyScreeningAndTrading(self, data):
        # === Check if enough days have passed since last screening ===
        if self.last_screening_date != self.Time.date():
            return # Only run on the day the scheduled event fires
        
        if (self.Time.date() - self.last_rebalance_date).days >= self.rebalance_frequency_days:
            self.Log(f"Quarterly Rebalancing: Setting SPY to {self.SPY_ALLOCATION:.0%}, XLU to {self.XLU_ALLOCATION:.0%}")
            self.SetHoldings(self.spy, self.SPY_ALLOCATION)
            self.SetHoldings(self.xlu, self.XLU_ALLOCATION)
            self.last_rebalance_date = self.Time.date()
        vix_security = self.Securities[self.vix]
        
        if vix_security.HasData and vix_security.Price > 0:
            vix_price = vix_security.Price
            
            # Update regime only if we have fresh VIX data
            if vix_price > self.VIX_THRESHOLD:
                self.market_regime = 'BEAR'
            else:
                self.market_regime = 'BULL'
            
            self.Log(f"VIX={vix_price:.2f} | Regime: {self.market_regime}")
        else:
            # Use last known regime if VIX unavailable (don't skip screening!)
            self.Log(f"VIX unavailable, using last known regime: {self.market_regime}")
        
        if self.market_regime == 'BEAR':
            self.Log(f"Market regime is BEAR. Pausing new trades.")
            return  # Don't screen in bear market
        
        # --- Screening Logic using IV Cache ---
        # Use a copy of the cache to avoid issues if the cache is modified during screening
        current_iv_data = self.iv_cache.copy()

        # Ensure we only consider symbols that are currently in our universe
        # This cleans up any stale IV data for symbols that might have left the universe
        active_equity_symbols = {s.Symbol for s in self.ActiveSecurities.Values if s.Type == SecurityType.Equity and s.Symbol not in [self.spy, self.xlu]}
        current_iv_data = {s: iv for s, iv in current_iv_data.items() if s in active_equity_symbols}

        self.Log(f"SCREENING: Found {len(current_iv_data)} stocks with a ready IV indicator.")

        # Determine the pool of candidates for trading
        candidate_pool = {}
        is_initial_run = not self.previous_iv

        if is_initial_run:
            self.Log("Initial screening run: Selecting stocks based on highest absolute IV.")
            # On the first run, we use the absolute IV values
            candidate_pool = current_iv_data
        else:
            # On subsequent runs, calculate the increase in IV
            iv_increase_data = {}
            for symbol, current_iv in current_iv_data.items():
                if symbol in self.previous_iv:
                    previous_iv = self.previous_iv[symbol]
                    # Only consider stocks where IV has actually increased
                    iv_increase = current_iv - previous_iv
                    if iv_increase > 0:
                        iv_increase_data[symbol] = iv_increase
                        self.Log(f"IV_CHANGE: {symbol.Value}: Current IV {current_iv:.4f} - Previous IV {previous_iv:.4f} = Increase {iv_increase:.4f}")
            
            self.Log(f"SCREENING: Found {len(iv_increase_data)} stocks with an IV increase since last screening.")
            candidate_pool = iv_increase_data

        # --- Sentiment Analysis ---
        # We still use the call/put ratio as a secondary filter
        bullish_sentiment_pool = {}
        for symbol, value in candidate_pool.items():
            # Ensure the option chain exists and has contracts to avoid errors
            if not data.OptionChains.ContainsKey(symbol) or len(data.OptionChains[symbol]) == 0:
                continue

            chain = data.OptionChains[symbol]
            contracts = chain.Contracts.values()
            
            total_call_volume = sum(c.Volume for c in contracts if c.Right == OptionRight.Call)
            total_put_volume = sum(c.Volume for c in contracts if c.Right == OptionRight.Put)

            call_put_ratio = float('inf') if total_put_volume == 0 and total_call_volume > 0 else (total_call_volume / total_put_volume if total_put_volume > 0 else 0)

            if call_put_ratio >= 1.10:
                # The 'value' is either the absolute IV (first run) or the IV increase (subsequent runs)
                bullish_sentiment_pool[symbol] = value

        self.Log(f"SCREENING: {len(bullish_sentiment_pool)} stocks passed sentiment filter (Call/Put Ratio >= 1.10).")

        if not bullish_sentiment_pool:
            self.Log("No candidates passed all filters. Skipping this screening cycle.")
            return

        # Rank candidates by their value (either absolute IV or IV increase)
        self.Log(f"Ranking candidates by {'absolute IV' if is_initial_run else 'IV increase'}.")
        top_sorted = sorted(bullish_sentiment_pool.items(), key=lambda item: item[1], reverse=True)

        # Select the top 15 symbols to trade
        top_15_symbols = [symbol for symbol, _ in top_sorted[:15]]

        for symbol in top_15_symbols:
            current_stock_allocation = self.Portfolio.TotalHoldingsValue / self.Portfolio.TotalPortfolioValue
            if current_stock_allocation + self.POSITION_ALLOCATION > self.MAX_PORTFOLIO_ALLOCATION:
                break
            if self.Portfolio[symbol].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 the current IV data to be used as 'previous' in the next screening cycle
        self.previous_iv = current_iv_data
                
    def CacheImpliedVolatility(self, data):
        """
        Caches the Implied Volatility (IV) for each underlying equity from the OptionChains.
        This method is called every tick from OnData to ensure the cache is fresh.
        """
        for chain in data.OptionChains.values():
            underlying_symbol = chain.Underlying.Symbol  # Use this like the working code
            
            # Validate the underlying has data
            if not self.Securities.ContainsKey(underlying_symbol) or not self.Securities[underlying_symbol].HasData:
                continue
            
            underlying_price = self.Securities[underlying_symbol].Price
            if underlying_price <= 0:
                continue
            
            try:  # Wrap filtering in try-catch to catch date calculation issues
                # Filter for liquid Call options in the desired DTE range (7-39 days)
                calls = [c for c in chain.Contracts.values()
                         if c.Right == OptionRight.Call and
                         (c.Volume > 0 or c.OpenInterest > 0) and
                         7 <= (c.Expiry.date() - self.Time.date()).days <= 39]
                
                if not calls:
                    # Fallback: if no calls in primary DTE range, try closest to 23 days
                    all_calls_with_liquidity = [c for c in chain.Contracts.values()
                                                if c.Right == OptionRight.Call and
                                                (c.Volume > 0 or c.OpenInterest > 0)]
                    if all_calls_with_liquidity:
                        calls = sorted(all_calls_with_liquidity, 
                                       key=lambda c: abs((c.Expiry.date() - self.Time.date()).days - 23))[:5]
                    if not calls:
                        continue  # No suitable calls found
                
                # Select the ATM contract
                atm_contract = min(calls, key=lambda c: abs(c.Strike - underlying_price))
                
                # Store the IV
                if atm_contract.ImpliedVolatility is not None and atm_contract.ImpliedVolatility > 0:
                    if self.iv_cache.get(underlying_symbol) != atm_contract.ImpliedVolatility:
                        self.iv_cache[underlying_symbol] = atm_contract.ImpliedVolatility
                        
            except Exception as e:
                self.Log(f"[IV_ERROR] Failed to cache IV for {underlying_symbol.Value}: {str(e)}")
                continue


    def ManagePositions(self, data):
        """Scheduled function to manage open positions."""
        # This function is called from OnData and is responsible for managing
        # existing positions based on price updates (stop loss, take profit).
        # It needs the `data` slice to check for stop-loss conditions based on IV, if re-enabled.
    
        # Timeout protection - timezone safe
        for order_ticket in self.Transactions.GetOpenOrderTickets():
            try:
                # Safe subtraction using datetime utilities
                time_open = (self.Time - order_ticket.Time).total_seconds()
                if time_open > 125:
                    self.Log(f"⚠️ Order timeout {order_ticket.Symbol} (ID:{order_ticket.OrderId}). Canceling.")
                    order_ticket.Cancel()
            except TypeError as e:
                # Skip if timezone issue (rare but safe)
                self.Log(f"⚠️ Skipping timeout check for order {order_ticket.OrderId}: {str(e)[:50]}")
                continue

        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
            # To re-enable this, you would use self.iv_cache[symbol] to get the latest IV
            # current_iv = self.iv_cache.get(symbol, 0.0) # Get from cache, default to 0 if not found
            # if 0 < current_iv < self.MIN_IMPLIED_VOLATILITY: # Assuming MIN_IMPLIED_VOLATILITY is the threshold for exit
            #     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.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.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)
        
        # Reset daily slippage at start of new trading day
        if self.last_slippage_reset_date is None or self.Time.date() > self.last_slippage_reset_date:
            self.daily_slippage_dollars = 0.0
            self.daily_trades_count = 0
            self.last_slippage_reset_date = self.Time.date()
        
        # Log large trades (>100 shares) for visibility
        if order.AbsoluteQuantity >= 100:
            self.Log(f"FILLED {order.Symbol.Value} {order.Quantity} @ ${orderEvent.FillPrice:.2f}")
        
        # === Calculate and accumulate slippage ===
        market_price = self.Securities[order.Symbol].Price
        
        if market_price > 0:
            filled_price = orderEvent.FillPrice
            
            # Calculate slippage in dollars (absolute impact per share)
            if order.Direction == OrderDirection.Buy:
                slippage_per_share = filled_price - market_price  # Negative = good (bought cheaper)
            else:  # Sell
                slippage_per_share = market_price - filled_price  # Negative = good (sold more)
            
            # Total slippage for this trade in dollars
            trade_slippage_dollars = slippage_per_share * order.AbsoluteQuantity
            
            # Accumulate daily slippage
            self.daily_slippage_dollars += trade_slippage_dollars
            self.daily_trades_count += 1