Overall Statistics
Total Orders
10
Average Win
0.15%
Average Loss
-0.03%
Compounding Annual Return
0.033%
Drawdown
0.100%
Expectancy
0.221
Start Equity
100000
End Equity
100033
Net Profit
0.033%
Sharpe Ratio
-1.545
Sortino Ratio
-0.379
Probabilistic Sharpe Ratio
14.322%
Loss Rate
80%
Win Rate
20%
Profit-Loss Ratio
5.11
Alpha
-0.002
Beta
-0.001
Annual Standard Deviation
0.001
Annual Variance
0
Information Ratio
-1.825
Tracking Error
0.108
Treynor Ratio
2.081
Total Fees
$10.00
Estimated Strategy Capacity
$150000.00
Lowest Capacity Asset
SPY XUERCYEFBQ06|SPY R735QTJ8XC9X
Portfolio Turnover
0.00%
Drawdown Recovery
179
from AlgorithmImports import *

class PriceComparisonAlgorithm(QCAlgorithm):

    # ---------------------------------------------------------------------- #
    #  INITIALIZATION                                                        #
    # ---------------------------------------------------------------------- #
    def initialize(self) -> None:
        self.set_start_date(2021, 1, 1)
        self.set_end_date(2022, 1, 1)
        self.set_cash(100_000)

        # Underlying
        self._spy: Symbol = self.add_equity("SPY", Resolution.MINUTE).symbol
        option = self.add_option("SPY", Resolution.MINUTE)
        option.set_filter(-10, 10, timedelta(0), timedelta(days=7))
        self._spy_options_symbol: Symbol = option.symbol

        # Trade-tracking
        self._current_call_contract: Symbol | None = None
        self._current_put_contract: Symbol | None = None
        self._current_ticket: OrderTicket | None = None
        self._entry_spy_price: float | None = None
        self._entry_time: datetime = self.start_date      # initialise

        # Signal flags
        self.buy_signal_active: bool = False
        self.sell_signal_active: bool = False

        # ------------------------------------------------  Charts  ---------- #
        price_chart = Chart("Price Comparison")
        price_chart.add_series(Series("Weighted Mean", SeriesType.LINE, 0))
        price_chart.add_series(Series("Live Price",   SeriesType.LINE, 0))
        price_chart.add_series(Series("Signal",       SeriesType.SCATTER, 0))
        self.add_chart(price_chart)

        for chart_name, series_name in [
            ("Z-Score", "Z"),
            ("Mean Slope", "Slope"),
            ("RSI", "RSI"),
            ("ATR-Normalized Deviation", "NormDev")
        ]:
            chart = Chart(chart_name)
            chart.add_series(Series(series_name, SeriesType.LINE, 0))
            self.add_chart(chart)

        # ------------------------------------------------  Indicators  ------ #
        lookback: int = 21
        self._mean_window: RollingWindow[float] = RollingWindow[float](lookback)
        self._rsi = self.rsi(self._spy, lookback, MovingAverageType.WILDERS, Resolution.HOUR)
        self._atr = self.atr(self._spy, lookback, MovingAverageType.WILDERS, Resolution.HOUR)
        self.warm_up_indicator(self._spy, self._rsi, Resolution.HOUR)
        self.warm_up_indicator(self._spy, self._atr, Resolution.HOUR)

        # ------------------------------------------------  Consolidator  ---- #
        consolidator = TradeBarConsolidator(timedelta(hours=1))
        consolidator.data_consolidated += self.on_hour_bar
        self.subscription_manager.add_consolidator(self._spy, consolidator)

    # ---------------------------------------------------------------------- #
    #  ON DATA                                                               #
    # ---------------------------------------------------------------------- #
    def on_data(self, data: Slice) -> None:
        spy_price: float = self.securities[self._spy].price
        now: datetime = self.time
        did_liq: bool = False

        #  Minimum hold period to avoid “instant” exits (except price-trigger)
        min_hold_period: timedelta = timedelta(minutes=1)
        time_since_entry: timedelta = now - self._entry_time

        # =============================  EXIT – CALL  ======================== #
        if (self._current_call_contract
                and self.portfolio[self._current_call_contract].invested
                and self._entry_spy_price is not None):

            expiry: datetime = self._current_call_contract.id.date
            time_to_expiry: timedelta = expiry - now

            # SPY-price stop / take-profit
            price_trigger_call: bool = (spy_price <= self._entry_spy_price * 0.995
                                        or spy_price >= self._entry_spy_price * 1.007)

            # Time-to-expiry trigger
            expiry_trigger_call: bool = False
            if self.live_mode:
                expiry_trigger_call = time_to_expiry <= timedelta(minutes=5)
            else:
                expiry_trigger_call = now.date() >= expiry.date()

            if time_since_entry < min_hold_period:
                expiry_trigger_call = False      # block “instant” close

            if price_trigger_call or expiry_trigger_call:
                self.liquidate(self._current_call_contract)
                self._reset_position_tracking()
                did_liq = True

        # =============================  EXIT – PUT   ======================== #
        if (not did_liq and self._current_put_contract
                and self.portfolio[self._current_put_contract].invested
                and self._entry_spy_price is not None):

            expiry: datetime = self._current_put_contract.id.date
            time_to_expiry: timedelta = expiry - now

            price_trigger_put: bool = (spy_price >= self._entry_spy_price * 1.007
                                       or spy_price <= self._entry_spy_price * 0.995)

            expiry_trigger_put: bool = False
            if self.live_mode:
                expiry_trigger_put = time_to_expiry <= timedelta(minutes=5)
            else:
                expiry_trigger_put = now.date() >= expiry.date()

            if time_since_entry < min_hold_period:
                expiry_trigger_put = False

            if price_trigger_put or expiry_trigger_put:
                self.liquidate(self._current_put_contract)
                self._reset_position_tracking()
                did_liq = True

        # ------------------------------------------------------------------- #
        #  ENTRY                                                              #
        # ------------------------------------------------------------------- #
        if self.portfolio.invested:
            return

        if not (self.buy_signal_active or self.sell_signal_active):
            return

        if self._spy_options_symbol not in data.option_chains:
            return
        chain = data.option_chains[self._spy_options_symbol]
        if not chain:
            return

        expiries = sorted({c.expiry for c in chain})
        if not expiries:
            return

        # Choose nearest expiry ~7 DTE
        target_dte: int = 7
        nearest_expiry: datetime = min(
            expiries,
            key=lambda e: abs((e.date() - (now.date() + timedelta(days=target_dte))).days)
        )
        same_expiry_chain = [c for c in chain if c.expiry == nearest_expiry]
        if not same_expiry_chain:
            return

        strikes = sorted({c.strike for c in same_expiry_chain})
        if not strikes:
            return

        # ------------------------------ BUY CALL ---------------------------- #
        if self.buy_signal_active:
            otm_call_strikes = [s for s in strikes if s > spy_price]
            if not otm_call_strikes:
                return
            call_strike: float = min(otm_call_strikes)
            call_contracts = [c for c in same_expiry_chain
                              if c.strike == call_strike and c.right == OptionRight.CALL]
            if not call_contracts:
                return
            call_symbol: Symbol = call_contracts[0].symbol

            self._current_ticket = self.market_order(call_symbol, 1)
            self._current_call_contract = call_symbol
            self._current_put_contract = None
            self._entry_spy_price = spy_price
            self._entry_time = now
            self.buy_signal_active = False

        # ------------------------------ BUY PUT  ---------------------------- #
        elif self.sell_signal_active:
            otm_put_strikes = [s for s in strikes if s < spy_price]
            if not otm_put_strikes:
                return
            put_strike: float = max(otm_put_strikes)
            put_contracts = [c for c in same_expiry_chain
                             if c.strike == put_strike and c.right == OptionRight.PUT]
            if not put_contracts:
                return
            put_symbol: Symbol = put_contracts[0].symbol

            self._current_ticket = self.market_order(put_symbol, 1)
            self._current_put_contract = put_symbol
            self._current_call_contract = None
            self._entry_spy_price = spy_price
            self._entry_time = now
            self.sell_signal_active = False

    # ---------------------------------------------------------------------- #
    #  HOURLY CONSOLIDATOR                                                   #
    # ---------------------------------------------------------------------- #
    def on_hour_bar(self, sender: object, bar: TradeBar) -> None:
        weighted_mean: float = (bar.open + 2*bar.high + 2*bar.low + 3*bar.close) / 8.0
        self._mean_window.add(weighted_mean)

        live_price: float = self.securities[self._spy].price
        self.plot("Price Comparison", "Weighted Mean", weighted_mean)
        self.plot("Price Comparison", "Live Price", live_price)

        signal_price: float | None = None
        if self._mean_window.is_ready:
            window_vals = [v for v in self._mean_window]
            mean_val: float = float(np.mean(window_vals))
            std_val: float = float(np.std(window_vals))
            z: float = (live_price - mean_val) / std_val if std_val > 0 else 0.0
            self.plot("Z-Score", "Z", z)

            slope: float = float(np.polyfit(range(len(window_vals)), window_vals, 1)[0]) \
                if len(window_vals) >= 2 else 0.0
            self.plot("Mean Slope", "Slope", slope)

            trigger_buy:  bool = z < -2 and abs(slope) > 0.30
            trigger_sell: bool = z >  2 and abs(slope) > 0.30

            if trigger_sell and not self.sell_signal_active:
                signal_price = live_price
                self.debug(f"SELL Signal | Z:{z:.2f} | Slope:{slope:.2f}")
                self.sell_signal_active, self.buy_signal_active = True, False
            elif trigger_buy and not self.buy_signal_active:
                signal_price = live_price
                self.debug(f"BUY  Signal | Z:{z:.2f} | Slope:{slope:.2f}")
                self.buy_signal_active, self.sell_signal_active = True, False
            else:
                if not (trigger_buy or trigger_sell):
                    self.buy_signal_active = self.sell_signal_active = False

            if signal_price is not None:
                self.plot("Price Comparison", "Signal", signal_price)

        if self._rsi.is_ready:
            self.plot("RSI", "RSI", self._rsi.current.value)
        if self._atr.is_ready and self._atr.current.value != 0:
            norm_dev: float = abs(live_price - weighted_mean) / self._atr.current.value
            self.plot("ATR-Normalized Deviation", "NormDev", norm_dev)

    # ---------------------------------------------------------------------- #
    #  UTILITIES                                                             #
    # ---------------------------------------------------------------------- #
    def _reset_position_tracking(self) -> None:
        """Clear position-related fields after a liquidation."""
        self._current_call_contract = None
        self._current_put_contract = None
        self._current_ticket = None
        self._entry_spy_price = None
        self._entry_time = self.time
        self.buy_signal_active = False
        self.sell_signal_active = False