""" Kalman Pairs Backtesting Framework. Full walk-forward backtest with: - Realistic execution (transaction costs, capital allocation) - Per-trade P&L tracking - Side-by-side comparison vs rolling OLS (60-day, 120-day windows) - Performance report: CAGR, Sharpe, Sortino, max DD, win rate, turnover - Regime-shift stress tests """ from __future__ import annotations import numpy as np from typing import Optional from .kalman_filter import KalmanPairsTrader from .pair_discovery import compute_rolling_ols_hedge # Import project metrics import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent)) from common.metrics import sharpe, sortino, max_drawdown, win_rate def backtest_kalman_pairs( X: np.ndarray, Y: np.ndarray, trader: KalmanPairsTrader, trade_size_usd: float = 100.0, transaction_cost_bps: float = 2.5, initial_capital: float = 10000.0, ) -> dict: """ Run a walk-forward backtest for a single pair using Kalman filter. Args: X, Y: Price series (must be same length). trader: Pre-configured KalmanPairsTrader (already initialized). trade_size_usd: Notional per leg in USD. transaction_cost_bps: Fee per leg in basis points. initial_capital: Starting capital. Returns: dict with: trades list, equity_curve, metrics, final_equity. """ n = min(len(X), len(Y)) trader.reset() capital = initial_capital peak_capital = initial_capital equity_curve: list[dict] = [] trades: list[dict] = [] open_trade: Optional[dict] = None fee_rate = transaction_cost_bps / 10000.0 # bps → decimal for t in range(n): x_t = float(X[t]) y_t = float(Y[t]) result = trader.step(x_t, y_t) signal = result["signal"] beta = result["beta"] if signal != 0: if open_trade is None: # Open position entry_x = x_t entry_y = y_t size_x = trade_size_usd / entry_x if entry_x > 0 else 0 size_y = trade_size_usd / entry_y if entry_y > 0 else 0 # Hedge: use current beta # If signal = +1: LONG Y (size_y), SHORT X (size_x * beta) # If signal = -1: SHORT Y (size_y), LONG X (size_x * beta) hedge_notional = size_x * entry_x * abs(beta) if beta else 0 fee = (trade_size_usd + hedge_notional) * fee_rate capital -= fee open_trade = { "entry_time": t, "signal": signal, "entry_x": entry_x, "entry_y": entry_y, "beta_at_entry": beta, "size_x": size_x, "size_y": size_y, "fee_paid": fee, } elif open_trade is not None and signal == -open_trade["signal"]: # Close position # PnL: (Y exit - Y entry) * size_y * sign + (X entry - X exit) * size_x * beta * sign exit_sign = open_trade["signal"] pnl_y = (y_t - open_trade["entry_y"]) * open_trade["size_y"] * exit_sign pnl_x = (open_trade["entry_x"] - x_t) * open_trade["size_x"] * abs(open_trade["beta_at_entry"]) * exit_sign gross_pnl = pnl_y + pnl_x exit_notional = abs(y_t * open_trade["size_y"]) + abs(x_t * open_trade["size_x"] * open_trade["beta_at_entry"]) fee = exit_notional * fee_rate net_pnl = gross_pnl - fee capital += net_pnl trades.append({ "entry_time": open_trade["entry_time"], "exit_time": t, "signal": open_trade["signal"], "entry_x": open_trade["entry_x"], "exit_x": x_t, "entry_y": open_trade["entry_y"], "exit_y": y_t, "beta": open_trade["beta_at_entry"], "gross_pnl": round(gross_pnl, 4), "net_pnl": round(net_pnl, 4), "fee": round(open_trade["fee_paid"] + fee, 6), "duration_bars": t - open_trade["entry_time"], }) open_trade = None # Track equity unrealized = 0.0 if open_trade is not None: exit_sign = open_trade["signal"] ur_y = (y_t - open_trade["entry_y"]) * open_trade["size_y"] * exit_sign ur_x = (open_trade["entry_x"] - x_t) * open_trade["size_x"] * abs(open_trade["beta_at_entry"]) * exit_sign unrealized = ur_y + ur_x peak_capital = max(peak_capital, capital + unrealized) equity_curve.append({ "t": t, "equity": round(capital + unrealized, 4), "alpha": round(result["alpha"], 6), "beta": round(result["beta"], 6), "spread": round(result["spread"], 6), "z_score": round(result["z_score"], 4), }) # Force close open trade at end if open_trade is not None: exit_sign = open_trade["signal"] y_t = float(Y[-1]) x_t = float(X[-1]) pnl_y = (y_t - open_trade["entry_y"]) * open_trade["size_y"] * exit_sign pnl_x = (open_trade["entry_x"] - x_t) * open_trade["size_x"] * abs(open_trade["beta_at_entry"]) * exit_sign gross_pnl = pnl_y + pnl_x exit_notional = abs(y_t * open_trade["size_y"]) + abs(x_t * open_trade["size_x"] * open_trade["beta_at_entry"]) fee = exit_notional * fee_rate capital += gross_pnl - fee trades.append({ "entry_time": open_trade["entry_time"], "exit_time": n - 1, "signal": open_trade["signal"], "entry_x": open_trade["entry_x"], "exit_x": x_t, "entry_y": open_trade["entry_y"], "exit_y": y_t, "beta": open_trade["beta_at_entry"], "gross_pnl": round(gross_pnl, 4), "net_pnl": round(gross_pnl - fee, 4), "fee": round(open_trade["fee_paid"] + fee, 6), "duration_bars": n - 1 - open_trade["entry_time"], }) # ── Metrics ── eq = np.array([e["equity"] for e in equity_curve]) returns = np.diff(eq) / eq[:-1] if len(eq) > 1 else np.array([0.0]) total_pnl = capital - initial_capital pnl_pct = total_pnl / initial_capital * 100 dd = max_drawdown(eq.tolist()) sh = sharpe(returns.tolist()) so = sortino(returns.tolist()) wr = win_rate(trades) cagr = ((capital / initial_capital) ** (1 / max(n / (365 * 24), 0.01)) - 1) * 100 if n > 0 and capital > 0 else 0.0 return { "total_pnl": round(total_pnl, 4), "pnl_pct": round(pnl_pct, 2), "cagr": round(cagr, 2), "sharpe": round(sh, 4), "sortino": round(so, 4), "max_drawdown": round(dd, 4), "win_rate": round(wr, 4), "total_trades": len(trades), "final_equity": round(capital, 4), "transaction_costs": round(sum(t["fee"] for t in trades), 4), "avg_trade_duration": round(np.mean([t["duration_bars"] for t in trades]), 1) if trades else 0, "trades": trades[-200:], "equity_curve": equity_curve, "alpha_history": [e["alpha"] for e in equity_curve], "beta_history": [e["beta"] for e in equity_curve], "spread_history": [e["spread"] for e in equity_curve], "z_score_history": [e["z_score"] for e in equity_curve], } def backtest_rolling_ols( X: np.ndarray, Y: np.ndarray, window: int = 60, z_entry: float = 2.0, z_exit: float = 0.5, trade_size_usd: float = 100.0, transaction_cost_bps: float = 2.5, initial_capital: float = 10000.0, ) -> dict: """ Baseline: classic rolling OLS pairs trading. Uses a fixed-lookback rolling beta instead of Kalman adaptation. """ n = len(X) betas = compute_rolling_ols_hedge(X, Y, window) fee_rate = transaction_cost_bps / 10000.0 capital = initial_capital equity_curve: list[dict] = [] trades: list[dict] = [] open_trade: Optional[dict] = None spreads: list[float] = [] z_lookback = 100 for t in range(window, n): x_t = float(X[t]) y_t = float(Y[t]) beta = betas[t] if not np.isnan(betas[t]) else 1.0 spread = y_t - beta * x_t spreads.append(spread) # Z-score lb = min(z_lookback, len(spreads)) rec = spreads[-lb:] mu = np.mean(rec) sigma = np.std(rec, ddof=1) z = (spread - mu) / sigma if sigma > 1e-12 else 0.0 signal = 0 if open_trade is None: if z > z_entry: signal = -1 # short Y, long X elif z < -z_entry: signal = +1 # long Y, short X else: if abs(z) < z_exit: signal = -open_trade["signal"] if signal != 0: if open_trade is None: size_x = trade_size_usd / x_t if x_t > 0 else 0 size_y = trade_size_usd / y_t if y_t > 0 else 0 hedge_notional = size_x * x_t * abs(beta) fee = (trade_size_usd + hedge_notional) * fee_rate capital -= fee open_trade = { "entry_time": t, "signal": signal, "entry_x": x_t, "entry_y": y_t, "beta": beta, "size_x": size_x, "size_y": size_y, "fee_paid": fee, } elif signal == -open_trade["signal"]: es = open_trade["signal"] pnl_y = (y_t - open_trade["entry_y"]) * open_trade["size_y"] * es pnl_x = (open_trade["entry_x"] - x_t) * open_trade["size_x"] * abs(open_trade["beta"]) * es gross_pnl = pnl_y + pnl_x exit_notional = abs(y_t * open_trade["size_y"]) + abs(x_t * open_trade["size_x"] * open_trade["beta"]) fee = exit_notional * fee_rate capital += gross_pnl - fee trades.append({ "entry_time": open_trade["entry_time"], "exit_time": t, "signal": open_trade["signal"], "gross_pnl": round(gross_pnl, 4), "net_pnl": round(gross_pnl - fee, 4), "duration_bars": t - open_trade["entry_time"], }) open_trade = None equity_curve.append({"t": t, "equity": round(capital, 4)}) eq = np.array([e["equity"] for e in equity_curve]) returns = np.diff(eq) / eq[:-1] if len(eq) > 1 else np.zeros(1) total_pnl = capital - initial_capital dd = max_drawdown(eq.tolist()) return { "total_pnl": round(total_pnl, 4), "pnl_pct": round(total_pnl / initial_capital * 100, 2), "sharpe": round(sharpe(returns.tolist()), 4), "sortino": round(sortino(returns.tolist()), 4), "max_drawdown": round(dd, 4), "win_rate": round(win_rate(trades), 4), "total_trades": len(trades), "final_equity": round(capital, 4), "trades": trades[-200:], "equity_curve": equity_curve, } def run_comparison( X: np.ndarray, Y: np.ndarray, transition_covariance: float = 1e-4, observation_covariance: float = 1e-2, z_entry: float = 2.0, z_exit: float = 0.5, trade_size_usd: float = 100.0, transaction_cost_bps: float = 2.5, ols_windows: list[int] = [60, 120], ) -> dict: """ Run Kalman vs rolling OLS comparison backtest. Returns: dict with kalman_results, ols_results, and comparison_summary. """ trader = KalmanPairsTrader( transition_covariance=transition_covariance, observation_covariance=observation_covariance, z_entry=z_entry, z_exit=z_exit, ) kalman = backtest_kalman_pairs( X, Y, trader, trade_size_usd=trade_size_usd, transaction_cost_bps=transaction_cost_bps, ) ols_results = {} for w in ols_windows: ols_results[f"ols_{w}d"] = backtest_rolling_ols( X, Y, window=w, z_entry=z_entry, z_exit=z_exit, trade_size_usd=trade_size_usd, transaction_cost_bps=transaction_cost_bps, ) return { "kalman": kalman, "ols": ols_results, }