""" Kalman Pairs Trading System — Production Orchestrator. Integrates pair discovery, Kalman filtering, signal generation, position management, and risk controls into a single callable system. Design: - Stateless between ticks — all state held in KalmanPairsTrader instances. - Multi-pair: manages N independent pairs simultaneously. - Risk overlay: per-pair stop-loss, max position, max drawdown. - Capital allocation: equal-weight or volatility-weighted. - Clean interface compatible with live node + backtester. Usage (live): system = KalmanPairsTradingSystem(config) system.initialize(price_data) for tick in price_stream: signals = system.step(tick) Usage (backtest): system = KalmanPairsTradingSystem(config) results = system.run_backtest(price_data) """ from __future__ import annotations import numpy as np from dataclasses import dataclass, field from typing import Optional from pathlib import Path import json import time # Internal imports from .kalman_filter import KalmanPairsTrader from .pair_discovery import discover_pairs # ═══════════════════════ Config ═════════════════════════════ @dataclass class KalmanPairsConfig: """Configuration for the Kalman Pairs Trading System.""" # ── Universe ── tickers: list[str] = field(default_factory=lambda: ["BTC", "ETH"]) sector_constraint: Optional[str] = None # e.g., None = any, "BTC" = BTC-only pairs # ── Pair Discovery ── min_half_life: float = 1.0 max_half_life: float = 20.0 max_pairs: int = 5 coint_sig_level: float = 0.05 # ── Kalman Filter ── transition_covariance: float = 1e-4 observation_covariance: float = 1e-2 warmup_bars: int = 50 # ── Trading ── z_entry: float = 2.0 z_exit: float = 0.5 z_stop: float = 4.0 trade_size_usd: float = 100.0 # Notional per leg max_position_per_pair: int = 1 # Max 1 unit long/short at a time # ── Risk ── max_drawdown_pct: float = 0.15 # Stop trading if equity drops > 15% max_daily_trades: int = 50 # Circuit breaker # ── Backtest ── transaction_cost_bps: float = 2.5 # 2.5 bps = 0.025% per leg (taker) initial_capital: float = 10000.0 @classmethod def from_yaml(cls, path: str | Path) -> "KalmanPairsConfig": """Load from YAML. Falls back to defaults if YAML not available.""" import yaml # may not be installed with open(path) as f: data = yaml.safe_load(f) return cls(**data.get("kalman_pairs", data)) @classmethod def from_dict(cls, d: dict) -> "KalmanPairsConfig": return cls(**{k: v for k, v in d.items() if k in cls.__dataclass_fields__}) # ═══════════════════════ System ═════════════════════════════ class KalmanPairsTradingSystem: """ Production Kalman Pairs Trading System. Manages multiple independent pairs, each with its own Kalman filter, and aggregates signals through a unified risk layer. """ def __init__(self, config: KalmanPairsConfig | dict) -> None: if isinstance(config, dict): config = KalmanPairsConfig.from_dict(config) self.config = config # Active pair traders self.traders: dict[tuple[str, str], KalmanPairsTrader] = {} self.pair_info: dict[tuple[str, str], dict] = {} # Equity tracking self.capital = config.initial_capital self.peak_capital = config.initial_capital self.equity_curve: list[dict] = [] self.daily_trades: int = 0 self.daily_reset_time: float = time.time() # Trade log self.trades: list[dict] = [] def initialize(self, price_data: dict[str, np.ndarray]) -> list[dict]: """ Discover pairs and initialize Kalman traders. Args: price_data: {ticker: np.array of prices} Returns: List of discovered pair info dicts. """ pairs = discover_pairs( price_data, sector_constraint=self.config.sector_constraint, min_half_life=self.config.min_half_life, max_half_life=self.config.max_half_life, sig_level=self.config.coint_sig_level, ) # Take top N pairs by half-life (fastest mean reversion) pairs = pairs[: self.config.max_pairs] for p in pairs: key = p["pair"] self.pair_info[key] = p trader = KalmanPairsTrader( transition_covariance=self.config.transition_covariance, observation_covariance=self.config.observation_covariance, z_entry=self.config.z_entry, z_exit=self.config.z_exit, z_stop=self.config.z_stop, warmup_bars=self.config.warmup_bars, ) self.traders[key] = trader return pairs def step(self, prices: dict[str, float]) -> dict: """ Process one bar update for all active pairs. Args: prices: {ticker: current_price} for this bar. Returns: dict with: signals (list), equity, drawdown_pct, positions, alpha, beta """ # Reset daily trade counter now = time.time() if now - self.daily_reset_time > 86400: self.daily_trades = 0 self.daily_reset_time = now signals = [] total_pnl = 0.0 for key, trader in self.traders.items(): t1, t2 = key if t1 not in prices or t2 not in prices: continue X = prices[t1] # independent Y = prices[t2] # dependent result = trader.step(X, Y) if result["signal"] != 0 and self.daily_trades < self.config.max_daily_trades: # Apply risk checks if self._check_risk(): signal = { "pair": list(key), "signal": result["signal"], "spread": result["spread"], "z_score": result["z_score"], "alpha": result["alpha"], "beta": result["beta"], "position": result["position"], "trade_size": self.config.trade_size_usd, } signals.append(signal) self.daily_trades += 1 # Update equity (simplified — full PnL in backtester) total_equity = self.capital + total_pnl self.peak_capital = max(self.peak_capital, total_equity) dd_pct = (self.peak_capital - total_equity) / self.peak_capital if self.peak_capital > 0 else 0.0 self.equity_curve.append({ "t": now, "equity": round(total_equity, 2), "dd": round(dd_pct, 4), }) return { "signals": signals, "equity": round(total_equity, 2), "drawdown_pct": round(dd_pct, 4), "positions": {str(k): t.position for k, t in self.traders.items()}, "alpha": {str(k): t.alpha for k, t in self.traders.items()}, "beta": {str(k): t.beta for k, t in self.traders.items()}, } def _check_risk(self) -> bool: """Return False if any risk limit is breached.""" if self.peak_capital > 0: dd = (self.peak_capital - self.capital) / self.peak_capital if dd > self.config.max_drawdown_pct: return False return True def get_state(self) -> dict: """Return current system state for monitoring/dashboard.""" return { "capital": round(self.capital, 2), "peak_capital": round(self.peak_capital, 2), "drawdown_pct": round( (self.peak_capital - self.capital) / self.peak_capital * 100 if self.peak_capital > 0 else 0, 2 ), "active_pairs": len(self.traders), "daily_trades": self.daily_trades, "positions": {str(k): t.position for k, t in self.traders.items()}, "alpha": {str(k): round(t.alpha, 6) for k, t in self.traders.items()}, "beta": {str(k): round(t.beta, 6) for k, t in self.traders.items()}, }