427 lines
14 KiB
Python
427 lines
14 KiB
Python
"""
|
||
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]),
|
||
}
|