feat: creative alpha models + portfolio layer targeting Sharpe > 1.5
New strategies: - Cross-Sectional Momentum: long top-N, short bottom-N across HL universe - Spot-Perp Basis Arbitrage: delta-neutral spot vs perp price gap trading - Regime-Switching Ensemble: dynamically allocates strategies by market regime - Portfolio Construction: risk parity, vol targeting, correlation penalty Infrastructure: - DuckDBDataProvider: real tick/candle data for backtests (replaces synthetic) - Walk-Forward Validation: systematic IS/OOS across all 12 strategies - 3 Jupyter research notebooks (EDA, strategy research, portfolio) Pipeline integration: - deploy.py registry, sweep_runner, vbt_runner all updated - fee_tiers support for new strategies - All modules syntax-validated and import-tested
This commit is contained in:
@@ -0,0 +1,413 @@
|
||||
"""
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
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]),
|
||||
}
|
||||
Reference in New Issue
Block a user