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
+122
View File
@@ -0,0 +1,122 @@
"""
Parameter tuning for Kalman Pairs Trader.
Grid search over transition_covariance (and optionally observation_covariance)
to find optimal settings that maximize out-of-sample Sharpe while controlling turnover.
Design:
- Train/validation split (chronological, no look-ahead)
- Grid search over log-spaced transition_covariance values
- Objective: maximize Sharpe_validation - λ * max_drawdown_penalty
- Reports top-N parameter sets with full metrics
"""
from __future__ import annotations
import numpy as np
from typing import Optional
from .kalman_filter import KalmanPairsTrader
from .backtest import backtest_kalman_pairs
def grid_search_transition_cov(
X_train: np.ndarray,
Y_train: np.ndarray,
X_val: np.ndarray,
Y_val: np.ndarray,
transition_cov_range: tuple[float, float, int] = (1e-6, 1e-1, 20),
observation_covariance: float = 1e-2,
z_entry: float = 2.0,
z_exit: float = 0.5,
max_drawdown_penalty: float = 0.5,
trade_size_usd: float = 100.0,
transaction_cost_bps: float = 2.5,
) -> list[dict]:
"""
Grid search optimal transition_covariance.
Strategy:
1. Split data chronologically (train → validation).
2. For each Q value, run Kalman backtest on validation set
(with no pre-training — Kalman adapts online).
3. Score = Sharpe λ * max_drawdown.
4. Return sorted results.
Args:
X_train, Y_train: Training price series (used for initialization only).
X_val, Y_val: Validation price series (out-of-sample test).
transition_cov_range: (min, max, num_steps) in log space.
max_drawdown_penalty: Weight for drawdown penalty in scoring.
Returns:
List of dicts sorted by score (descending), each with:
transition_cov, sharpe, sortino, max_drawdown, win_rate, total_trades, score.
"""
q_min, q_max, n_steps = transition_cov_range
q_values = np.logspace(np.log10(q_min), np.log10(q_max), n_steps)
results = []
for q in q_values:
trader = KalmanPairsTrader(
transition_covariance=float(q),
observation_covariance=observation_covariance,
z_entry=z_entry,
z_exit=z_exit,
)
# Pre-warm on training data (online filtering, no position taking)
for x, y in zip(X_train, Y_train):
trader.kf.update(float(x), float(y))
# Backtest on validation
bt = backtest_kalman_pairs(
X_val, Y_val, trader,
trade_size_usd=trade_size_usd,
transaction_cost_bps=transaction_cost_bps,
)
score = bt["sharpe"] - max_drawdown_penalty * bt["max_drawdown"]
results.append({
"transition_cov": float(q),
"sharpe": bt["sharpe"],
"sortino": bt["sortino"],
"max_drawdown": bt["max_drawdown"],
"win_rate": bt["win_rate"],
"total_trades": bt["total_trades"],
"pnl_pct": bt["pnl_pct"],
"score": round(score, 4),
})
results.sort(key=lambda r: r["score"], reverse=True)
return results
def find_optimal_params(
X: np.ndarray,
Y: np.ndarray,
train_frac: float = 0.6,
**grid_kwargs,
) -> dict:
"""
One-shot: split data, run grid search, return best params.
Returns:
dict with: best_params, all_results, train_size, val_size.
"""
n = len(X)
split = int(n * train_frac)
X_train, X_val = X[:split], X[split:]
Y_train, Y_val = Y[:split], Y[split:]
grid = grid_search_transition_cov(X_train, Y_train, X_val, Y_val, **grid_kwargs)
return {
"best_params": {
"transition_covariance": grid[0]["transition_cov"] if grid else 1e-4,
},
"best_score": grid[0]["score"] if grid else 0.0,
"all_results": grid,
"train_size": len(X_train),
"val_size": len(X_val),
}