Introduction¶

This project implements a multi-strategy quantitative investment system that combines Defensive Adaptive Asset Allocation, Gold Overlay and Market-Neutral Pairs Trading. Each sub-strategy operates independently with its own capital allocation and leverage, resulting in a diversified, risk-aware portfolio. The system was backtested on multiple windows using daily data.

Problem¶

Traditional portfolios often struggle with regime fragility. Standard momentum strategies are prone to "whipsaws" during market volatility, while static hedges (like gold) can create a drag on performance during bull markets. Additionally, long-only strategies lack a market-neutral component to generate returns when the broader market is sideways or declining. +4

Reasoning¶

The goal was to create a "robust risk-adjusted" system by blending three uncorrelated return drivers:

  • Strategic Hedging: Using Gold as a base layer for tail-risk protection.
  • Dynamic Momentum: Using an adaptive model that can pivot between "risky" assets and "safe-haven" Treasuries based on market health.
  • Statistical Arbitrage: Incorporating a market-neutral system to capture micro-inefficiencies regardless of market direction.

Description of Strategies¶

1. Defensive Adaptive Asset Allocation (DAA)¶

The DAA module dynamically allocates capital using multi-horizon momentum and a risk-on / risk-off filter.

Momentum Calculation¶

For each asset $ i $, a weighted momentum score is computed using four lookback horizons:

$ \text{Momentum}_i = 12 \cdot R_{1m} + 4 \cdot R_{3m} + 2 \cdot R_{6m} + 1 \cdot R_{12m} $

Where each return is defined as:

$ R_{k} = \frac{P_t}{P_{t-k}} - 1 $

with:

  • $ P_t $ = current price
  • $ k \in \{22, 63, 126, 252\} $ trading days

Only assets with positive momentum are eligible for selection.

Canary Assets (Risk Filter)¶

Two assets act as early-warning indicators of market stress:

  • VWO — Emerging Markets Equity
  • BND — Total Bond Market

Let:

$ C = \frac{\text{Number of Canaries with Negative Momentum}}{\text{Total Canaries}} $

  • If $ C = 1 $: full defensive mode
  • Otherwise: partial risk exposure proportional to $ (1 - C) $

Risky Asset Selection¶

The risky universe consists of:

  • SPY, EFA, EEM
  • VEU, VNQ
  • TLT

From this universe:

  • Rank assets by momentum score
  • Select the top 3 assets
  • Allocate capital equally among selected assets

Risky exposure is scaled as:

$ W_{\text{risky}} = (1 - C) \cdot W_{\text{DAA}} \cdot L_{\text{DAA}} $

Where:

  • $ W_{\text{DAA}} $ = base DAA allocation
  • $ L_{\text{DAA}} $ = DAA leverage factor

Any remaining allocation is assigned to the safe asset.

Safe Asset¶

  • IEF — Intermediate-Term Treasury Bonds

If:

  • Both canaries fail, or
  • No risky assets have positive momentum

Then:

$ W_{\text{IEF}} = W_{\text{DAA}} \cdot L_{\text{DAA}} $


2. Gold Overlay¶

Gold (GLD) is maintained at a constant portfolio weight:

$ W_{\text{Gold}} = 20\% $

Characteristics:

  • Rebalanced daily
  • Independent of DAA and Pairs Trading
  • Acts as a volatility dampener and tail-risk hedge

This ensures gold exposure does not drift due to price fluctuations.


3. Kalman Filter Pairs Trading¶

This module implements dynamic hedge ratio estimation using a Kalman Filter to trade mean-reverting spreads.

Trading Pairs¶

  • EWA / EWC — Australia vs Canada equities
  • CL / BRN — Crude Oil benchmarks

State-Space Model¶

The relationship between two assets $ y_t $ and $ x_t $ is modeled as:

$ y_t = \beta_t x_t + \alpha_t + \varepsilon_t $

Where:

  • $ \beta_t $ = time-varying hedge ratio
  • $ \alpha_t $ = intercept
  • $ \varepsilon_t \sim \mathcal{N}(0, \sigma^2) $

