Overall Statistics
Total Orders
888
Average Win
0.16%
Average Loss
-0.33%
Compounding Annual Return
-4.287%
Drawdown
19.900%
Expectancy
-0.137
Start Equity
100000
End Equity
80908.2
Net Profit
-19.092%
Sharpe Ratio
-1.312
Sortino Ratio
-0.477
Probabilistic Sharpe Ratio
0.000%
Loss Rate
42%
Win Rate
58%
Profit-Loss Ratio
0.48
Alpha
-0.064
Beta
-0.008
Annual Standard Deviation
0.049
Annual Variance
0.002
Information Ratio
-0.917
Tracking Error
0.153
Treynor Ratio
8.113
Total Fees
$1102.80
Estimated Strategy Capacity
$0
Lowest Capacity Asset
GOOG YX9XLSFTQ8VA|GOOG T1AZ164W5VTX
Portfolio Turnover
0.17%
Drawdown Recovery
15
from AlgorithmImports import *
from datetime import timedelta
from typing import List, Tuple
import datetime as dt

class UpcomingEarningsExampleAlgorithm(QCAlgorithm):
    options_by_symbol: dict = {}        # { underlying: (callSym, putSym) }
    pending_trade: set = set()          # underlyings awaiting liquidity check & entry
    active_positions: set = set()       # underlyings with open positions (protected from removal)
    max_spread_pct: float = 0.10        # 10%
    target_delta: float = 0.20          # 20 delta for strangle legs
    min_market_cap: float = 600e6       # Minimum $600M market cap
    min_dollar_volume: float = 50e6     # Minimum $50M daily dollar volume
    min_stock_price: float = 10.0       # Minimum $10 stock price
    min_credit: float = 1.00            # Minimum $1.00 credit per strangle

    def initialize(self) -> None:
        self.set_start_date(2021, 1, 1)
        self.set_end_date(2025, 10, 31)
        self.set_cash(100000)
        self.set_time_zone("America/New_York")

        self.set_security_initializer(BrokerageModelSecurityInitializer(
            self.brokerage_model, FuncSecuritySeeder(self.get_last_known_prices)))

        self.universe_settings.resolution = Resolution.MINUTE
        self.universe_settings.data_normalization_mode = DataNormalizationMode.RAW
        self.add_universe(EODHDUpcomingEarnings, self.selection)
        
        # Schedule liquidation at 9:45 AM ET
        self.schedule.on(
            self.date_rules.every_day(),
            self.time_rules.at(9, 45),
            self.liquidate_all_positions
        )

    def selection(self, earnings: List[EODHDUpcomingEarnings]) -> List[Symbol]:
        """
        Select stocks that report:
        - AfterMarket TODAY (we trade today before close, hold through tonight's earnings)
        - BeforeMarket TOMORROW (we trade today before close, hold through tomorrow's pre-market earnings)

        reportDate is a date object.
        reportTime is a string: "BeforeMarket" or "AfterMarket".
        Returns list(Symbol) as expected by the rest of the algorithm.
        """
        today = self.Time.date()
        tomorrow = today + timedelta(days=1)
        symbols = set()

        for e in earnings:
            # Get date and time
            report_date = getattr(e, "ReportDate", None) or getattr(e, "report_date", None)
            report_time = getattr(e, "ReportTime", None) or getattr(e, "report_time", None)

            if not report_date or not report_time:
                continue

            # Normalize date (ensure it's a date object)
            report_date = report_date.date() if hasattr(report_date, "date") else report_date

            # Selection logic - FIXED
            # Case 1: AfterMarket today (earnings tonight after close)
            if report_time == "AfterMarket" and report_date == today:
                symbols.add(e.Symbol)
                # self.Debug(f"[SELECTED] {e.Symbol.Value}: AfterMarket TODAY ({report_date})")
            
            # Case 2: BeforeMarket tomorrow (earnings tomorrow morning before open)
            elif report_time == "BeforeMarket" and report_date == tomorrow:
                symbols.add(e.Symbol)
                # self.Debug(f"[SELECTED] {e.Symbol.Value}: BeforeMarket TOMORROW ({report_date})")

        # Plot and return
        if symbols:
            self.Plot("Universe", "Count", len(symbols))

        return list(symbols)


    def on_securities_changed(self, changes: SecurityChanges) -> None:
        # Handle equity additions: pick 20-delta call/put, subscribe to contracts, but DO NOT trade yet
        for added in [s for s in changes.added_securities if s.type == SecurityType.EQUITY]:
            # Skip if underlying is not tradable (delisted, halted, etc.)
            if not added.is_tradable:
                # self.debug(f"[SKIP] {added.symbol.Value} is not tradable, skipping")
                continue
            
            # Filter by stock price
            if added.price < self.min_stock_price:
                # self.debug(f"[SKIP] {added.symbol.Value} price ${added.price:.2f} < ${self.min_stock_price:.0f}")
                continue
            
            # Filter by market cap
            if hasattr(added.fundamentals, 'market_cap') and added.fundamentals.market_cap:
                if added.fundamentals.market_cap < self.min_market_cap:
                    # self.debug(f"[SKIP] {added.symbol.Value} market cap ${added.fundamentals.market_cap/1e9:.2f}B < ${self.min_market_cap/1e9:.0f}B")
                    continue
            
            # Filter by dollar volume (use 30-day average)
            if hasattr(added, 'volume_model') and added.volume_model:
                dollar_volume = added.volume_model.value * added.price
                if dollar_volume < self.min_dollar_volume:
                    # self.debug(f"[SKIP] {added.symbol.Value} dollar volume ${dollar_volume/1e6:.1f}M < ${self.min_dollar_volume/1e6:.0f}M")
                    continue
            
            call, put = self.select_option_contracts(added.symbol)
            if not call or not put:
                continue

            # Cache selection and subscribe to get live quotes for liquidity check
            # Wrap in try-except to handle any issues with adding options
            try:
                self.options_by_symbol[added.symbol] = (call, put)
                call_sub = self.add_option_contract(call).symbol
                put_sub = self.add_option_contract(put).symbol
                self.pending_trade.add(added.symbol)
            except Exception as e:
                # self.debug(f"[ERROR] Failed to add options for {added.symbol.Value}: {str(e)}")
                # Clean up if partially added
                self.options_by_symbol.pop(added.symbol, None)
                continue

        # Handle equity removals: ONLY remove if NOT in active positions
        for removed in [s for s in changes.removed_securities if s.type == SecurityType.EQUITY]:
            # KEY FIX: Don't remove securities with open positions
            if removed.symbol in self.active_positions:
                # self.debug(f"[UNIVERSE] {removed.symbol.Value} removed from universe but has active positions - keeping until liquidation")
                continue
            
            # Safe to remove - no active positions
            self.liquidate(removed.symbol)
            contracts = self.options_by_symbol.pop(removed.symbol, None)
            self.pending_trade.discard(removed.symbol)
            if contracts:
                for c in contracts:
                    self.remove_option_contract(c)
            # self.debug(f"[UNIVERSE] Removed {removed.symbol.Value}, flattened & unsubscribed.")

    def on_data(self, slice: Slice) -> None:
        # Only enter trades between 3:50 PM and 4:00 PM ET (10 min before close)
        current_time = self.time.time()
        entry_window_start = dt.time(15, 50)  # 3:50 PM ET
        entry_window_end = dt.time(16, 0)     # 4:00 PM ET
        
        # Check if we're in the entry window
        if not (entry_window_start <= current_time <= entry_window_end):
            return
        
        # Try to enter for each pending underlying once quotes are available & liquid
        to_remove = []
        for underlying in list(self.pending_trade):
            call_sym, put_sym = self.options_by_symbol.get(underlying, (None, None))
            if not call_sym or not put_sym:
                to_remove.append(underlying)
                continue

            call_sec = self.securities.get(call_sym, None)
            put_sec  = self.securities.get(put_sym,  None)

            # Need valid quotes
            call_spread = self._spread_pct(call_sec)
            put_spread  = self._spread_pct(put_sec)
            if call_spread is None or put_spread is None:
                # quotes not ready yet
                continue

            # If either leg is illiquid (> max_spread_pct), skip trading this underlying
            if call_spread > self.max_spread_pct or put_spread > self.max_spread_pct:
                # Unsubscribe the contracts to free resources
                for c in (call_sym, put_sym):
                    self.remove_option_contract(c)
                to_remove.append(underlying)
                self.options_by_symbol.pop(underlying, None)
                continue

            # Passed liquidity check — calculate position size as 0.5% of portfolio
            portfolio_value = self.portfolio.total_portfolio_value
            target_position_value = portfolio_value * 0.005  # 0.5% of portfolio
            
            # Estimate the credit received per strangle (mid prices)
            call_mid = (call_sec.bid_price + call_sec.ask_price) / 2.0
            put_mid = (put_sec.bid_price + put_sec.ask_price) / 2.0
            credit_per_contract = call_mid + put_mid  # Credit per share
            
            # Check minimum credit requirement
            if credit_per_contract < self.min_credit:
                # self.debug(f"[SKIP] {underlying.Value} credit ${credit_per_contract:.2f} < ${self.min_credit:.2f} minimum")
                # Unsubscribe the contracts to free resources
                for c in (call_sym, put_sym):
                    self.remove_option_contract(c)
                to_remove.append(underlying)
                self.options_by_symbol.pop(underlying, None)
                continue
            
            credit_per_strangle = credit_per_contract * 100  # *100 for contract multiplier
            
            # Calculate number of strangles to sell (minimum 1)
            if credit_per_strangle > 0:
                quantity = max(1, int(target_position_value / credit_per_strangle))
            else:
                quantity = 1
            
            # Place the short strangle (as combo)
            self.combo_market_order([Leg.create(call_sym, -1), Leg.create(put_sym, -1)], quantity)
            self.debug(f"[ENTRY] Short {quantity}x strangle on {underlying.Value} (call spr={call_spread:.1%}, put spr={put_spread:.1%}, credit=${credit_per_contract:.2f}/share)")
            
            # Mark as active position to protect from universe removal
            self.active_positions.add(underlying)
            to_remove.append(underlying)

        # Clean up pending set for processed symbols
        for u in to_remove:
            self.pending_trade.discard(u)

    def select_option_contracts(self, underlying: Symbol) -> Tuple[Symbol, Symbol]:
        """
        Choose a same-expiry 20-delta strangle (shortest expiration):
        - Put: ~20 delta (OTM put)
        - Call: ~20 delta (OTM call)
        
        Also filters for option liquidity by checking spreads.
        """
        # Get all tradable option contracts for filtering
        option_contract_list = self.option_chain(underlying)
        if not option_contract_list:
            return None, None

        # Nearest expiry at least 1 week out
        min_expiry = self.time + timedelta(days=7)
        valid = [x for x in option_contract_list if x.id.date >= min_expiry]
        if len(valid) < 2:
            return None, None
        
        # Get the shortest expiry
        expiry = min(x.id.date for x in valid)
        same_expiry = [x for x in valid if x.id.date == expiry]

        # Get spot price
        spot = self.securities[underlying].price
        
        # Split by right - for strangle we want OTM options
        puts = [x for x in same_expiry if x.id.option_right == OptionRight.PUT and x.id.strike_price < spot]
        calls = [x for x in same_expiry if x.id.option_right == OptionRight.CALL and x.id.strike_price > spot]

        if not puts or not calls:
            return None, None

        # Find the option contracts closest to target delta
        put_contract = self._find_delta_contract(underlying, puts, -self.target_delta)
        call_contract = self._find_delta_contract(underlying, calls, self.target_delta)

        if not put_contract or not call_contract:
            return None, None

        # Check option liquidity by spread (if securities already exist)
        # This provides early filtering before we subscribe in OnSecuritiesChanged
        if put_contract in self.securities and call_contract in self.securities:
            put_spread = self._spread_pct(self.securities[put_contract])
            call_spread = self._spread_pct(self.securities[call_contract])
            
            if put_spread is not None and call_spread is not None:
                if put_spread > self.max_spread_pct or call_spread > self.max_spread_pct:
                    # self.debug(f"[SKIP] {underlying.Value} options too illiquid at selection (call={call_spread:.1%}, put={put_spread:.1%})")
                    return None, None

        return call_contract, put_contract

    def _find_delta_contract(self, underlying: Symbol, contracts: List[Symbol], target_delta: float) -> Symbol:
        """
        Find the contract closest to the target delta.
        If greeks are not available, estimate using a simple strike distance heuristic.
        """
        if not contracts:
            return None
        
        spot = self.securities[underlying].price
        
        # Try to use actual greeks if available
        best_contract = None
        best_delta_diff = float('inf')
        
        for contract in contracts:
            # Try to get the option contract security
            if contract in self.securities:
                option_sec = self.securities[contract]
                # Check if greeks are available
                if hasattr(option_sec, 'greeks') and option_sec.greeks is not None:
                    delta = option_sec.greeks.delta
                    delta_diff = abs(delta - target_delta)
                    if delta_diff < best_delta_diff:
                        best_delta_diff = delta_diff
                        best_contract = contract
        
        # If we found a contract using greeks, return it
        if best_contract is not None:
            return best_contract
        
        # Fallback: use strike distance as proxy for delta
        target_distance_pct = 0.10  # 10% OTM as approximation for 20 delta
        
        if target_delta > 0:  # Call
            target_strike = spot * (1 + target_distance_pct)
        else:  # Put
            target_strike = spot * (1 - target_distance_pct)
        
        # Find contract with strike closest to target
        best_contract = min(contracts, key=lambda x: abs(x.id.strike_price - target_strike))
        
        return best_contract

    def liquidate_all_positions(self) -> None:
        """
        Liquidate all option positions at 9:45 AM ET
        """
        if self.portfolio.invested:
            self.liquidate()
            # self.debug(f"[EXIT] Liquidated all positions at {self.time}")
            
            # Clear tracking dictionaries and active positions
            for underlying in list(self.options_by_symbol.keys()):
                contracts = self.options_by_symbol.pop(underlying, None)
                if contracts:
                    for c in contracts:
                        if c in self.securities:
                            self.remove_option_contract(c)
            
            self.pending_trade.clear()
            self.active_positions.clear()  # Clear active positions after liquidation

    def _spread_pct(self, sec: Security) -> float:
        """
        Returns bid/ask spread as a fraction of mid.
        None if quotes are not available or invalid.
        """
        if sec is None:
            return None
        bid = float(sec.bid_price) if sec.bid_price is not None else 0.0
        ask = float(sec.ask_price) if sec.ask_price is not None else 0.0
        if bid <= 0 or ask <= 0:
            return None
        mid = 0.5 * (bid + ask)
        if mid <= 0:
            return None
        return (ask - bid) / mid