Overall Statistics
Total Orders
355
Average Win
0.07%
Average Loss
-0.05%
Compounding Annual Return
-0.138%
Drawdown
0.600%
Expectancy
0.443
Start Equity
100000
End Equity
99585
Net Profit
-0.415%
Sharpe Ratio
-4.171
Sortino Ratio
-4.884
Probabilistic Sharpe Ratio
0.144%
Loss Rate
41%
Win Rate
59%
Profit-Loss Ratio
1.44
Alpha
-0.008
Beta
-0.004
Annual Standard Deviation
0.002
Annual Variance
0
Information Ratio
-1.444
Tracking Error
0.099
Treynor Ratio
2.094
Total Fees
$355.00
Estimated Strategy Capacity
$0
Lowest Capacity Asset
SPY VXBK4R8VKMAU|SPY R735QTJ8XC9X
Portfolio Turnover
0.05%
Drawdown Recovery
0
# region imports
from AlgorithmImports import *
from datetime import timedelta, date
# endregion

class SpyRiskReversal2M(QCAlgorithm):

    def Initialize(self):
        self.SetStartDate(2012, 1, 1)
        self.SetEndDate(2015, 1, 1)
        self.SetCash(100000)
        self.UniverseSettings.Resolution = Resolution.Minute
        self.SetWarmUp(timedelta(days=3))  # let greeks populate

        # Underlying
        self.spy = self.AddEquity("SPY", Resolution.Minute).Symbol

        # Options
        opt = self.AddOption("SPY", Resolution.Minute)
        opt.SetFilter(self.UniverseFilter)
        self.optionSym = opt.Symbol

        # State
        self.ready = False
        self.currentExpiry = None
        self.callSymbol = None
        self.putSymbol = None

        # Scheduled flags -> handled in OnData (where OptionChains exist)
        self.needs_open = False
        self.needs_roll = False
        self.needs_exit_check = False

        # Schedules
        self.Schedule.On(self.DateRules.Every(DayOfWeek.Monday),
                         self.TimeRules.At(10, 5),
                         self.FlagWeeklyRoll)
        self.Schedule.On(self.DateRules.EveryDay("SPY"),
                         self.TimeRules.BeforeMarketClose("SPY", 10),
                         self.FlagExitCheck)

    def OnWarmupFinished(self):
        self.ready = True
        self.needs_open = True
        self.Debug("Warmup finished — will open first cycle from OnData.")

    def UniverseFilter(self, u: OptionFilterUniverse) -> OptionFilterUniverse:
        # 35–90 DTE so we can choose ~60
        return (u.IncludeWeeklys()
                .Expiration(timedelta(days=35), timedelta(days=90))
                .Strikes(-60, +60))

    # ---------- Scheduler flag setters ----------
    def FlagWeeklyRoll(self):
        if not self.ready:
            return
        self.needs_roll = True

    def FlagExitCheck(self):
        if not self.ready:
            return
        self.needs_exit_check = True

    # ---------- Core selection utilities (run in OnData) ----------
    def select_expiry_closest_to(self, chain, target_days: int):
        expiries = sorted({c.Expiry.date() for c in chain})
        if not expiries:
            return None
        today = self.Time.date()
        return min(expiries, key=lambda d: abs((d - today).days - target_days))

    def safe_delta(self, c):
        try:
            g = c.Greeks
            if g and g.Delta is not None:
                return float(g.Delta)
        except Exception:
            pass
        # quick fallback if greeks are temporarily missing
        spot = float(self.Securities[self.spy].Price)
        if c.Right == OptionRight.Call:
            return 0.25 if c.Strike >= spot else 0.5
        else:
            return -0.25 if c.Strike <= spot else -0.5

    def select_25d_rr(self, chain, expiry_date):
        contracts = [c for c in chain if c.Expiry.date() == expiry_date and c.BidPrice is not None and c.AskPrice is not None]
        if not contracts:
            return None, None
        calls = [c for c in contracts if c.Right == OptionRight.Call]
        puts  = [c for c in contracts if c.Right == OptionRight.Put]
        if not calls or not puts:
            return None, None

        call = min(calls, key=lambda k: abs(self.safe_delta(k) - 0.25))
        put  = min(puts,  key=lambda k: abs(self.safe_delta(k) + 0.25))
        return call, put

    # ---------- Trading helpers ----------
    def set_short_shares(self, target_short_qty=-50):
        cur = self.Portfolio[self.spy].Quantity
        if cur != target_short_qty:
            self.MarketOrder(self.spy, target_short_qty - cur)
            self.Debug(f"Set SPY shares to {target_short_qty}")

    def close_current_legs(self):
        for sym in (self.callSymbol, self.putSymbol):
            if sym and self.Portfolio[sym].Invested:
                self.MarketOrder(sym, -self.Portfolio[sym].Quantity)

    def close_all_spy_options(self):
        for kv in self.Portfolio:
            sym = kv.Key
            if sym.SecurityType == SecurityType.Option and sym.HasUnderlying and sym.Underlying == self.spy:
                h = self.Portfolio[sym]
                if h.Invested and h.Quantity != 0:
                    self.MarketOrder(sym, -h.Quantity)

    # ---------- Main logic in OnData ----------
    def OnData(self, data: Slice):
        if not self.ready:
            return

        chain = data.OptionChains.get(self.optionSym)
        if chain is None or len(chain) == 0:
            return  # wait until we have contracts

        today = self.Time.date()

        # 1) Exit check at 14 DTE
        if self.needs_exit_check and self.currentExpiry is not None:
            dte = (self.currentExpiry - today).days
            if dte <= 14:
                self.Debug(f"Exit (DTE={dte}) — closing current cycle.")
                self.close_current_legs()
                # keep the stock overlay; we'll re-affirm below
                self.currentExpiry = None
                self.callSymbol = None
                self.putSymbol = None
                self.needs_open = True  # immediately start the next cycle
            self.needs_exit_check = False

        # 2) If no active cycle, open a new ~60 DTE RR
        if self.needs_open and self.currentExpiry is None:
            expiry = self.select_expiry_closest_to(chain, 60)
            if expiry:
                c, p = self.select_25d_rr(chain, expiry)
                if c and p:
                    # flatten any leftover options just in case
                    self.close_all_spy_options()
                    # stock overlay
                    self.set_short_shares(-50)
                    # enter RR: +1 call, -1 put
                    self.MarketOrder(c.Symbol, +1)
                    self.MarketOrder(p.Symbol, -1)
                    self.currentExpiry = expiry
                    self.callSymbol = c.Symbol
                    self.putSymbol = p.Symbol
                    self.Debug(f"Opened RR exp {expiry}: +C{c.Strike} (~Δ{self.safe_delta(c):.2f}) / -P{p.Strike} (~Δ{self.safe_delta(p):.2f}); short 50 SPY")
                    self.needs_open = False
            else:
                # no expiry yet — try again next OnData
                pass

        # 3) Weekly roll to keep legs near 25Δ while >14 DTE
        if self.needs_roll and self.currentExpiry is not None:
            dte = (self.currentExpiry - today).days
            if dte > 14:
                newC, newP = self.select_25d_rr(chain, self.currentExpiry)
                if newC and newP and (self.callSymbol != newC.Symbol or self.putSymbol != newP.Symbol):
                    self.Debug(f"Rolling strikes to keep ~25Δ (DTE={dte}).")
                    self.close_current_legs()
                    self.MarketOrder(newC.Symbol, +1)
                    self.MarketOrder(newP.Symbol, -1)
                    self.callSymbol = newC.Symbol
                    self.putSymbol = newP.Symbol
                    self.Debug(f"Rolled to +C{newC.Strike} / -P{newP.Strike} exp {self.currentExpiry}")
            # maintain stock overlay
            self.set_short_shares(-50)
            self.needs_roll = False