""" Regime-Switching Ensemble — meta-strategy that selects strategies by market regime. Monitors market conditions (volatility, trend strength, correlation, liquidity) and dynamically allocates to the best strategy for each environment. Regime detection: - TRENDING: ↑ vol, ↑ directional persistence, strong Hurst - MEAN_REVERTING: ↓ vol, mean-reverting price action, OBI signals - CHOPPY: ↑ vol, no directional signal, avoid directional strategies - HIGH_VOL: ↑↑ vol, wide spreads → size down, tighten risk - LOW_VOL: ↓↓ vol, tight spreads → aggressive market making - FUNDING_EXTREME: extreme funding → delta-neutral carry Strategy-regime affinity map (which strategies work in which regimes): - TRENDING → Cross-Sectional Momentum, Hurst/VPIN, Momentum Breakout - MEAN_REVERTING → Pairs Trading, Mean Reversion, OBI - CHOPPY → Grid MM, A-S MM (market making thrives) - HIGH_VOL → Size down everything, tighten stops - LOW_VOL → A-S MM, Grid MM, Queue Imbalance - FUNDING_EXTREME → Funding Rate Arb Weight blending: regime probability × strategy-regime affinity = final weight. """ from __future__ import annotations import logging from collections import deque from typing import Optional import numpy as np import pandas as pd logger = logging.getLogger(__name__) # Strategy × Regime affinity matrix: 1.0 = ideal, 0.0 = useless STRATEGY_REGIME_AFFINITY = { "TRENDING": { "cross_sectional": 1.0, "hurst_vpin": 0.9, "momentum": 0.8, "iceberg": 0.7, "pairs": 0.2, "mean_rev": 0.0, "obi": 0.0, "grid_mm": 0.0, "as_mm": 0.1, "funding_arb": 0.1, }, "MEAN_REVERTING": { "pairs": 1.0, "mean_rev": 0.9, "obi": 0.8, "queue_imbalance": 0.6, "cross_sectional": 0.2, "hurst_vpin": 0.1, "momentum": 0.1, "grid_mm": 0.4, "as_mm": 0.5, "funding_arb": 0.1, }, "CHOPPY": { "grid_mm": 1.0, "as_mm": 0.8, "queue_imbalance": 0.4, "pairs": 0.3, "cross_sectional": 0.0, "hurst_vpin": 0.0, "momentum": 0.0, "obi": 0.0, "mean_rev": 0.0, "funding_arb": 0.1, }, "HIGH_VOL": { "hurst_vpin": 0.7, "cross_sectional": 0.6, "momentum": 0.5, "funding_arb": 0.4, "grid_mm": 0.1, # Wide spreads = bad for MM "as_mm": 0.1, "pairs": 0.3, "mean_rev": 0.2, "obi": 0.2, "iceberg": 0.5, }, "LOW_VOL": { "as_mm": 1.0, "grid_mm": 0.8, "queue_imbalance": 0.6, "pairs": 0.4, "mean_rev": 0.3, "cross_sectional": 0.3, "hurst_vpin": 0.2, "momentum": 0.2, "obi": 0.7, "funding_arb": 0.2, }, "FUNDING_EXTREME": { "funding_arb": 1.0, "pairs": 0.1, "cross_sectional": 0.1, "hurst_vpin": 0.1, "grid_mm": 0.1, "as_mm": 0.1, "momentum": 0.1, "obi": 0.1, "mean_rev": 0.1, }, "NORMAL": { "pairs": 0.7, "cross_sectional": 0.6, "hurst_vpin": 0.5, "momentum": 0.5, "obi": 0.5, "mean_rev": 0.5, "grid_mm": 0.5, "as_mm": 0.4, "funding_arb": 0.3, "queue_imbalance": 0.4, "iceberg": 0.4, }, } class RegimeDetector: """Multi-dimensional regime classification. Computes regime probabilities from multiple indicators: - Realized volatility (annualized) - Trend strength (directional persistence) - Mean reversion speed (half-life of deviation) - Hurst exponent - OBI (order book imbalance proxy) - Funding rate extremeness """ def __init__( self, vol_lookback: int = 50, trend_lookback: int = 20, hurst_window: int = 64, high_vol_threshold: float = 0.60, low_vol_threshold: float = 0.15, funding_extreme_apr: float = 0.30, ): self.vol_lookback = vol_lookback self.trend_lookback = trend_lookback self.hurst_window = hurst_window self.high_vol_threshold = high_vol_threshold self.low_vol_threshold = low_vol_threshold self.funding_extreme_apr = funding_extreme_apr self.prices: deque = deque(maxlen=500) self.funding_rates: deque = deque(maxlen=100) def feed_price(self, price: float): self.prices.append(price) def feed_funding(self, funding_rate: float): """Funding rate per 8h period.""" self.funding_rates.append(funding_rate) def detect(self) -> dict[str, float]: """Compute regime probabilities (sums to 1.0). Returns dict of regime_name → probability. """ if len(self.prices) < self.hurst_window: return {"NORMAL": 1.0, "TRENDING": 0.0, "MEAN_REVERTING": 0.0, "CHOPPY": 0.0, "HIGH_VOL": 0.0, "LOW_VOL": 0.0, "FUNDING_EXTREME": 0.0} prices = list(self.prices) returns = [np.log(prices[i] / prices[i - 1]) for i in range(1, len(prices))] # 1. Realized volatility vol = float(np.std(returns[-self.vol_lookback:])) if len(returns) >= self.vol_lookback else 0.0 annual_vol = vol * np.sqrt(365 * 24 * 60 * 60) # 2. Trend strength: fraction of bars in same direction if len(prices) >= self.trend_lookback: up = sum(1 for i in range(-self.trend_lookback + 1, 0) if prices[i] > prices[i - 1]) trend_pct = up / (self.trend_lookback - 1) else: trend_pct = 0.5 # 3. Mean reversion: half-life from AR(1) if len(returns) >= 50: mr_speed = self._half_life(returns[-100:]) else: mr_speed = 999.0 # 4. Hurst exponent if len(returns) >= self.hurst_window: hurst = self._hurst_rs(returns[-self.hurst_window:]) else: hurst = 0.50 # 5. Funding extremeness funding_extreme = 0.0 if self.funding_rates: fr = self.funding_rates[-1] annual_fr = abs(fr) * 365 * 3 if annual_fr > self.funding_extreme_apr / 2: funding_extreme = min(1.0, annual_fr / self.funding_extreme_apr) # 6. Compute regime probabilities with fuzzy logic probs = {} # TRENDING: high trend consistency + Hurst > 0.55 + not extreme vol trending_score = trend_pct * min(1.0, (hurst - 0.45) * 10) * (1.0 - min(annual_vol / 2.0, 1.0)) probs["TRENDING"] = max(0.0, min(1.0, trending_score * 2.0)) # MEAN_REVERTING: low trend + fast half-life + normal vol mr_score = (1.0 - trend_pct) * min(1.0, 20.0 / max(mr_speed, 1.0)) * (1.0 - min(annual_vol / 1.5, 1.0)) probs["MEAN_REVERTING"] = max(0.0, min(1.0, mr_score * 1.5)) # CHOPPY: high vol + no trend + no MR choppy_score = (1.0 - abs(trend_pct - 0.5) * 2.0) * min(annual_vol / 0.5, 1.0) probs["CHOPPY"] = max(0.0, min(1.0, choppy_score)) # HIGH_VOL: annual_vol > threshold probs["HIGH_VOL"] = max(0.0, min(1.0, (annual_vol - self.low_vol_threshold) / max(self.high_vol_threshold - self.low_vol_threshold, 0.01))) # LOW_VOL: annual_vol < low threshold probs["LOW_VOL"] = max(0.0, min(1.0, 1.0 - annual_vol / self.low_vol_threshold)) # FUNDING_EXTREME probs["FUNDING_EXTREME"] = funding_extreme # NORMAL: everything else normal = 1.0 - sum(max(0, v) for v in probs.values()) probs["NORMAL"] = max(0.0, normal) # Normalize so sum ≤ 1.0, but permit overlap total = sum(probs.values()) if total > 0: probs = {k: v / total for k, v in probs.items()} return probs def primary_regime(self) -> str: """Return the single most-likely regime label.""" probs = self.detect() if not probs: return "NORMAL" return max(probs, key=probs.get) @staticmethod def _half_life(returns: list[float]) -> float: """Estimate half-life of mean reversion from AR(1) coefficient.""" if len(returns) < 10: return 999.0 spread = np.cumsum(returns) spread_lag = spread[:-1] spread_diff = np.diff(spread) if len(spread_diff) < 2: return 999.0 try: slope = float(np.polyfit(spread_lag[:len(spread_diff)], spread_diff, 1)[0]) if slope >= 0 or slope <= -1: return 999.0 return -np.log(2) / slope except Exception: return 999.0 @staticmethod def _hurst_rs(returns: list[float]) -> float: """R/S Hurst exponent.""" n = len(returns) if n < 32: return 0.50 max_lag = min(n // 2, 64) lags = [] rs_vals = [] for lag in range(4, max_lag): segs = n // lag if segs < 2: continue vals = [] for s in range(segs): seg = returns[s * lag:(s + 1) * lag] mean = np.mean(seg) dev = np.cumsum([x - mean for x in seg]) r = float(np.max(dev) - np.min(dev)) sd = float(np.std(seg, ddof=1)) if sd > 1e-12: vals.append(r / sd) if vals: lags.append(np.log(lag)) rs_vals.append(np.log(np.mean(vals))) if len(lags) < 4: return 0.50 try: slope = float(np.polyfit(lags, rs_vals, 1)[0]) return max(0.20, min(0.90, slope)) except Exception: return 0.50 class RegimeEnsemble: """Regime-switching strategy ensemble. At each bar: 1. Detect current regime probabilities 2. Blend strategy-regime affinity matrix with regime probabilities 3. Produce weighted strategy allocations 4. Emit final trading signals """ def __init__( self, detector: RegimeDetector | None = None, min_signal_strength: float = 0.15, ): self.detector = detector or RegimeDetector() self.min_signal_strength = min_signal_strength self._strategy_signals: dict[str, dict] = {} self._strategy_returns: dict[str, list[float]] = {} self._weights: dict[str, float] = {} def feed_price(self, price: float): self.detector.feed_price(price) def feed_funding(self, rate: float): self.detector.feed_funding(rate) def update_strategy_signal(self, name: str, direction: str, strength: float, returns: list[float] | None = None): """Update a strategy's current signal.""" self._strategy_signals[name] = { "direction": direction, "strength": strength, } if returns: if name not in self._strategy_returns: self._strategy_returns[name] = [] self._strategy_returns[name].extend(returns) def compute_weights(self) -> dict[str, float]: """Compute strategy weights from regime probabilities × affinity matrix.""" regime_probs = self.detector.detect() weights: dict[str, float] = {} total = 0.0 for regime, prob in regime_probs.items(): if prob <= 0.01: continue affinity = STRATEGY_REGIME_AFFINITY.get(regime, {}) for strategy, aff in affinity.items(): if strategy not in self._strategy_signals: continue score = prob * aff if strategy not in weights: weights[strategy] = score else: weights[strategy] = max(weights[strategy], score) # Take best regime fit total += score if total > 0: weights = {k: v / total for k, v in weights.items()} self._weights = weights return weights def get_signals(self, current_prices: dict[str, float]) -> dict[str, dict]: """Produce weighted trading signals for each strategy. Returns dict of strategy_name → {direction, size, weight, regime}. """ weights = self.compute_weights() regime = self.detector.primary_regime() signals = {} for name, wt in weights.items(): if wt < 0.02: continue sig = self._strategy_signals.get(name, {}) direction = sig.get("direction", "NEUTRAL") strength = sig.get("strength", 0.0) * wt if strength < self.min_signal_strength: continue # Determine coin coin = self._strategy_coin(name) px = current_prices.get(coin, 0) signals[name] = { "direction": direction, "strength": round(strength, 3), "weight": round(wt, 3), "regime": regime, "coin": coin, "price": px, } return signals def _strategy_coin(self, name: str) -> str: coin_map = { "pairs": "ETH", "hurst_vpin": "BTC", "as_mm": "BTC", "obi": "BTC", "grid_mm": "BTC", "composite_mm": "BTC", "iceberg": "BTC", "funding_arb": "BTC", "momentum": "ETH", "mean_rev": "ETH", "cross_sectional": "BTC", "queue_imbalance": "BTC", } return coin_map.get(name.lower(), "BTC") def summary(self) -> dict: return { "regime": self.detector.primary_regime(), "regime_probs": {k: round(v, 3) for k, v in self.detector.detect().items() if v > 0.01}, "strategy_weights": {k: round(v, 3) for k, v in self._weights.items() if v > 0.01}, "active_strategies": len([w for w in self._weights.values() if w > 0.02]), }