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 $
- Short spread if:
Exit Trade
- Close position when:
$ |Z_t| < 0.38 $
- Close position when:
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 |
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.")