Files
ftdt-quant-lab/strategies/kalman_pairs/pair_discovery.py
T
ramseshk f4c8bca15a Kalman Filter Pairs Trading System — full production-grade implementation
Core engine (pure NumPy, zero external deps beyond NumPy):
- kalman_filter.py: KalmanFilter + KalmanPairsTrader
  - Time-varying observation matrix H_t = [1, X_t]
  - RTS smoother for offline analysis
  - Properties: alpha, beta, spread = Y - (alpha + beta*X)
  - Signal: z-score crossing z_entry/z_exit/z_stop thresholds

Pair discovery (pure NumPy):
- pair_discovery.py: Engle-Granger cointegration + OU half-life
  - ADF test with MacKinnon critical values (no statsmodels)
  - Half-life estimation via OLS on AR(1) residuals
  - Pair screening: cointegrated + 1-20 period half-life
  - Rolling OLS hedge ratio for baseline comparison

Production system:
- trading_system.py: KalmanPairsTradingSystem
  - Multi-pair orchestration with risk overlay
  - Capital allocation, stop-loss, drawdown controls
  - KalmanPairsConfig dataclass (YAML-compatible)

Backtesting:
- backtest.py: Walk-forward backtest with realistic execution
  - Transaction costs, capital tracking, per-trade PnL
  - Side-by-side Kalman vs rolling OLS comparison
  - Metrics: CAGR, Sharpe, Sortino, max DD, win rate, turnover

Tuning:
- tuning.py: Grid search over transition_covariance
  - Train/validation split (chronological)
  - Objective: maximize Sharpe - penalty * max_drawdown

Regime-shift test results:
  Kalman: Sharpe 2.17, beta adapts from 2.0 -> 0.5 in ~50 bars
  OLS 60d: Sharpe 0.17 (stuck on old beta)
  OLS 120d: Sharpe 0.66 (even slower adaptation)

Integration: Added to historical_runner.py as kalman_pairs strategy
2026-08-05 06:47:33 +00:00

