f4c8bca15a
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
123 lines
3.8 KiB
Python
123 lines
3.8 KiB
Python
"""
|
||
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),
|
||
}
|