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
This commit is contained in:
ramseshk
2026-08-05 06:47:33 +00:00
parent 5c41d232c1
commit f4c8bca15a
7 changed files with 1470 additions and 0 deletions
+293
View File
@@ -0,0 +1,293 @@
"""
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