Overall Statistics
Total Orders
280
Average Win
0.51%
Average Loss
-0.47%
Compounding Annual Return
2.926%
Drawdown
7.800%
Expectancy
0.230
Start Equity
100000
End Equity
115515.77
Net Profit
15.516%
Sharpe Ratio
-0.494
Sortino Ratio
-0.326
Probabilistic Sharpe Ratio
7.708%
Loss Rate
41%
Win Rate
59%
Profit-Loss Ratio
1.09
Alpha
-0.022
Beta
0.016
Annual Standard Deviation
0.042
Annual Variance
0.002
Information Ratio
-0.541
Tracking Error
0.146
Treynor Ratio
-1.323
Total Fees
$353.86
Estimated Strategy Capacity
$400000000.00
Lowest Capacity Asset
MRK R735QTJ8XC9X
Portfolio Turnover
1.82%
Drawdown Recovery
217
# region imports
from AlgorithmImports import *
from stat_arb import PairStatArb
from pair import Pair
# endregion


class SpectralStatArb(QCAlgorithm):

    def initialize(self):
        self.set_start_date(self.end_date - timedelta(5 * 365))
        self.set_cash(100_000)
        self.settings.automatic_indicator_warm_up = True
        self.settings.seed_initial_prices = True
        self.universe_settings.resolution = Resolution.DAILY
        self.universe_settings.schedule.on(self.date_rules.month_start("SPY"))
        self._universe = self.add_universe(lambda fundamental: 
            [f.symbol for f in sorted([f for f in fundamental if f.has_fundamental_data and f.market_cap > 5e9],
            key=lambda f: f.dollar_volume)[-100:]]
        )
        self.laplacian_lookback = 252
        self.arb_lookback = 30
        self.entry_z_score = 2.0
        self.exit_z_score = 0.5
        self.max_pairs = 5
        self._pairs = []
        self.set_warm_up(timedelta(45))

    def on_warmup_finished(self):
        # Configure training and prediction schedule.
        trading_time_rule = self.time_rules.at(8, 0)
        training_time_rule = self.time_rules.at(6, 0)
        self.train(self.date_rules.month_end(), training_time_rule, self._train)
        self.schedule.on(self.date_rules.every_day('SPY'), trading_time_rule, self._rebalance)
        # Rebalance today too.
        if self.live_mode:
            self._train()
            self._rebalance()
        else:
            self.train(self.date_rules.today, training_time_rule, self._train)
            self.schedule.on(self.date_rules.today, trading_time_rule, self._rebalance)

    def on_securities_changed(self, changes):
        for security in changes.removed_securities:
            # Drop any active pair that uses a leg leaving the universe.
            for pair in [p for p in self._pairs if security.symbol in p.symbols]:
                self._drop_pair(pair)

    def _train(self):
        # Train pair selection and warm each selected spread indicator.
        model = PairStatArb(self.laplacian_lookback, self.arb_lookback, self.max_pairs)
        spreads = model.train(self, self._universe)
        for pair in list(self._pairs):
            self._drop_pair(pair)
        if not spreads:
            self.log("No cointegrated pairs found in the cluster this month.")
            return
        # Split capital evenly across the pairs selected this month.
        allocation = 1 / len(spreads)
        for spread in spreads:
            pair = Pair(self, spread, allocation)
            self._pairs.append(pair)
            self.register_indicator(spread.symbol_a, spread)
            self.register_indicator(spread.symbol_b, spread)
            self.log(
                f"New Cointegrated Pair: {spread.symbol_a.value} & {spread.symbol_b.value} "
                f"(beta={spread.hedge_ratio:.2f}, alloc={allocation:.2%})"
            )

    def _rebalance(self):
        for pair in self._pairs:
            spread = pair.spread
            if not spread.is_ready:
                continue
            half_life = float(spread.half_life)
            if not np.isfinite(half_life) or half_life > 30:
                continue
            dynamic_entry_z = self.entry_z_score if half_life < 10 else self.entry_z_score + 0.5
            z_score = float(spread.z_score)
            if not np.isfinite(z_score):
                continue
            if not pair.is_invested and abs(z_score) > dynamic_entry_z:
                # Short the spread when z-score is high, and long it when z-score is low.
                pair.enter(np.sign(z_score))
                self.log(
                    f"ENTRY: {pair.symbol_a.value}/{pair.symbol_b.value} | HL: {half_life:.1f}d | Z: {z_score:.2f}"
                )
            elif pair.is_invested and abs(z_score) < self.exit_z_score:
                # Take profit once the spread reverts toward the mean.
                pair.exit()
                self.log("EXIT: Pair converged")

    def _drop_pair(self, pair):
        # Close the pair's positions and release its spread indicator.
        pair.exit()
        self.deregister_indicator(pair.spread)
        self._pairs.remove(pair)