The state vector evolves as a random walk:

$$ \begin{bmatrix} \beta_t \\ \alpha_t \end{bmatrix} = \begin{bmatrix} \beta_{t-1} \\ \alpha_{t-1} \end{bmatrix} + \eta_t \quad , \quad \eta_t \sim \mathcal{N}(0, Q) $$

Spread Definition¶

The instantaneous spread is computed as:

$ \text{Spread}_t = y_t - (\beta_t x_t + \alpha_t) $

A rolling window of the most recent $ N $ spreads is maintained.

Z-Score Calculation¶

$ Z_t = \frac{\text{Spread}_t}{\sigma_{\text{Spread}}} $

Where $ \sigma_{\text{Spread}} $ is the standard deviation of the rolling spread window.


Trading Rules¶

  • Enter Trade

    • Short spread if:
      $ Z_t > 1.13 $
    • Long spread if:
      $ Z_t < -1.13 $
  • Exit Trade

    • Close position when:
      $ |Z_t| < 0.38 $

Position Sizing¶

Total capital allocated to pairs trading:

$ W_{\text{pairs}} = W_{\text{pairs base}} \cdot L_{\text{pairs}} $

Weights for each leg are adjusted dynamically using the hedge ratio $ \beta_t $:

$ w_y = \frac{\beta_t}{1 + 2|\beta_t|} \cdot W_{\text{pairs}} $

$ w_x = \frac{1}{1 + 0.7|\beta_t|} \cdot W_{\text{pairs}} $

This maintains approximate dollar neutrality while adapting to changing asset relationships.


Summary¶

This strategy combines:

  • Trend-following (DAA)
  • Constant real-asset exposure (Gold)
  • Market-neutral mean reversion (Pairs Trading)

The result is a diversified, adaptive portfolio designed to perform across multiple market regimes.

Outcome¶

By applying specific leverage factors ($1.45\times$ for DAA and $1.95\times$ for Pairs), the strategy achieves a balanced risk contribution across different market environments—combining trend-following, mean reversion, and defensive hedging

Strategy Performance Metrics¶

Metric Value Metric Value
PSR 88.727% Sharpe Ratio 2.219
Compounding Annual Return 44.330% Sortino Ratio 2.596
Net Profit 20.032% Drawdown 6.900%
Win Rate 74% Loss Rate 26%
Average Win 0.36% Average Loss -0.39%
Profit–Loss Ratio 0.92 Expectancy 0.429
Alpha 0.206 Beta 0.358
Annual Standard Deviation 0.109 Annual Variance 0.012
Information Ratio 0.921 Treynor Ratio 0.676
Tracking Error 0.155 Portfolio Turnover 9.09%
Total Orders 115 Total Fees $275.09
Start Equity $100,000 End Equity $120,031.77
Estimated Strategy Capacity $0 Lowest Capacity Asset SPY R735QTJ8XC9X
In [ ]:
from AlgorithmImports import *
import numpy as np
from pykalman import KalmanFilter

