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:
@@ -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),
|
||||
}
|
||||
Reference in New Issue
Block a user