# region imports
from AlgorithmImports import *
from pair_spread import PairSpread
# endregion


class Pair:

    def __init__(self, algorithm: QCAlgorithm, spread: PairSpread, allocation: float):
        self._algorithm = algorithm
        self.spread = spread
        self.symbols = spread.symbols
        self.symbol_a, self.symbol_b = self.symbols
        self._a = algorithm.securities[self.symbol_a]
        self._b = algorithm.securities[self.symbol_b]
        self._allocation = allocation
        self._ticket_by_security = {}

    @property
    def is_invested(self) -> bool:
        return bool(self._ticket_by_security)

    def enter(self, direction: float):
        if self._ticket_by_security:
            return
        gross = 1 + abs(self.spread.hedge_ratio)
        if not np.isfinite(gross) or gross <= 0:
            return
        order_size = self._allocation * self._algorithm.portfolio.total_portfolio_value
        dollar_a = -direction * order_size / gross
        dollar_b = direction * self.spread.hedge_ratio * order_size / gross
        quantity_a = int(dollar_a / self._a.price)
        quantity_b = int(dollar_b / self._b.price)
        if quantity_a and quantity_b:
            self._ticket_by_security = {
                self._a: self._algorithm.market_order(self._a, quantity_a),
                self._b: self._algorithm.market_order(self._b, quantity_b),
            }

    def exit(self):
        for security, ticket in self._ticket_by_security.items():
            self._algorithm.market_order(security, -ticket.quantity)
        self._ticket_by_security.clear()
# region imports
from AlgorithmImports import *
# endregion


class PairSpread(PythonIndicator):

    def __init__(self, symbol_a, symbol_b, hedge_ratio: float, period: int, name: str = "PairSpread"):
        super().__init__()
        self.name = name
        self.symbol_a = symbol_a
        self.symbol_b = symbol_b
        self.symbols = (symbol_a, symbol_b)
        self.hedge_ratio = float(hedge_ratio)
        self.value = 0.0
        self.spread = 0.0
        self.z_score = 0.0
        self.half_life = np.inf
        # Store spread values.
        self._window = RollingWindow[float](period)
        # Cache the latest price and timestamp for each leg.
        self._prices = {}
        self._last_time = None

    def update(self, input_: BaseData):
        price = float(input_.value)
        if input_.symbol not in self.symbols or price <= 0:
            return self.is_ready
        self._prices[input_.symbol] = (input_.end_time, price)
        if self.symbol_a not in self._prices or self.symbol_b not in self._prices:
            return self.is_ready
        time_a, price_a = self._prices[self.symbol_a]
        time_b, price_b = self._prices[self.symbol_b]
        if time_a != time_b or time_a == self._last_time:
            return self.is_ready
        self._last_time = time_a
        # Calculate the log-price spread using the selected hedge ratio.
        self.value = float(np.log(price_a) - self.hedge_ratio * np.log(price_b))
        self.spread = self.value
        # Reset signal values until the spread window can recompute them.
        self.z_score = 0.0
        self.half_life = np.inf
        self._window.add(self.value)
        # Recompute z-score and half-life from the complete trailing spread window.
        if self._window.is_ready:
            spreads = np.array(list(self._window)[::-1], dtype=float)
            sd = np.std(spreads)
            if sd > 0 and np.isfinite(sd):
                self.z_score = float((self.value - np.mean(spreads)) / sd)
            # Estimate mean-reversion speed with a lagged spread regression.
            lagged = spreads[:-1]
            if len(lagged) > 1 and not np.all(lagged == lagged[0]):
                slope = np.polyfit(lagged, spreads[1:] - lagged, 1)[0]
                if np.isfinite(slope) and slope < 0:
                    self.half_life = float(-np.log(2) / slope)
        return self.is_ready

    @property
    def is_ready(self) -> bool:
        return self._window.is_ready
