Overall Statistics
Total Orders
1330
Average Win
0.32%
Average Loss
-0.36%
Compounding Annual Return
-17.632%
Drawdown
17.700%
Expectancy
-0.112
Start Equity
100000
End Equity
82367.75
Net Profit
-17.632%
Sharpe Ratio
-2.224
Sortino Ratio
-1.705
Probabilistic Sharpe Ratio
0.000%
Loss Rate
53%
Win Rate
47%
Profit-Loss Ratio
0.90
Alpha
-0.135
Beta
0.016
Annual Standard Deviation
0.059
Annual Variance
0.004
Information Ratio
-3.13
Tracking Error
0.109
Treynor Ratio
-8.262
Total Fees
$2897.75
Estimated Strategy Capacity
$0
Lowest Capacity Asset
ADSK 2ZNFM7EXBB2TI|ADSK R735QTJ8XC9X
Portfolio Turnover
2.28%
Drawdown Recovery
0
from AlgorithmImports import *
import math


class FactorModelingWOptions(QCAlgorithm):
    def Initialize(self) -> None:
        # Backtest parameters
        self.SetStartDate(2013, 1, 1)
        self.SetEndDate(2014, 1, 1)
        self.SetCash(100000)

        # Universe & valuation signal (smaller universe reduces fine + options load)
        self.UNIVERSE_SIZE = 300
        self.spy = self.AddEquity("SPY", Resolution.DAILY).Symbol
        # Options require RAW normalization; keep equities ADJUSTED
        self.SetSecurityInitializer(
            lambda sec: sec.SetDataNormalizationMode(
                DataNormalizationMode.RAW if sec.Type == SecurityType.Option else DataNormalizationMode.ADJUSTED
            )
        )

        # Option selection rules
        self.OPT_MAX_SIDE_DEVIATION = 0.08  # 8% max deviation from mid on either side
        # Tighter filter: around ATM strikes and 25-60 DTE approximates second monthly
        self.OPT_EXP_MIN = 25
        self.OPT_EXP_MAX = 60
        self.OPT_STRIKES_RANGE = 3  # +/- strikes around ATM
        # Cap number of underlyings per leg to avoid too many option chains
        self.MAX_UNDERLYINGS_PER_LEG = 25

        # State
        self.option_by_underlying = {}
        self.fine_last_sorted = []
        self.rebalance_flag = False

        # Universe selection: coarse/fine
        self.AddUniverse(self.CoarseSelection, self.FineSelection)

        # Universe settings to reduce data churn
        self.UniverseSettings.Resolution = Resolution.DAILY
        self.UniverseSettings.ExtendedMarketHours = False
        self.UniverseSettings.FillForward = True
        self.UniverseSettings.MinimumTimeInUniverse = 5  # at least a week

        # Weekly on Friday after market open
        self.Schedule.On(self.DateRules.Every(DayOfWeek.Friday), self.TimeRules.AfterMarketOpen(self.spy, 30), self.FlagRebalance)

        # Warm-up
        self.SetWarmUp(5, Resolution.DAILY)

        # Caches
        self._second_monthly_cache = {}
        self._second_monthly_cache_key = None

    def CoarseSelection(self, coarse):
        filtered = [c for c in coarse if c.HasFundamentalData and c.Price is not None and c.Price > 5]
        filtered.sort(key=lambda c: c.DollarVolume, reverse=True)
        return [c.Symbol for c in filtered[: self.UNIVERSE_SIZE]]

    def FineSelection(self, fine):
        rows = []
        for f in fine:
            try:
                # Exclude Financials sector
                is_financial = False
                ac = getattr(f, 'AssetClassification', None)
                sector_code = getattr(ac, 'MorningstarSectorCode', None) if ac is not None else None
                if sector_code is not None:
                    try:
                        # Prefer enum comparison when available
                        from QuantConnect.Data.Fundamental import MorningstarSectorCode  # type: ignore
                        if sector_code == MorningstarSectorCode.FinancialServices:
                            is_financial = True
                    except Exception:
                        # Fallback: common code for Financial Services is 103
                        try:
                            if int(sector_code) == 103:
                                is_financial = True
                        except Exception:
                            pass
                if is_financial:
                    continue

                # Use EV/EBIT as valuation metric (lower is cheaper)
                vr = getattr(f, 'ValuationRatios', None)
                ev_ebit = None
                if vr is not None:
                    ev_ebit = getattr(vr, 'EVToEBIT', None)
                    if ev_ebit is None:
                        ev_ebit = getattr(vr, 'EVToEbit', None)
                if ev_ebit is None or not math.isfinite(ev_ebit) or ev_ebit <= 0 or ev_ebit > 100:
                    continue
                rows.append((f.Symbol, ev_ebit))
            except Exception:
                continue
        if not rows:
            return []
        # Sort ascending: lowest EV/EBIT (cheapest) first
        rows.sort(key=lambda t: t[1])
        self.fine_last_sorted = [sym for sym, _ in rows]
        # Keep a manageable tradable set: top and bottom quartiles union
        n = len(rows)
        q = max(1, n // 4)
        top_syms = [sym for sym, _ in rows[:q]]      # cheapest quartile
        bot_syms = [sym for sym, _ in rows[-q:]]     # most expensive quartile
        return list(dict.fromkeys(top_syms + bot_syms))

    def FlagRebalance(self):
        self.rebalance_flag = True

    def OnData(self, data: Slice) -> None:
        if self.IsWarmingUp or not self.rebalance_flag:
            return
        self.rebalance_flag = False

        ranks = list(getattr(self, 'fine_last_sorted', []))
        if len(ranks) < 8:
            return

        # Quartiles
        n = len(ranks)
        q = n // 4
        top_quartile = ranks[:q]      # cheap by valuation (high earnings yield)
        bottom_quartile = ranks[-q:]  # expensive by valuation
        # Cap number of underlyings traded per leg to keep it fast
        top_quartile = top_quartile[:self.MAX_UNDERLYINGS_PER_LEG]
        bottom_quartile = bottom_quartile[:self.MAX_UNDERLYINGS_PER_LEG]

        # Build desired straddles
        desired = set()
        pv = self.Portfolio.TotalPortfolioValue
        # Equal budget split: 50% for short straddles, 50% for long straddles
        budget_short = pv * 0.5
        budget_long = pv * 0.5
        per_short = budget_short / max(1, len(top_quartile))
        per_long = budget_long / max(1, len(bottom_quartile))

        # Ensure subscriptions for all candidates
        for u in set(top_quartile + bottom_quartile):
            self._EnsureOptionSubscription(u)

        # Helper to select ATM straddle on second monthly expiry
        def select_straddle(chain, underlying):
            if chain is None:
                return None
            # Collect unique expiries
            expiries = sorted({c.Expiry.date() for c in chain})
            # Filter to standard monthly expiries (3rd Friday)
            def is_third_friday(d):
                return d.weekday() == 4 and 15 <= d.day <= 21
            # Cache key per-week to avoid recomputing
            week_key = (self.Time.year, self.Time.isocalendar()[1])
            if self._second_monthly_cache_key != week_key:
                self._second_monthly_cache = {}
                self._second_monthly_cache_key = week_key
            target_expiry = self._second_monthly_cache.get(underlying)
            if target_expiry is None:
                monthly = [d for d in expiries if is_third_friday(d)]
                if len(monthly) >= 2:
                    target_expiry = monthly[1]
                elif len(expiries) >= 2:
                    target_expiry = expiries[1]
                else:
                    return None
                self._second_monthly_cache[underlying] = target_expiry
            # Spot and ATM strike
            spot = self.Securities[underlying].Price
            # Find strikes on target expiry
            contracts_exp = [c for c in chain if c.Expiry.date() == target_expiry]
            if not contracts_exp:
                return None
            # Prefer contracts with valid quotes to reduce filtering later
            contracts_exp = [c for c in contracts_exp if (c.BidPrice is not None and c.AskPrice is not None)]
            strikes = sorted({c.Strike for c in contracts_exp})
            if not strikes:
                return None
            atm_strike = min(strikes, key=lambda k: abs(k - spot))
            call = next((c for c in contracts_exp if c.Right == OptionRight.CALL and c.Strike == atm_strike), None)
            put  = next((c for c in contracts_exp if c.Right == OptionRight.PUT  and c.Strike == atm_strike), None)
            if call is None or put is None:
                return None
            # Bid/ask filter
            okc, midc = self._BidAskOk(call.BidPrice, call.AskPrice)
            okp, midp = self._BidAskOk(put.BidPrice, put.AskPrice)
            if not (okc and okp):
                return None
            return (call.Symbol, midc, put.Symbol, midp)

        # Short straddles on top quartile
        for u in top_quartile:
            opt_sym = self.option_by_underlying.get(u)
            if opt_sym is None:
                continue
            chain = data.OptionChains.get(opt_sym)
            sel = select_straddle(chain, u)
            if sel is None:
                continue
            call_sym, call_mid, put_sym, put_mid = sel
            prem = (call_mid + put_mid) * 100
            if prem <= 0:
                continue
            qty = int(max(0, per_short // prem))
            if qty <= 0:
                continue
            desired.update([call_sym, put_sym])
            # Place/adjust orders (sell straddle)
            self._EnsurePosition(call_sym, -qty, tag="Short straddle call")
            self._EnsurePosition(put_sym, -qty, tag="Short straddle put")

        # Long straddles on bottom quartile
        for u in bottom_quartile:
            opt_sym = self.option_by_underlying.get(u)
            if opt_sym is None:
                continue
            chain = data.OptionChains.get(opt_sym)
            sel = select_straddle(chain, u)
            if sel is None:
                continue
            call_sym, call_mid, put_sym, put_mid = sel
            prem = (call_mid + put_mid) * 100
            if prem <= 0:
                continue
            qty = int(max(0, per_long // prem))
            if qty <= 0:
                continue
            desired.update([call_sym, put_sym])
            # Place/adjust orders (buy straddle)
            self._EnsurePosition(call_sym, qty, tag="Long straddle call")
            self._EnsurePosition(put_sym, qty, tag="Long straddle put")

        # Clean up undesired options
        for kv in self.Portfolio:
            sym = kv.Key
            if sym.SecurityType == SecurityType.Option and self.Portfolio[sym].Invested and sym not in desired:
                self.Liquidate(sym, tag="Weekly roll")

    # Utilities
    def _EnsureOptionSubscription(self, underlying: Symbol):
        if underlying in self.option_by_underlying:
            return self.option_by_underlying[underlying]
        opt = self.AddOption(underlying, Resolution.DAILY)
        # Keep chains small: near-ATM (+/- OPT_STRIKES_RANGE) and target 2nd-month window (EXP_MIN..EXP_MAX)
        opt.SetFilter(lambda u: u.Strikes(-self.OPT_STRIKES_RANGE, self.OPT_STRIKES_RANGE)
                                 .Expiration(self.OPT_EXP_MIN, self.OPT_EXP_MAX))
        self.option_by_underlying[underlying] = opt.Symbol
        return opt.Symbol

    def _BidAskOk(self, bid: float, ask: float):
        if bid is None or ask is None or bid <= 0 or ask <= 0:
            return False, 0.0
        mid = 0.5 * (bid + ask)
        if mid <= 0:
            return False, 0.0
        dev = max(mid - bid, ask - mid) / mid
        return dev <= self.OPT_MAX_SIDE_DEVIATION, mid

    def _EnsurePosition(self, symbol: Symbol, target_qty: int, tag: str):
        held = self.Portfolio[symbol].Quantity if symbol in self.Portfolio else 0
        delta = target_qty - held
        if delta != 0:
            self.MarketOrder(symbol, delta, tag=tag)