Overall Statistics
Total Orders
166
Average Win
0.30%
Average Loss
-0.28%
Compounding Annual Return
0.504%
Drawdown
0.700%
Expectancy
0.082
Start Equity
1000000
End Equity
1011855.72
Net Profit
1.186%
Sharpe Ratio
-7.747
Sortino Ratio
-7.297
Probabilistic Sharpe Ratio
19.162%
Loss Rate
48%
Win Rate
52%
Profit-Loss Ratio
1.06
Alpha
0
Beta
0
Annual Standard Deviation
0.007
Annual Variance
0
Information Ratio
0.527
Tracking Error
0.007
Treynor Ratio
0
Total Fees
$230.05
Estimated Strategy Capacity
$0
Lowest Capacity Asset
SPY R735QTJ8XC9X
Portfolio Turnover
2.90%
Drawdown Recovery
330

Notebook too long to render.

#region imports
from AlgorithmImports import *
#endregion

import numpy as np
from collections import deque


"""
PAIR TRADING STRATEGY WITH SIMPLE CASH BENCHMARK

This strategy trades a relative-value pair between SPY and QQQ.

The model calculates the ratio:

    SPY price / QQQ price

It then computes a rolling z-score of that ratio. The z-score measures how far
the current ratio is from its recent average.

Trading logic:

1. If the z-score is high:
   SPY is expensive relative to QQQ.
   The strategy enters a short spread:
       short SPY
       long QQQ

2. If the z-score is low:
   SPY is cheap relative to QQQ.
   The strategy enters a long spread:
       long SPY
       short QQQ

3. If the z-score mean-reverts toward zero:
   The strategy exits both legs.

This is a pair-trading strategy, not a long-only market strategy. The goal is to
profit from relative movement between SPY and QQQ, not from broad equity market
exposure. Therefore, the most appropriate pedagogical benchmark is cash. A cash
benchmark is simply the initial portfolio value held constant through time.

SPY and QQQ buy-and-hold reference curves are also plotted, but only as market
context. They are not the primary benchmark.
"""