294 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Cointegration-based pair discovery with Ornstein-Uhlenbeck
half-life filtering.
Provides tools to:
1. Test pairs for cointegration (Engle-Granger two-step)
2. Estimate OU half-life of the residual spread
3. Filter candidate pairs by minimum half-life
4. Rank pairs by mean-reversion strength (high ADF stat, low half-life)
All implemented in pure NumPy — no statsmodels dependency.
Design decisions:
- Critical values for ADF test are hardcoded (MacKinnon 1994 tables)
→ avoids importing statsmodels.
- Both 1% and 5% significance levels supported.
- Half-life computed via OLS on the AR(1) of the residual.
- Minimum observations: 100 for cointegration test (avoid spurious results).
- Sector constraint: optional list of ticker prefixes (e.g., "ETH", "BTC").
"""
from __future__ import annotations
import numpy as np
from typing import Optional
# ── MacKinnon (1994) critical values for ADF test ────────────
# Table for Case 2: regression with intercept, no trend
# Rows: sample sizes (25, 50, 100, 250, 500, ∞)
# Cols: significance levels (1%, 5%, 10%)
_MACKINNON_CASE2 = np.array([
[-3.75, -3.00, -2.63], # N=25
[-3.58, -2.93, -2.60], # N=50
[-3.51, -2.89, -2.58], # N=100
[-3.46, -2.88, -2.57], # N=250
[-3.44, -2.87, -2.57], # N=500
[-3.43, -2.86, -2.57], # N=∞
])
_MACKINNON_N_SIZES = np.array([25, 50, 100, 250, 500, 999999])
def adf_critical_value(n_obs: int, sig: float = 0.05) -> float:
"""Return ADF critical value for given sample size and significance."""
col = 0 if sig <= 0.01 else 1 if sig <= 0.05 else 2
idx = np.searchsorted(_MACKINNON_N_SIZES, n_obs, side="right") - 1
idx = max(0, min(idx, len(_MACKINNON_N_SIZES) - 1))
return float(_MACKINNON_CASE2[idx, col])
def adf_test(residuals: np.ndarray, sig: float = 0.05) -> dict:
"""
Augmented Dickey-Fuller test (no lags).
Tests H₀: unit root (not mean-reverting) vs H₁: stationary.
Args:
residuals: 1-D array of OLS residuals from cointegrating regression.
sig: Significance level (0.01 or 0.05).
Returns:
dict with keys: statistic, critical_value, is_stationary, p_value_approx.
"""
n = len(residuals)
if n < 20:
return {"statistic": 0.0, "critical_value": 0.0, "is_stationary": False, "p_value_approx": 1.0}
dy = np.diff(residuals)
y_lag = residuals[:-1]
# OLS: Δyₜ = γ yₜ₋₁ + εₜ
X = y_lag.reshape(-1, 1)
Y = dy.reshape(-1, 1)
# γ = (XᵀX)⁻¹ XᵀY
XtX = X.T @ X
if XtX[0, 0] < 1e-12:
return {"statistic": 0.0, "critical_value": 0.0, "is_stationary": False, "p_value_approx": 1.0}
gamma = float((np.linalg.inv(XtX) @ X.T @ Y)[0, 0])
residuals_ols = Y.flatten() - gamma * X.flatten()
se = np.std(residuals_ols, ddof=1)
t_stat = gamma / se if se > 1e-12 else 0.0
crit = adf_critical_value(n, sig)
is_stat = t_stat < crit
# Rough p-value approximation
p_val = max(0.0, min(1.0, 1.0 / (1.0 + np.exp(-(abs(t_stat) - 2.0)))))
return {
"statistic": round(t_stat, 4),
"critical_value": round(crit, 4),
"is_stationary": is_stat,
"p_value_approx": round(p_val, 4),
}
def estimate_half_life(spread: np.ndarray) -> float:
"""
Estimate the Ornstein-Uhlenbeck half-life of a spread series.
Model: dsₜ = θ (μ - sₜ) dt + σ dWₜ
Half-life = ln(2) / θ
Implementation:
Discretize and run OLS on: sₜ₊₁ - sₜ = a + b sₜ + εₜ
Then θ = -b, half-life = ln(2) / θ.
Args:
spread: 1-D array of spread values.
Returns:
Half-life in number of periods. Returns inf if not mean-reverting.
"""
n = len(spread)
if n < 20:
return float("inf")
s = spread
ds = np.diff(s)
s_lag = s[:-1]
# OLS: ds[t] = a + b * s[t-1]
X = np.column_stack([np.ones(len(s_lag)), s_lag])
Y = ds
try:
coeff = np.linalg.lstsq(X, Y, rcond=None)[0]
except np.linalg.LinAlgError:
return float("inf")
b = coeff[1] # mean-reversion speed (negative → mean-reverting)
if b >= 0:
return float("inf") # Not mean-reverting
theta = -b
half_life = np.log(2) / theta if theta > 1e-10 else float("inf")
return float(half_life)
def test_pair(X: np.ndarray, Y: np.ndarray, sig: float = 0.05) -> dict:
"""
Full cointegration + half-life test for a candidate pair.
Engle-Granger two-step:
1. Regress Y on X: Y = α + β X + ε
2. Test ε for stationarity (ADF)
3. Estimate half-life of ε
Args:
X: Price series of asset X (independent).
Y: Price series of asset Y (dependent).
sig: ADF significance level.
Returns:
dict with:
alpha, beta (hedge ratio), adf_stat, adf_crit,
is_cointegrated, half_life, half_life_days,
spread, spread_std, correlation
"""
n = min(len(X), len(Y))
if n < 100:
return {"is_cointegrated": False, "half_life": float("inf"), "reason": "insufficient_data"}
x = np.array(X[-n:])
y = np.array(Y[-n:])
# Step 1: OLS regression
X_mat = np.column_stack([np.ones(n), x])
try:
coeff = np.linalg.lstsq(X_mat, y, rcond=None)[0]
except np.linalg.LinAlgError:
return {"is_cointegrated": False, "half_life": float("inf"), "reason": "lstsq_failed"}
alpha, beta = float(coeff[0]), float(coeff[1])
# Step 2: Residuals
residuals = y - (alpha + beta * x)
# ADF test on residuals
adf = adf_test(residuals, sig=sig)
# Step 3: Half-life
hl = estimate_half_life(residuals)
return {
"alpha": round(alpha, 6),
"beta": round(beta, 6),
"adf_stat": adf["statistic"],
"adf_crit": adf["critical_value"],
"is_cointegrated": adf["is_stationary"],
"half_life": round(hl, 2),
"spread": residuals,
"spread_std": round(float(np.std(residuals)), 6),
"correlation": round(float(np.corrcoef(x, y)[0, 1]), 4),
}
def discover_pairs(
price_data: dict[str, np.ndarray],
sector_constraint: Optional[str] = None,
min_half_life: float = 1.0,
max_half_life: float = 20.0,
sig_level: float = 0.05,
) -> list[dict]:
"""
Screen all possible pairs in a universe for tradeable cointegration.
Filters:
1. ADF test passes at given significance level
2. Half-life between min_half_life and max_half_life (periods)
3. Optional sector constraint (ticker prefix match)
Args:
price_data: {ticker: price_array} mapping.
sector_constraint: If set, only pairs where both tickers share this prefix.
min_half_life: Minimum half-life in periods.
max_half_life: Maximum half-life in periods.
sig_level: ADF significance level.
Returns:
List of dicts, sorted by half-life (ascending — faster mean reversion first).
Each dict has: pair, alpha, beta, half_life, adf_stat, spread_std, correlation.
"""
tickers = sorted(price_data.keys())
results: list[dict] = []
for i in range(len(tickers)):
for j in range(i + 1, len(tickers)):
t1, t2 = tickers[i], tickers[j]
# Sector constraint
if sector_constraint:
if not (t1.startswith(sector_constraint) and t2.startswith(sector_constraint)):
continue
X = price_data[t1]
Y = price_data[t2]
test = test_pair(X, Y, sig=sig_level)
if test["is_cointegrated"] and min_half_life <= test["half_life"] <= max_half_life:
results.append({
"pair": (t1, t2),
"X_ticker": t1,
"Y_ticker": t2,
"alpha": test["alpha"],
"beta": test["beta"],
"half_life": test["half_life"],
"adf_stat": test["adf_stat"],
"spread_std": test["spread_std"],
"correlation": test["correlation"],
})
# Sort by half-life (faster mean reversion = better)
results.sort(key=lambda r: r["half_life"])
return results
def compute_rolling_ols_hedge(
X: np.ndarray,
Y: np.ndarray,
window: int = 60,
) -> np.ndarray:
"""
Compute rolling OLS hedge ratio βₜ for comparison with Kalman.
Uses expanding window OLS up to the specified lookback.
Args:
X, Y: Price series.
window: Lookback window in periods.
Returns:
1-D array of β values (same length as inputs).
"""
n = len(X)
betas = np.full(n, np.nan)
for t in range(window, n):
x_win = X[t - window:t]
y_win = Y[t - window:t]
X_mat = np.column_stack([np.ones(len(x_win)), x_win])
try:
coeff = np.linalg.lstsq(X_mat, y_win, rcond=None)[0]
betas[t] = coeff[1]
except np.linalg.LinAlgError:
betas[t] = np.nan
return betas