Files
ftdt-quant-lab/strategies/kalman_pairs/tuning.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

123 lines
3.8 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.
"""
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),
}