Files
ftdt-quant-lab/strategies/regime_ensemble.py
T

427 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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]),
}