class CombinedStrategy(QCAlgorithm):

    def Initialize(self):
        # ---------------------------------------
        # Backtest / portfolio settings
        # ---------------------------------------
        # Set the timeline for our backtest simulation
        self.SetStartDate(2005, 1, 1)
        self.SetEndDate(2025, 1, 1)
        
        # Start with a 100000 dollars in the account
        self.SetCash(100000)
        
        # Strategy Parameters
        self.daa_leverage = 1.45    
        self.pairs_leverage = 1.95  
        
        # Portfolio Allocations (Sum = 1.0)
        # How we slice our initial pie before applying leverage
        self.gold_allocation = 0.20    # 20% base for Gold
        self.daa_allocation = 0.55     # 55% base for Momentum (DAA)
        self.pairs_allocation = 0.25    # 25% base for Pairs Trading

        # ---------------------------------------
        # Defensive Adaptive Asset Allocation (DAA)
        # ---------------------------------------
        self.lookback = 252            # One trading year of data
        self.top_n = 3                 # We only want to hold the best 3 performers
        self.canaries = ["VWO", "BND"] # These act as our "warning" assets for market health
        self.risky_assets = ["SPY", "VEU", "VNQ", "TLT", "EFA", "EEM"] # Potential growth assets
        self.safe_asset = "IEF"        # Where we hide when things look scary
        self.gold_ticker = "GLD"       # Our steady gold anchor

        self.symbols = {}
        # Get all our equity symbols ready for the algorithm
        tickers = self.canaries + self.risky_assets + [self.safe_asset, self.gold_ticker]
        for ticker in tickers:
            self.symbols[ticker] = self.AddEquity(ticker, Resolution.Daily).Symbol

        # Scheduled Rebalancing
        # Check our momentum strategy once a month at market open
        self.Schedule.On(self.DateRules.MonthStart(self.symbols["SPY"]),
                         self.TimeRules.AfterMarketOpen(self.symbols["SPY"], 30),
                         self.RebalanceDAA)
        
        # Every single day, make sure our Gold position hasn't drifted too far
        self.Schedule.On(self.DateRules.EveryDay(self.symbols[self.gold_ticker]),
                         self.TimeRules.BeforeMarketClose(self.symbols[self.gold_ticker], 15),
                         self.RebalanceGoldDaily)

        # ---------------------------------------
        # Pairs Trading Setup
        # ---------------------------------------
        # We are looking at two pairs: Australia/Canada stocks and two types of Crude Oil
        pair_configs = [
            {"symbols": ["EWA", "EWC"]},
            {"symbols": ["CL", "BRN"]}
        ]
        
        self.pairs = []
        # Setup for the Kalman Filter
        trans_cov = 1e-5 / (1 - 1e-5) * np.eye(2)
        
        for cfg in pair_configs:
            syms = [self.AddEquity(t, Resolution.Daily).Symbol for t in cfg["symbols"]]
            self.pairs.append({
                "symbols": syms,
                "mean": np.zeros(2),
                "cov": np.ones((2, 2)),
                "buffer": RollingWindow[float](10), # Keep track of the last 10 spreads
                "invested": None,
                "kf": KalmanFilter(
                    n_dim_obs=1, n_dim_state=2,
                    initial_state_mean=np.zeros(2),
                    initial_state_covariance=np.ones((2, 2)),
                    transition_matrices=np.eye(2),
                    observation_covariance=1.0,
                    transition_covariance=trans_cov
                )
            })

        self.pairs_lookback = 500
        self.entry_zscore = 1.13   # Enter the trade when the price gap is unusually wide
        self.exit_zscore = 0.38    # Exit when the prices come back together
        self.SetWarmUp(max(self.lookback, self.pairs_lookback))

    def RebalanceGoldDaily(self):
        """Maintains the fixed gold allocation every day."""
        # This keeps our gold anchor exactly where we want it regardless of price moves
        self.SetHoldings(self.symbols[self.gold_ticker], self.gold_allocation)

    def RebalanceDAA(self):
        """Monthly rebalance for the Defensive Adaptive Asset Allocation (Leveraged)."""
        # Pull the last year of data to see how things are trending
        history = self.History(list(self.symbols.values()), self.lookback + 10, Resolution.Daily)
        if history.empty: return

        def get_mom(ticker):
            # Calculate a weighted momentum score (1, 3, 6, and 12 month performance)
            sym = self.symbols[ticker]
            if sym not in history.index.get_level_values(0): return None
            p = history.loc[sym].close
            if len(p) < self.lookback: return None
            return (12 * (p.iloc[-1]/p.iloc[-22]-1)) + (4 * (p.iloc[-1]/p.iloc[-63]-1)) + \
                   (2 * (p.iloc[-1]/p.iloc[-126]-1)) + (p.iloc[-1]/p.iloc[-252]-1)

        # Check if our "canary" assets are failing. If they are, it's a sign of market stress.
        canary_fails = len([t for t in self.canaries if (get_mom(t) or 0) < 0])
        canary_ratio = canary_fails / len(self.canaries)

        targets = []
        # We multiply our base slice by our leverage factor
        total_daa_weight = self.daa_allocation * self.daa_leverage

        if canary_ratio == 1:
            # If both canaries fail, move the whole DAA slice into the safe asset (IEF)
            targets.append(PortfolioTarget(self.symbols[self.safe_asset], total_daa_weight))
            for t in self.risky_assets: targets.append(PortfolioTarget(self.symbols[t], 0))
        else:
            # Otherwise, pick the top risky assets that have positive momentum
            scores = {t: get_mom(t) for t in self.risky_assets if (get_mom(t) or 0) > 0}
            selected = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:self.top_n]
            
            # The more canaries that fail, the less risky exposure we take
            exposure_pct = (1.0 - canary_ratio)
            risky_exposure = exposure_pct * total_daa_weight
            
            if not selected:
                # If nothing has positive momentum, play it safe
                targets.append(PortfolioTarget(self.symbols[self.safe_asset], total_daa_weight))
            else:
                # Split our risky budget among the winners
                weight_per_asset = risky_exposure / len(selected)
                for t, _ in selected: 
                    targets.append(PortfolioTarget(self.symbols[t], weight_per_asset))
                
                # If we aren't 100% risk-on, put the rest in the safe asset
                safe_weight = total_daa_weight - risky_exposure
                targets.append(PortfolioTarget(self.symbols[self.safe_asset], max(0, safe_weight)))
                
                # Make sure we sell anything that didn't make the cut this month
                selected_tickers = [x[0] for x in selected]
                for t in self.risky_assets:
                    if t not in selected_tickers: targets.append(PortfolioTarget(self.symbols[t], 0))

        # Update our portfolio positions
        self.SetHoldings(targets)

    def OnData(self, data: Slice):
        """Runs on every new piece of market data (daily here)."""
        if self.IsWarmingUp: return

        # Process each of our trading pairs
        for p in self.pairs:
            s1, s2 = p["symbols"]
            if data.Bars.ContainsKey(s1) and data.Bars.ContainsKey(s2):
                y, x = data[s1].Close, data[s2].Close
                
                # Use the Kalman Filter to update our estimate of the relationship (beta/intercept)
                p["mean"], p["cov"] = p["kf"].filter_update(
                    p["mean"], p["cov"], observation=y, observation_matrix=np.array([[x, 1.0]])
                )

                beta, intercept = p["mean"]
                # The spread is the difference between current price and what the model expected
                spread = y - (beta * x + intercept)
                p["buffer"].Add(spread)

                if p["buffer"].IsReady:
                    # Calculate a Z-Score: how many standard deviations is this spread from the average?
                    std = np.std([v for v in p["buffer"]])
                    z = spread / std if std > 0 else 0
                    
                    # Determine how much money to put into each side of the pair
                    total_pairs_slice = self.pairs_allocation * self.pairs_leverage
                    
                    # We weight the stocks based on their relationship (beta)
                    w_y = (beta / (1.0 + 2 * abs(beta))) * total_pairs_slice
                    w_x = (1.0 / (1.0 + 0.7 * abs(beta))) * total_pairs_slice

                    if not p["invested"]:
                        # If the spread is too high, sell the first and buy the second
                        if z > self.entry_zscore:
                            self.SetHoldings(s1, -w_y)
                            self.SetHoldings(s2, w_x)
                            p["invested"] = 'short'
                        # If the spread is too low, buy the first and sell the second
                        elif z < -self.entry_zscore:
                            self.SetHoldings(s1, w_y)
                            self.SetHoldings(s2, -w_x)
                            p["invested"] = 'long'
                    # If we are already in a trade and the spread returns to normal, close out
                    elif abs(z) < self.exit_zscore:
                        self.Liquidate(s1)
                        self.Liquidate(s2)
                        p["invested"] = None

    def OnEndOfAlgorithm(self):
        # Just a quick note to say we finished the run
        self.Log("Backtest Complete.")