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:
ramseshk
2026-08-12 12:26:29 +08:00
parent d967301834
commit 0446443d36
14 changed files with 3942 additions and 10 deletions
+413
View File
@@ -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]),
}