# region imports
from AlgorithmImports import *
from pair_spread import PairSpread
from statsmodels.tsa.stattools import adfuller
# endregion


class PairStatArb:

    # Initialize the strategy parameters for graph clustering and statistical arbitrage lookbacks.
    def __init__(self, laplacian_lookback: int, arb_lookback: int, max_pairs: int):
        self._laplacian_lookback = laplacian_lookback
        self._arb_lookback = arb_lookback
        self._max_pairs = max_pairs

    def train(self, algorithm, universe):
        symbols = sorted(universe.selected, key=lambda s: s.value)
        # Ensure there are at least two valid assets available to form pairs.
        if len(symbols) < 2:
            return []
        # Fetch historical close prices for the whole universe in a single request.
        history = algorithm.history(symbols, self._laplacian_lookback + 1, Resolution.DAILY)
        if history.empty:
            return []
        log_prices = np.log(history["close"].unstack(level=0))
        # diff()'s first row is NaN for every symbol; drop it before dropping
        # columns, or dropna(axis=1) would remove every symbol.
        df_returns = log_prices.diff().iloc[1:].dropna(axis=1)
        if df_returns.shape[1] < 2:
            return []
        valid_symbols = list(df_returns.columns)
        log_prices = log_prices[valid_symbols]
        # Build the weighted correlation graph and its Laplacian.
        weights = np.abs(df_returns.corr().fillna(0).values)
        np.fill_diagonal(weights, 0)
        laplacian = np.diag(np.sum(weights, axis=1)) - weights
        # Compute the eigenvalues and eigenvectors to find the Fiedler vector for spectral clustering.
        _, eigenvectors = np.linalg.eigh(laplacian)
        fiedler_vector = eigenvectors[:, 1]
        # Standardize the Fiedler vector orientation based on its maximum absolute value.
        if fiedler_vector[np.argmax(np.abs(fiedler_vector))] < 0:
            fiedler_vector = -fiedler_vector
        # Select assets belonging to the positive partition of the Fiedler vector.
        cluster_pos = [valid_symbols[i] for i in range(len(valid_symbols)) if fiedler_vector[i] > 0]
        if len(cluster_pos) < 2:
            return []
        cluster_log_prices = log_prices[cluster_pos]
        # Iterate over all asset combinations within the cluster to locate cointegrated candidates.
        candidates = []
        for i in range(len(cluster_pos)):
            for j in range(i + 1, len(cluster_pos)):
                candidate = self._try_create_pair(algorithm, cluster_log_prices, cluster_pos[i], cluster_pos[j])
                if candidate:
                    candidates.append(candidate)
        # Keep the pairs with the strongest cointegration evidence, ranked by ADF p-value.
        candidates.sort(key=lambda candidate: candidate[0])
        return [pair_spread for _, pair_spread in candidates[:self._max_pairs]]

    def _try_create_pair(self, algorithm, log_prices, asset_a, asset_b):
        aligned = log_prices[[asset_a, asset_b]].dropna()
        if len(aligned) < self._arb_lookback:
            return None
        y = aligned[asset_a]
        x = aligned[asset_b]
        if np.all(x == x.iloc[0]):
            return None
        # Determine the hedge ratio using an ordinary least squares linear regression.
        hedge_ratio = float(np.polyfit(x, y, 1)[0])
        # Construct the spread series using the computed hedge ratio.
        spread = y - hedge_ratio * x
        if np.all(spread == spread.iloc[0]):
            return None
        # Verify the stationarity of the spread using the Augmented Dickey-Fuller test.
        p_value = adfuller(spread)[1]
        if p_value >= 0.05:
            return None
        pair_spread = PairSpread(asset_a, asset_b, hedge_ratio, self._arb_lookback)
        # Fetch historical bar data to warm up the indicator windows of the pair.
        pair_history = algorithm.history[TradeBar]([asset_a, asset_b], self._arb_lookback + 5, Resolution.DAILY)
        for bars in pair_history:
            if asset_a in bars and asset_b in bars:
                pair_spread.update(bars[asset_a])
                pair_spread.update(bars[asset_b])
        return p_value, pair_spread