class PairsTradingZScoreAlgorithm(QCAlgorithm):

    def Initialize(self):

        # ------------------------------------------------------------
        # 1. BACKTEST SETTINGS
        # ------------------------------------------------------------
        self.SetStartDate(2024, 1, 1)
        self.SetEndDate(2026, 5, 5)

        self.initial_cash = 1000000
        self.SetCash(self.initial_cash)

        # ------------------------------------------------------------
        # 2. SYMBOLS
        # ------------------------------------------------------------
        self.symbol1 = self.AddEquity("SPY", Resolution.Daily).Symbol
        self.symbol2 = self.AddEquity("QQQ", Resolution.Daily).Symbol

        # For reporting, use cash as the benchmark.
        self.SetBenchmark(lambda time: self.initial_cash)

        # ------------------------------------------------------------
        # 3. PARAMETERS
        # ------------------------------------------------------------
        self.window = 20
        self.entry_threshold = 1.5
        self.exit_threshold = 0.5

        # Gross spread exposure.
        # 0.30 means approximately 15% long one leg and 15% short the other.
        self.position_size = 0.30

        # ------------------------------------------------------------
        # 4. DATA STORAGE
        # ------------------------------------------------------------
        self.prices1 = deque(maxlen=self.window)
        self.prices2 = deque(maxlen=self.window)

        # 0 = no position
        # 1 = long spread: long SPY, short QQQ
        # -1 = short spread: short SPY, long QQQ
        self.current_position = 0

        # ------------------------------------------------------------
        # 5. BENCHMARK REFERENCE PRICES
        # ------------------------------------------------------------
        self.initial_spy_price = None
        self.initial_qqq_price = None

        # ------------------------------------------------------------
        # 6. TRADE CONTROL
        # ------------------------------------------------------------
        self.last_trade_date = None

        # ------------------------------------------------------------
        # 7. LOAD HISTORY
        # ------------------------------------------------------------
        self.LoadHistory()

        # ------------------------------------------------------------
        # 8. SCHEDULE DAILY TRADING DECISION
        # ------------------------------------------------------------
        self.Schedule.On(
            self.DateRules.EveryDay(self.symbol1),
            self.TimeRules.AfterMarketOpen(self.symbol1, 30),
            self.TradeLogic
        )

    def LoadHistory(self):

        history = self.History(
            [self.symbol1, self.symbol2],
            self.window,
            Resolution.Daily
        )

        if history.empty:
            self.Debug("No history data available.")
            return

        try:
            hist1 = history.loc[self.symbol1]["close"]
            hist2 = history.loc[self.symbol2]["close"]

            for p1, p2 in zip(hist1, hist2):
                self.prices1.append(float(p1))
                self.prices2.append(float(p2))

            self.Debug(
                "History loaded: "
                + str(len(self.prices1))
                + " SPY prices and "
                + str(len(self.prices2))
                + " QQQ prices"
            )

        except:
            self.Debug("History loading error: missing symbol data.")

    def TradeLogic(self):

        # ------------------------------------------------------------
        # 1. PREVENT MULTIPLE DECISIONS SAME DAY
        # ------------------------------------------------------------
        if self.last_trade_date == self.Time.date():
            return

        # ------------------------------------------------------------
        # 2. CHECK DATA
        # ------------------------------------------------------------
        if not self.Securities[self.symbol1].HasData:
            return

        if not self.Securities[self.symbol2].HasData:
            return

        price1 = self.Securities[self.symbol1].Price
        price2 = self.Securities[self.symbol2].Price

        if price1 <= 0 or price2 <= 0:
            return

        # ------------------------------------------------------------
        # 3. UPDATE ROLLING WINDOW
        # ------------------------------------------------------------
        self.prices1.append(float(price1))
        self.prices2.append(float(price2))

        if len(self.prices1) < self.window:
            return

        if len(self.prices2) < self.window:
            return

        # ------------------------------------------------------------
        # 4. CALCULATE RATIO AND Z-SCORE
        # ------------------------------------------------------------
        ratio_series = np.array(self.prices1) / np.array(self.prices2)

        ratio_mean = np.mean(ratio_series)
        ratio_std = np.std(ratio_series)

        if ratio_std <= 0:
            return

        current_ratio = price1 / price2
        z_score = (current_ratio - ratio_mean) / ratio_std

        # ------------------------------------------------------------
        # 5. TRADING LOGIC
        # ------------------------------------------------------------
        if self.current_position == 0:

            if z_score > self.entry_threshold:
                self.EnterShortSpread()

            elif z_score < -self.entry_threshold:
                self.EnterLongSpread()

        elif self.current_position == 1:

            # Long spread exits when z-score mean-reverts upward.
            if z_score > -self.exit_threshold:
                self.ExitPosition()

        elif self.current_position == -1:

            # Short spread exits when z-score mean-reverts downward.
            if z_score < self.exit_threshold:
                self.ExitPosition()

        self.last_trade_date = self.Time.date()

        self.Plot("Pair Signal", "Z Score", z_score)
        self.Plot("Pair Signal", "Entry Upper", self.entry_threshold)
        self.Plot("Pair Signal", "Entry Lower", -self.entry_threshold)
        self.Plot("Pair Signal", "Exit Upper", self.exit_threshold)
        self.Plot("Pair Signal", "Exit Lower", -self.exit_threshold)

    def EnterLongSpread(self):

        # Long spread:
        # Buy SPY, short QQQ.
        leg_weight = self.position_size / 2.0

        self.SetHoldings(self.symbol1, leg_weight)
        self.SetHoldings(self.symbol2, -leg_weight)

        self.current_position = 1

        self.Debug(
            "Entered long spread: long SPY, short QQQ on "
            + str(self.Time.date())
        )

    def EnterShortSpread(self):

        # Short spread:
        # Short SPY, buy QQQ.
        leg_weight = self.position_size / 2.0

        self.SetHoldings(self.symbol1, -leg_weight)
        self.SetHoldings(self.symbol2, leg_weight)

        self.current_position = -1

        self.Debug(
            "Entered short spread: short SPY, long QQQ on "
            + str(self.Time.date())
        )

    def ExitPosition(self):

        self.Liquidate(self.symbol1)
        self.Liquidate(self.symbol2)

        self.current_position = 0

        self.Debug(
            "Exited spread position on "
            + str(self.Time.date())
        )

    def OnData(self, data):

        # ------------------------------------------------------------
        # 1. CHECK DATA FOR PLOTS
        # ------------------------------------------------------------
        if self.symbol1 not in data or data[self.symbol1] is None:
            return

        if self.symbol2 not in data or data[self.symbol2] is None:
            return

        spy_price = self.Securities[self.symbol1].Price
        qqq_price = self.Securities[self.symbol2].Price

        if spy_price <= 0 or qqq_price <= 0:
            return

        if self.initial_spy_price is None:
            self.initial_spy_price = spy_price

        if self.initial_qqq_price is None:
            self.initial_qqq_price = qqq_price

        # ------------------------------------------------------------
        # 2. PRIMARY BENCHMARK: CASH
        # ------------------------------------------------------------
        cash_benchmark_value = self.initial_cash

        self.Plot(
            "Strategy Equity",
            "Portfolio Value",
            self.Portfolio.TotalPortfolioValue
        )

        self.Plot(
            "Strategy Equity",
            "Cash Benchmark",
            cash_benchmark_value
        )

        # ------------------------------------------------------------
        # 3. SECONDARY MARKET REFERENCES
        # ------------------------------------------------------------
        spy_buy_hold = (
            self.initial_cash
            * spy_price
            / self.initial_spy_price
        )

        qqq_buy_hold = (
            self.initial_cash
            * qqq_price
            / self.initial_qqq_price
        )

        self.Plot(
            "Market References",
            "Buy Hold SPY",
            spy_buy_hold
        )

        self.Plot(
            "Market References",
            "Buy Hold QQQ",
            qqq_buy_hold
        )

        # ------------------------------------------------------------
        # 4. POSITION STATE
        # ------------------------------------------------------------
        self.Plot(
            "Position State",
            "Current Position",
            self.current_position
        )

        gross_exposure = (
            abs(self.Portfolio[self.symbol1].HoldingsValue)
            + abs(self.Portfolio[self.symbol2].HoldingsValue)
        )

        if self.Portfolio.TotalPortfolioValue > 0:

            gross_exposure_weight = (
                gross_exposure
                / self.Portfolio.TotalPortfolioValue
            )

            self.Plot(
                "Position State",
                "Gross Exposure",
                gross_exposure_weight
            )