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,243 @@
|
||||
"""
|
||||
Cross-Sectional Momentum strategy for Hyperliquid assets.
|
||||
|
||||
Ranks all available assets by recent return (lookback window). Goes long
|
||||
the top-N performers and short the bottom-N. Rebalances periodically.
|
||||
This captures the cross-sectional momentum premium documented extensively
|
||||
in academic literature (Jegadeesh & Titman 1993, Moskowitz 2012).
|
||||
|
||||
Assets: BTC, ETH, SOL, ARB, OP, HYPE, HFUN, PURR, VVV, etc.
|
||||
Interval: Daily rebalancing with 1m/1h/4h lookbacks available.
|
||||
Risk: Equal-weight or risk-parity across long/short baskets.
|
||||
Costs: Hyperliquid perp fee schedule with tier-appropriate rates.
|
||||
Post-only limit orders (maker fees) to reduce costs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Hyperliquid universe — perps with sufficient liquidity
|
||||
HL_UNIVERSE = [
|
||||
"BTC", "ETH", "SOL", "ARB", "OP", "HYPE", "HFUN", "PURR",
|
||||
"VVV", "LINK", "AVAX", "SUI", "DOGE", "XRP", "ADA", "DOT",
|
||||
"APT", "ATOM", "NEAR", "SEI",
|
||||
]
|
||||
|
||||
HIGH_LIQUIDITY = ["BTC", "ETH", "SOL", "HYPE", "ARB", "OP"]
|
||||
MEDIUM_LIQUIDITY = HIGH_LIQUIDITY + ["LINK", "AVAX", "SUI", "DOGE", "XRP"]
|
||||
LOW_LIQUIDITY = HL_UNIVERSE
|
||||
|
||||
|
||||
class CrossSectionalMomentum:
|
||||
"""Long-short cross-sectional momentum on Hyperliquid perps.
|
||||
|
||||
Ranks assets by momentum score, goes long top-N, short bottom-N.
|
||||
Rebalances every `rebalance_period` bars.
|
||||
|
||||
Attributes.
|
||||
----------
|
||||
lookback: int — bars to compute momentum over
|
||||
top_n: int — number of longs
|
||||
bottom_n: int — number of shorts
|
||||
rebalance_period: int — bars between rebalances
|
||||
risk_parity: bool — size positions by inverse volatility
|
||||
vol_target: float — annualized vol target (0 = disabled)
|
||||
filter_threshold: float — min abs return to include (avoids noise)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
lookback: int = 20,
|
||||
top_n: int = 3,
|
||||
bottom_n: int = 3,
|
||||
rebalance_period: int = 1,
|
||||
risk_parity: bool = True,
|
||||
vol_target: float = 0.25,
|
||||
filter_threshold: float = 0.002,
|
||||
):
|
||||
self.lookback = lookback
|
||||
self.top_n = top_n
|
||||
self.bottom_n = bottom_n
|
||||
self.rebalance_period = rebalance_period
|
||||
self.risk_parity = risk_parity
|
||||
self.vol_target = vol_target
|
||||
self.filter_threshold = filter_threshold
|
||||
|
||||
def compute_signals(
|
||||
self,
|
||||
prices: dict[str, pd.Series],
|
||||
) -> dict[str, float]:
|
||||
"""Compute cross-sectional momentum weights.
|
||||
|
||||
Args:
|
||||
prices: dict of coin → pd.Series of close prices (aligned by index)
|
||||
|
||||
Returns:
|
||||
dict of coin → weight (-1 to +1). Positive = long, negative = short.
|
||||
"""
|
||||
if len(prices) < self.top_n + self.bottom_n:
|
||||
return {}
|
||||
|
||||
momentum_scores = {}
|
||||
returns = {}
|
||||
|
||||
for coin, px in prices.items():
|
||||
if len(px) < self.lookback + 1:
|
||||
continue
|
||||
|
||||
pct_ret = (px.iloc[-1] / px.iloc[-self.lookback] - 1)
|
||||
if abs(pct_ret) < self.filter_threshold:
|
||||
continue
|
||||
|
||||
returns[coin] = px
|
||||
momentum_scores[coin] = pct_ret
|
||||
|
||||
if len(momentum_scores) < self.top_n + self.bottom_n:
|
||||
return {}
|
||||
|
||||
sorted_coins = sorted(momentum_scores, key=momentum_scores.get, reverse=True)
|
||||
|
||||
longs = sorted_coins[:self.top_n]
|
||||
shorts = sorted_coins[-self.bottom_n:]
|
||||
|
||||
weights: dict[str, float] = {}
|
||||
|
||||
if self.risk_parity:
|
||||
long_wt = self._risk_parity_weights({c: returns[c] for c in longs + shorts}, longs, shorts)
|
||||
weights.update(long_wt)
|
||||
else:
|
||||
for c in longs:
|
||||
weights[c] = 1.0 / self.top_n
|
||||
for c in shorts:
|
||||
weights[c] = -1.0 / self.bottom_n
|
||||
|
||||
if self.vol_target > 0:
|
||||
weights = self._scale_to_vol_target(weights, returns)
|
||||
|
||||
return weights
|
||||
|
||||
def _risk_parity_weights(
|
||||
self,
|
||||
returns: dict[str, pd.Series],
|
||||
longs: list[str],
|
||||
shorts: list[str],
|
||||
) -> dict[str, float]:
|
||||
"""Compute risk-parity weights: positions sized by 1/volatility."""
|
||||
weights = {}
|
||||
vols = {}
|
||||
for coin, px in returns.items():
|
||||
ret_series = px.pct_change().dropna()
|
||||
vol = ret_series.std() * np.sqrt(365 * 24)
|
||||
vols[coin] = max(vol, 0.05)
|
||||
|
||||
# Long basket
|
||||
long_inv_vols = {c: 1.0 / vols[c] for c in longs}
|
||||
long_sum = sum(long_inv_vols.values())
|
||||
for c in longs:
|
||||
weights[c] = long_inv_vols[c] / long_sum if long_sum > 0 else 1.0 / len(longs)
|
||||
|
||||
# Short basket
|
||||
short_inv_vols = {c: 1.0 / vols[c] for c in shorts}
|
||||
short_sum = sum(short_inv_vols.values())
|
||||
for c in shorts:
|
||||
weights[c] = -(short_inv_vols[c] / short_sum) if short_sum > 0 else -(1.0 / len(shorts))
|
||||
|
||||
return weights
|
||||
|
||||
def _scale_to_vol_target(
|
||||
self,
|
||||
weights: dict[str, float],
|
||||
returns: dict[str, pd.Series],
|
||||
) -> dict[str, float]:
|
||||
"""Scale portfolio to target annualized volatility."""
|
||||
if not weights:
|
||||
return weights
|
||||
|
||||
combined_ret = None
|
||||
for coin, wt in weights.items():
|
||||
if coin not in returns:
|
||||
continue
|
||||
px = returns[coin]
|
||||
ret = px.pct_change().dropna()
|
||||
if combined_ret is None:
|
||||
combined_ret = ret * wt
|
||||
else:
|
||||
combined_ret = combined_ret + ret * wt
|
||||
|
||||
if combined_ret is None or len(combined_ret) < 2:
|
||||
return weights
|
||||
|
||||
portfolio_vol = combined_ret.std() * np.sqrt(365 * 24)
|
||||
if portfolio_vol <= 0:
|
||||
return weights
|
||||
|
||||
scale = self.vol_target / portfolio_vol
|
||||
scale = min(scale, 2.0) # Cap leverage at 2x
|
||||
|
||||
return {c: w * scale for c, w in weights.items()}
|
||||
|
||||
def generate_entries_exits(
|
||||
self,
|
||||
prices: dict[str, pd.DataFrame],
|
||||
coin: str,
|
||||
) -> tuple[pd.Series, pd.Series]:
|
||||
"""Generate entry/exit signals suitable for VBT integration.
|
||||
|
||||
Returns (entries, exits) boolean Series indexed by time.
|
||||
"""
|
||||
close_prices = {c: df["close"] for c, df in prices.items() if "close" in df.columns}
|
||||
if coin not in close_prices:
|
||||
return pd.Series(dtype=bool), pd.Series(dtype=bool)
|
||||
|
||||
main_close = close_prices[coin]
|
||||
entries = pd.Series(False, index=main_close.index)
|
||||
exits = pd.Series(False, index=main_close.index)
|
||||
|
||||
for i in range(self.lookback, len(main_close.index)):
|
||||
if (i - self.lookback) % self.rebalance_period != 0:
|
||||
continue
|
||||
|
||||
slice_prices = {
|
||||
c: px.iloc[:i + 1]
|
||||
for c, px in close_prices.items()
|
||||
if len(px) > i
|
||||
}
|
||||
weights = self.compute_signals(slice_prices)
|
||||
|
||||
if coin in weights and weights[coin] != 0:
|
||||
wt = weights[coin]
|
||||
# Check if position changed direction
|
||||
prev_wt = self._get_prev_weight(coin, close_prices, i - self.rebalance_period, self.lookback)
|
||||
if wt > 0 and prev_wt <= 0:
|
||||
entries.iloc[i] = True
|
||||
elif wt < 0 and prev_wt >= 0:
|
||||
entries.iloc[i] = True
|
||||
elif abs(prev_wt - wt) < 0.01:
|
||||
# No significant weight change — exit
|
||||
exits.iloc[i] = True
|
||||
|
||||
return entries, exits
|
||||
|
||||
def _get_prev_weight(
|
||||
self,
|
||||
coin: str,
|
||||
prices: dict[str, pd.Series],
|
||||
idx: int,
|
||||
lookback: int,
|
||||
) -> float:
|
||||
"""Look up previous position weight."""
|
||||
if idx < lookback:
|
||||
return 0.0
|
||||
slice_prices = {
|
||||
c: px.iloc[:idx + 1]
|
||||
for c, px in prices.items()
|
||||
if len(px) > idx
|
||||
}
|
||||
weights = self.compute_signals(slice_prices)
|
||||
return weights.get(coin, 0.0)
|
||||
@@ -0,0 +1,420 @@
|
||||
"""
|
||||
Portfolio construction layer — risk allocation across strategies.
|
||||
|
||||
Turns N independent strategy signals into a single meta-portfolio using:
|
||||
1. Risk parity — allocates capital inversely proportional to strategy vol
|
||||
2. Volatility targeting — scales total portfolio to target annualized vol
|
||||
3. Correlation-based sizing — reduces allocation to redundant strategies
|
||||
4. Maximum drawdown stops — kill switch per strategy and portfolio-level
|
||||
5. Regime-weighted allocation — adjusts weights based on market regime
|
||||
|
||||
Integration point: sits between strategy signals and execution.
|
||||
Consumes signal strength values from each strategy, produces position sizes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StrategyAllocation:
|
||||
"""Position and PnL state for one strategy within the portfolio."""
|
||||
name: str
|
||||
weight: float = 0.0
|
||||
position: float = 0.0 # Current signed position (units)
|
||||
entry_price: float = 0.0 # Average entry price
|
||||
pnl: float = 0.0 # Realized PnL
|
||||
unrealized: float = 0.0 # Mark-to-market PnL
|
||||
trades: int = 0 # Trade count
|
||||
wins: int = 0 # Winning trades
|
||||
fee_paid: float = 0.0
|
||||
equity: float = 0.0 # Current allocation value
|
||||
initial_equity: float = 0.0 # Starting allocation
|
||||
signal_strength: deque = field(default_factory=lambda: deque(maxlen=100))
|
||||
returns: deque = field(default_factory=lambda: deque(maxlen=500))
|
||||
vol_20d: float = 0.0 # Rolling 20-period volatility
|
||||
var_95: float = 0.0 # Value at Risk (95%)
|
||||
active: bool = True # Kill-switch: False = disabled
|
||||
max_drawdown: float = 0.0 # Peak-to-trough drawdown
|
||||
peak_equity: float = 0.0 # All-time high equity
|
||||
regime_scores: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PortfolioMetrics:
|
||||
"""Aggregate portfolio metrics."""
|
||||
total_equity: float = 0.0
|
||||
total_exposure: float = 0.0
|
||||
gross_exposure: float = 0.0
|
||||
net_exposure: float = 0.0
|
||||
total_pnl: float = 0.0
|
||||
total_pnl_pct: float = 0.0
|
||||
sharpe: float = 0.0
|
||||
sortino: float = 0.0
|
||||
vol_20d: float = 0.0
|
||||
var_95: float = 0.0
|
||||
cvar_95: float = 0.0
|
||||
max_drawdown_pct: float = 0.0
|
||||
daily_drawdown: float = 0.0
|
||||
trades_today: int = 0
|
||||
win_rate: float = 0.0
|
||||
correlation_matrix: dict = field(default_factory=dict)
|
||||
regime: str = "NORMAL"
|
||||
|
||||
|
||||
class PortfolioConstructor:
|
||||
"""Risk-managed portfolio of strategies.
|
||||
|
||||
Responsibilities:
|
||||
- Compute optimal capital allocation per strategy
|
||||
- Apply volatility targeting at portfolio level
|
||||
- Reduce allocations to correlated strategies
|
||||
- Enforce per-strategy and portfolio-level drawdown stops
|
||||
- Produce final position sizes for each strategy
|
||||
|
||||
Usage:
|
||||
pf = PortfolioConstructor(capital=100000, vol_target=0.20, max_correlation=0.70)
|
||||
pf.update_returns("pairs", [0.001, -0.002, 0.003])
|
||||
pf.update_signal("pairs", strength=0.8, direction="BUY")
|
||||
...
|
||||
sizes = pf.get_positions(current_prices)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
capital: float = 100_000.0,
|
||||
vol_target: float = 0.20, # Annualized vol target
|
||||
max_correlation: float = 0.70, # Max corr before reducing allocation
|
||||
min_allocation: float = 0.02, # Min allocation fraction
|
||||
max_allocation: float = 0.25, # Max allocation fraction per strategy
|
||||
max_drawdown_stop: float = 0.15, # Kill strategy after 15% DD
|
||||
portfolio_mdd_stop: float = 0.10, # Stop entire portfolio at 10% DD
|
||||
n_lookback: int = 200, # Days for risk estimation
|
||||
regime_weights: dict | None = None, # Per-regime strategy weights
|
||||
):
|
||||
self.capital = capital
|
||||
self.vol_target = vol_target
|
||||
self.max_correlation = max_correlation
|
||||
self.min_allocation = min_allocation
|
||||
self.max_allocation = max_allocation
|
||||
self.max_drawdown_stop = max_drawdown_stop
|
||||
self.portfolio_mdd_stop = portfolio_mdd_stop
|
||||
self.n_lookback = n_lookback
|
||||
|
||||
self.strategies: dict[str, StrategyAllocation] = {}
|
||||
self.portfolio_returns: deque = deque(maxlen=n_lookback)
|
||||
self.portfolio_equity_history: deque = deque(maxlen=n_lookback)
|
||||
self.peak_equity: float = capital
|
||||
self.current_regime: str = "NORMAL"
|
||||
self.regime_weights = regime_weights or {}
|
||||
|
||||
self._portfolio_stopped: bool = False
|
||||
|
||||
def register_strategy(self, name: str, allocation: float = 0.0):
|
||||
"""Register a strategy in the portfolio."""
|
||||
if name not in self.strategies:
|
||||
alloc = allocation if allocation > 0 else self.capital * self.min_allocation
|
||||
self.strategies[name] = StrategyAllocation(
|
||||
name=name,
|
||||
initial_equity=alloc,
|
||||
equity=alloc,
|
||||
weight=1.0 / max(len(self.strategies) + 1, 1),
|
||||
)
|
||||
|
||||
def update_returns(self, name: str, returns: list[float]):
|
||||
"""Feed per-bar returns for a strategy."""
|
||||
if name not in self.strategies:
|
||||
self.register_strategy(name)
|
||||
st = self.strategies[name]
|
||||
for r in returns:
|
||||
st.returns.append(r)
|
||||
|
||||
def update_signal(self, name: str, strength: float, direction: str,
|
||||
price: float = 0.0, regime: str = "NORMAL"):
|
||||
"""Record a strategy signal and its strength."""
|
||||
if name not in self.strategies:
|
||||
self.register_strategy(name)
|
||||
st = self.strategies[name]
|
||||
st.signal_strength.append(strength)
|
||||
if regime not in st.regime_scores:
|
||||
st.regime_scores[regime] = []
|
||||
st.regime_scores[regime].append(strength if direction == "BUY" else -strength)
|
||||
|
||||
def compute_allocations(self, current_prices: dict[str, float]) -> dict[str, float]:
|
||||
"""Compute optimal capital allocation per strategy.
|
||||
|
||||
Returns dict of strategy_name → dollar_amount to allocate.
|
||||
"""
|
||||
total_alloc = 0.0
|
||||
weights = {}
|
||||
vols = {}
|
||||
n_active = sum(1 for s in self.strategies.values() if s.active)
|
||||
|
||||
# Step 1: compute individual strategy vols
|
||||
for name, st in self.strategies.items():
|
||||
if not st.active or len(st.returns) < 20:
|
||||
weights[name] = 0.0
|
||||
continue
|
||||
returns = list(st.returns)[-min(len(st.returns), self.n_lookback):]
|
||||
vol = float(np.std(returns)) if len(returns) > 1 else 0.0
|
||||
annual_vol = vol * np.sqrt(365 * 24) if vol > 0 else 0.10
|
||||
st.vol_20d = annual_vol
|
||||
vols[name] = annual_vol
|
||||
|
||||
# VaR 95%
|
||||
if len(returns) >= 50:
|
||||
st.var_95 = float(np.percentile(returns, 5))
|
||||
|
||||
weights[name] = 1.0 / max(annual_vol, 0.01)
|
||||
|
||||
if not vols:
|
||||
return {name: 0.0 for name in self.strategies}
|
||||
|
||||
# Step 2: adjust weights for correlation — reduce allocation to highly correlated strategies
|
||||
adjusted_weights = self._adjust_for_correlation(weights)
|
||||
|
||||
# Step 3: normalize to sum to 1 (risk-parity)
|
||||
total = sum(adjusted_weights.values())
|
||||
if total > 0:
|
||||
for name in adjusted_weights:
|
||||
adjusted_weights[name] = max(
|
||||
self.min_allocation,
|
||||
min(self.max_allocation, adjusted_weights[name] / total)
|
||||
)
|
||||
|
||||
# Step 4: regime override — if regime weights are specified, blend with risk-parity
|
||||
if self.current_regime in self.regime_weights:
|
||||
rw = self.regime_weights[self.current_regime]
|
||||
for name, wt in rw.items():
|
||||
if name in adjusted_weights:
|
||||
adjusted_weights[name] = adjusted_weights.get(name, 0) * 0.5 + wt * 0.5
|
||||
|
||||
# Step 5: vol target scaling
|
||||
if self.vol_target > 0 and self.portfolio_returns:
|
||||
pf_returns = list(self.portfolio_returns)[-100:]
|
||||
if len(pf_returns) > 10:
|
||||
pf_vol = float(np.std(pf_returns)) * np.sqrt(365 * 24)
|
||||
scale = self.vol_target / max(pf_vol, 0.01)
|
||||
scale = min(scale, 2.0) # Max 2x leverage
|
||||
for name in adjusted_weights:
|
||||
adjusted_weights[name] *= scale
|
||||
|
||||
# Step 6: convert weights to dollar allocations
|
||||
allocations = {}
|
||||
working_capital = self.capital * 0.70 # 70% of capital deployed, 30% reserve
|
||||
total_weight = sum(adjusted_weights.values())
|
||||
for name, wt in adjusted_weights.items():
|
||||
if total_weight > 0:
|
||||
allocations[name] = working_capital * (wt / total_weight)
|
||||
else:
|
||||
allocations[name] = 0.0
|
||||
|
||||
for name, st in self.strategies.items():
|
||||
st.weight = adjusted_weights.get(name, 0.0)
|
||||
st.equity = allocations.get(name, 0.0)
|
||||
|
||||
return allocations
|
||||
|
||||
def _adjust_for_correlation(self, raw_weights: dict[str, float]) -> dict[str, float]:
|
||||
"""Reduce weights of correlated strategies to avoid concentration."""
|
||||
adjusted = dict(raw_weights)
|
||||
|
||||
strategy_names = [n for n in raw_weights if raw_weights[n] > 0]
|
||||
if len(strategy_names) < 2:
|
||||
return adjusted
|
||||
|
||||
# Build correlation matrix from returns
|
||||
corr_matrix = {}
|
||||
for i, n1 in enumerate(strategy_names):
|
||||
for n2 in strategy_names[i + 1:]:
|
||||
r1 = list(self.strategies[n1].returns)[-200:]
|
||||
r2 = list(self.strategies[n2].returns)[-200:]
|
||||
min_len = min(len(r1), len(r2))
|
||||
if min_len < 20:
|
||||
corr = 0.0
|
||||
else:
|
||||
corr = float(np.corrcoef(r1[-min_len:], r2[-min_len:])[0, 1])
|
||||
corr_matrix[f"{n1}|{n2}"] = round(corr, 3)
|
||||
|
||||
# Penalize correlated pairs
|
||||
for key, corr in corr_matrix.items():
|
||||
if abs(corr) > self.max_correlation and not np.isnan(corr):
|
||||
n1, n2 = key.split("|")
|
||||
penalty = 1.0 - (abs(corr) - self.max_correlation)
|
||||
adjusted[n1] = adjusted.get(n1, 0) * penalty
|
||||
adjusted[n2] = adjusted.get(n2, 0) * penalty
|
||||
|
||||
return adjusted
|
||||
|
||||
def get_positions(self, current_prices: dict[str, float],
|
||||
signals: dict[str, dict] | None = None) -> dict[str, dict]:
|
||||
"""Compute final position sizes for each strategy.
|
||||
|
||||
Args:
|
||||
current_prices: coin → current mark price
|
||||
signals: strategy_name → {"direction": "BUY"/"SELL", "strength": 0.0}
|
||||
|
||||
Returns:
|
||||
strategy_name → {"coin": str, "side": "BUY"/"SELL", "size": float, "price": float}
|
||||
"""
|
||||
allocations = self.compute_allocations(current_prices)
|
||||
positions = {}
|
||||
|
||||
for name, alloc in allocations.items():
|
||||
if alloc <= 0 or name not in self.strategies:
|
||||
continue
|
||||
st = self.strategies[name]
|
||||
if not st.active:
|
||||
continue
|
||||
|
||||
# Determine coin from strategy name
|
||||
coin = self._strategy_coin(name)
|
||||
px = current_prices.get(coin, 0)
|
||||
if px <= 0:
|
||||
continue
|
||||
|
||||
# Signal-based direction override
|
||||
sig = signals.get(name) if signals else None
|
||||
if sig:
|
||||
direction = sig.get("direction", "NEUTRAL")
|
||||
strength = sig.get("strength", 0.0)
|
||||
else:
|
||||
# Default: use recent signal history
|
||||
recent = list(st.signal_strength)[-20:]
|
||||
avg_signal = float(np.mean(recent)) if recent else 0.0
|
||||
direction = "BUY" if avg_signal > 0 else "SELL"
|
||||
strength = abs(avg_signal)
|
||||
|
||||
# Position size: allocation / price, scaled by signal strength
|
||||
base_size = alloc / px
|
||||
size = base_size * min(strength, 1.5)
|
||||
size = max(size, base_size * 0.25) # Minimum 25% of base size
|
||||
|
||||
positions[name] = {
|
||||
"coin": coin,
|
||||
"side": direction if strength > 0.1 else "NEUTRAL",
|
||||
"size": round(size, 6),
|
||||
"price": px,
|
||||
"allocation": round(alloc, 2),
|
||||
"weight": round(st.weight, 3),
|
||||
"vol_20d": round(st.vol_20d, 3),
|
||||
}
|
||||
|
||||
return positions
|
||||
|
||||
def _strategy_coin(self, name: str) -> str:
|
||||
"""Map strategy to primary trading coin."""
|
||||
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",
|
||||
}
|
||||
return coin_map.get(name.lower(), "BTC")
|
||||
|
||||
def check_drawdown_stops(self) -> dict[str, bool]:
|
||||
"""Check and enforce drawdown stops.
|
||||
|
||||
Returns dict of strategy_name → stopped (True if kill switch triggered).
|
||||
"""
|
||||
stops = {}
|
||||
|
||||
for name, st in self.strategies.items():
|
||||
if not st.active:
|
||||
continue
|
||||
|
||||
if st.equity > st.peak_equity:
|
||||
st.peak_equity = st.equity
|
||||
|
||||
if st.peak_equity > 0:
|
||||
dd = 1.0 - st.equity / st.peak_equity
|
||||
if dd > self.max_drawdown_stop:
|
||||
st.active = False
|
||||
stops[name] = True
|
||||
logger.warning("KILL SWITCH: %s DD=%.1f%% > %.1f%% limit",
|
||||
name, dd * 100, self.max_drawdown_stop * 100)
|
||||
else:
|
||||
stops[name] = False
|
||||
|
||||
return stops
|
||||
|
||||
def update_portfolio_value(self, strategy_pnls: dict[str, float]):
|
||||
"""Update portfolio equity after a round of PnL."""
|
||||
total_pnl = sum(strategy_pnls.values())
|
||||
new_equity = self.capital + total_pnl
|
||||
|
||||
if len(self.portfolio_equity_history) > 0:
|
||||
prev = self.portfolio_equity_history[-1]
|
||||
if prev > 0:
|
||||
ret = (new_equity - prev) / prev
|
||||
self.portfolio_returns.append(ret)
|
||||
|
||||
self.portfolio_equity_history.append(new_equity)
|
||||
|
||||
if new_equity > self.peak_equity:
|
||||
self.peak_equity = new_equity
|
||||
|
||||
# Portfolio-level drawdown stop
|
||||
if self.peak_equity > 0:
|
||||
pf_dd = 1.0 - new_equity / self.peak_equity
|
||||
if pf_dd > self.portfolio_mdd_stop and not self._portfolio_stopped:
|
||||
self._portfolio_stopped = True
|
||||
logger.warning("PORTFOLIO KILL SWITCH: DD=%.1f%% > %.1f%%",
|
||||
pf_dd * 100, self.portfolio_mdd_stop * 100)
|
||||
|
||||
def summary(self) -> PortfolioMetrics:
|
||||
"""Generate portfolio metrics report."""
|
||||
pf = PortfolioMetrics()
|
||||
pf.regime = self.current_regime
|
||||
|
||||
equity_vals = list(self.portfolio_equity_history)
|
||||
if equity_vals:
|
||||
pf.total_equity = round(equity_vals[-1], 2)
|
||||
returns = list(self.portfolio_returns)
|
||||
if len(returns) > 10:
|
||||
pf.vol_20d = round(float(np.std(returns[-20:])) * np.sqrt(365 * 24), 3)
|
||||
pf.sharpe = round(float(np.mean(returns) / max(np.std(returns), 1e-10)) * np.sqrt(365 * 24), 3)
|
||||
down_returns = [r for r in returns if r < 0]
|
||||
if down_returns:
|
||||
pf.sortino = round(float(np.mean(returns) / max(np.std(down_returns), 1e-10)) * np.sqrt(365 * 24), 3)
|
||||
|
||||
# Max drawdown
|
||||
peak = equity_vals[0]
|
||||
pf.max_drawdown_pct = 0.0
|
||||
for v in equity_vals:
|
||||
if v > peak:
|
||||
peak = v
|
||||
dd = (peak - v) / peak if peak > 0 else 0
|
||||
if dd > pf.max_drawdown_pct:
|
||||
pf.max_drawdown_pct = dd
|
||||
|
||||
pf.total_pnl = sum(s.pnl for s in self.strategies.values())
|
||||
pf.total_pnl_pct = pf.total_pnl / self.capital if self.capital > 0 else 0
|
||||
|
||||
pf.trades_today = sum(s.trades for s in self.strategies.values())
|
||||
total_wins = sum(s.wins for s in self.strategies.values())
|
||||
pf.win_rate = total_wins / max(pf.trades_today, 1)
|
||||
|
||||
long_exp = sum(s.equity for n, s in self.strategies.items() if s.position > 0)
|
||||
short_exp = sum(abs(s.position) for n, s in self.strategies.items() if s.position < 0)
|
||||
pf.gross_exposure = long_exp + short_exp
|
||||
pf.net_exposure = long_exp - short_exp
|
||||
pf.total_exposure = pf.gross_exposure
|
||||
|
||||
return pf
|
||||
|
||||
def is_stopped(self) -> bool:
|
||||
return self._portfolio_stopped
|
||||
@@ -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]),
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
"""
|
||||
Spot-Perp Basis Arbitrage — delta-neutral strategy exploiting spot/perp price gaps.
|
||||
|
||||
Hyperliquid has both spot and perpetual futures markets for most assets.
|
||||
The spot price and perp price should converge at settlement, but the perp
|
||||
can trade at a premium (contango) or discount (backwardation) to spot.
|
||||
|
||||
Strategy:
|
||||
- When perp > spot + threshold: short perp, long spot
|
||||
- When perp < spot - threshold: long perp, short spot
|
||||
- Hold until basis converges or funding arbitrage overwhelms
|
||||
|
||||
Key advantages:
|
||||
- Delta-neutral (market-neutral)
|
||||
- Low correlation to other strategies
|
||||
- Capital-efficient (spot + perp use separate collateral on HL)
|
||||
- Combinable with funding arb (earn funding while holding basis trade)
|
||||
|
||||
Data needed:
|
||||
- Hyperliquid spot mid price
|
||||
- Hyperliquid perp mark price
|
||||
- Perp funding rate (for combined carry trade)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import deque
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SpotPerpBasisArb:
|
||||
"""Delta-neutral spot-perpetual basis arbitrage.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
entry_threshold_bps: float — minimum basis (in bps) to enter a trade
|
||||
exit_threshold_bps: float — basis below which to exit
|
||||
max_hold_hours: float — maximum trade duration
|
||||
size_usd: float — notional size per leg in USD
|
||||
spot_fee_rate: float — spot taker fee rate (decimal)
|
||||
perp_fee_rate: float — perp taker fee rate (decimal)
|
||||
min_expected_profit_bps: float — minimum expected profit after fees
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entry_threshold_bps: float = 3.0,
|
||||
exit_threshold_bps: float = 1.0,
|
||||
max_hold_hours: float = 48.0,
|
||||
size_usd: float = 1000.0,
|
||||
spot_fee_rate: float = 0.0010,
|
||||
perp_fee_rate: float = 0.0007,
|
||||
min_expected_profit_bps: float = 1.5,
|
||||
):
|
||||
self.entry_threshold = entry_threshold_bps / 10000 # bps → decimal
|
||||
self.exit_threshold = exit_threshold_bps / 10000
|
||||
self.max_hold_hours = max_hold_hours
|
||||
self.size_usd = size_usd
|
||||
self.spot_fee_rate = spot_fee_rate
|
||||
self.perp_fee_rate = perp_fee_rate
|
||||
self.min_expected_profit = min_expected_profit_bps / 10000
|
||||
|
||||
self._position: dict = {}
|
||||
self._trades: list[dict] = []
|
||||
self._basis_history: deque = deque(maxlen=500)
|
||||
|
||||
def signal(
|
||||
self,
|
||||
spot_price: float,
|
||||
perp_price: float,
|
||||
funding_rate: float = 0.0,
|
||||
) -> dict:
|
||||
"""Compute basis trade signal.
|
||||
|
||||
Args:
|
||||
spot_price: current spot mid price
|
||||
perp_price: current perp mark/index price
|
||||
funding_rate: current 8h funding rate (decimal)
|
||||
|
||||
Returns:
|
||||
dict with action, basis_bps, expected_profit_bps, funding_apr
|
||||
"""
|
||||
if spot_price <= 0 or perp_price <= 0:
|
||||
return {"action": "HOLD", "basis_bps": 0.0}
|
||||
|
||||
basis = (perp_price - spot_price) / spot_price
|
||||
basis_bps = basis * 10000
|
||||
self._basis_history.append(basis_bps)
|
||||
|
||||
in_position = bool(self._position)
|
||||
|
||||
if in_position:
|
||||
# Exit if basis narrows enough or max hold exceeded
|
||||
pos_basis = abs(self._position.get("entry_basis", 0))
|
||||
current_basis_abs = abs(basis)
|
||||
|
||||
if current_basis_abs < self.exit_threshold:
|
||||
return {
|
||||
"action": "EXIT",
|
||||
"basis_bps": round(basis_bps, 2),
|
||||
"reason": f"basis_converged_{current_basis_abs*10000:.0f}bps",
|
||||
}
|
||||
|
||||
# Compute expected profit after fees
|
||||
round_trip_fee = self.spot_fee_rate * 2 + self.perp_fee_rate * 2
|
||||
expected_profit = abs(basis) - round_trip_fee
|
||||
|
||||
# Add funding carry expectation
|
||||
funding_apr = abs(funding_rate) * 365 * 3 # 8h → annual
|
||||
funding_profit = funding_apr * (self.max_hold_hours / (365 * 24))
|
||||
|
||||
if not in_position and abs(basis) > self.entry_threshold and expected_profit > self.min_expected_profit:
|
||||
if basis > 0:
|
||||
return {
|
||||
"action": "SELL_PERP_BUY_SPOT",
|
||||
"basis_bps": round(basis_bps, 2),
|
||||
"expected_profit_bps": round(expected_profit * 10000, 2),
|
||||
"funding_apr_pct": round(funding_apr * 100, 2),
|
||||
"reason": f"perp_premium_{basis_bps:.0f}bps",
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"action": "BUY_PERP_SELL_SPOT",
|
||||
"basis_bps": round(basis_bps, 2),
|
||||
"expected_profit_bps": round(expected_profit * 10000, 2),
|
||||
"funding_apr_pct": round(funding_apr * 100, 2),
|
||||
"reason": f"perp_discount_{abs(basis_bps):.0f}bps",
|
||||
}
|
||||
|
||||
return {
|
||||
"action": "HOLD",
|
||||
"basis_bps": round(basis_bps, 2),
|
||||
"expected_profit_bps": round(expected_profit * 10000, 2) if expected_profit > 0 else 0,
|
||||
"funding_apr_pct": round(funding_apr * 100, 2),
|
||||
}
|
||||
|
||||
def enter(self, action: str, spot_price: float, perp_price: float):
|
||||
"""Record a trade entry."""
|
||||
basis = (perp_price - spot_price) / spot_price
|
||||
self._position = {
|
||||
"action": action,
|
||||
"entry_spot": spot_price,
|
||||
"entry_perp": perp_price,
|
||||
"entry_basis": basis,
|
||||
"entry_time": __import__('time').time(),
|
||||
"spot_size": self.size_usd / spot_price,
|
||||
"perp_size": self.size_usd / perp_price,
|
||||
}
|
||||
|
||||
def exit(self, spot_price: float, perp_price: float) -> dict:
|
||||
"""Exit the current position and compute PnL."""
|
||||
if not self._position:
|
||||
return {"pnl": 0.0, "pnl_pct": 0.0}
|
||||
|
||||
entry = self._position
|
||||
action = entry["action"]
|
||||
|
||||
if action == "SELL_PERP_BUY_SPOT":
|
||||
perp_pnl = (entry["entry_perp"] - perp_price) * entry["perp_size"]
|
||||
spot_pnl = (spot_price - entry["entry_spot"]) * entry["spot_size"]
|
||||
elif action == "BUY_PERP_SELL_SPOT":
|
||||
perp_pnl = (perp_price - entry["entry_perp"]) * entry["perp_size"]
|
||||
spot_pnl = (entry["entry_spot"] - spot_price) * entry["spot_size"]
|
||||
else:
|
||||
perp_pnl = 0.0
|
||||
spot_pnl = 0.0
|
||||
|
||||
gross_pnl = perp_pnl + spot_pnl
|
||||
|
||||
spot_fee = entry["spot_size"] * entry["entry_spot"] * self.spot_fee_rate + \
|
||||
entry["spot_size"] * spot_price * self.spot_fee_rate
|
||||
perp_fee = entry["perp_size"] * entry["entry_perp"] * self.perp_fee_rate + \
|
||||
entry["perp_size"] * perp_price * self.perp_fee_rate
|
||||
total_fee = spot_fee + perp_fee
|
||||
|
||||
net_pnl = gross_pnl - total_fee
|
||||
pnl_pct = net_pnl / (self.size_usd * 2) if self.size_usd > 0 else 0
|
||||
|
||||
trade = {
|
||||
"action": action,
|
||||
"entry_spot": round(entry["entry_spot"], 2),
|
||||
"entry_perp": round(entry["entry_perp"], 2),
|
||||
"exit_spot": round(spot_price, 2),
|
||||
"exit_perp": round(perp_price, 2),
|
||||
"entry_basis_bps": round(entry["entry_basis"] * 10000, 2),
|
||||
"exit_basis_bps": round((perp_price - spot_price) / spot_price * 10000, 2),
|
||||
"gross_pnl": round(gross_pnl, 4),
|
||||
"fee": round(total_fee, 4),
|
||||
"net_pnl": round(net_pnl, 4),
|
||||
"pnl_pct": round(pnl_pct * 100, 3),
|
||||
}
|
||||
self._trades.append(trade)
|
||||
self._position = {}
|
||||
|
||||
return trade
|
||||
|
||||
def is_in_position(self) -> bool:
|
||||
return bool(self._position)
|
||||
|
||||
def summary(self) -> dict:
|
||||
"""Return strategy summary."""
|
||||
return {
|
||||
"in_position": self.is_in_position(),
|
||||
"position": self._position if self._position else None,
|
||||
"total_trades": len(self._trades),
|
||||
"total_pnl": round(sum(t["net_pnl"] for t in self._trades), 4),
|
||||
"recent_basis_bps": list(self._basis_history)[-10:],
|
||||
}
|
||||
|
||||
|
||||
# Multi-asset basis arb across coins
|
||||
BASIS_ARB_COINS = ["BTC", "ETH", "SOL", "HYPE", "ARB"]
|
||||
|
||||
|
||||
def multi_asset_basis_scan(
|
||||
spot_prices: dict[str, float],
|
||||
perp_prices: dict[str, float],
|
||||
funding_rates: dict[str, float],
|
||||
entry_threshold_bps: float = 3.0,
|
||||
) -> list[dict]:
|
||||
"""Scan all assets for basis arb opportunities.
|
||||
|
||||
Returns sorted list of opportunities, best first.
|
||||
"""
|
||||
opportunities = []
|
||||
for coin in perp_prices:
|
||||
if coin not in spot_prices:
|
||||
continue
|
||||
spot = spot_prices.get(coin, 0)
|
||||
perp = perp_prices.get(coin, 0)
|
||||
fr = funding_rates.get(coin, 0)
|
||||
|
||||
if spot <= 0 or perp <= 0:
|
||||
continue
|
||||
|
||||
basis = (perp - spot) / spot
|
||||
if abs(basis) > entry_threshold_bps / 10000:
|
||||
opportunities.append({
|
||||
"coin": coin,
|
||||
"spot": spot,
|
||||
"perp": perp,
|
||||
"basis_bps": round(basis * 10000, 2),
|
||||
"funding_rate": round(fr, 8),
|
||||
"direction": "SELL_PERP_BUY_SPOT" if basis > 0 else "BUY_PERP_SELL_SPOT",
|
||||
})
|
||||
|
||||
opportunities.sort(key=lambda x: abs(x["basis_bps"]), reverse=True)
|
||||
return opportunities
|
||||
@@ -0,0 +1,246 @@
|
||||
"""
|
||||
Systematic walk-forward validation across all strategies.
|
||||
|
||||
Runs every strategy through walk-forward IS/OOS backtesting with
|
||||
statistical significance testing (DSR, PSR, Sharpe Haircut).
|
||||
|
||||
Produces:
|
||||
- Per-strategy walk-forward reports
|
||||
- Composite significance scores
|
||||
- Strategy ranking by robustness
|
||||
- Deploy/simulate/discard recommendations
|
||||
|
||||
Usage:
|
||||
python strategies/wf_validate_all.py # all strategies, 1h interval
|
||||
python strategies/wf_validate_all.py --strategy pairs # single strategy
|
||||
python strategies/wf_validate_all.py --interval 4h # different interval
|
||||
python strategies/wf_validate_all.py --n-windows 5 # more windows
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from quant.walkforward import WalkForwardRunner
|
||||
from quant.significance import QuantVerdict, validate_strategy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VALIDATION_STRATEGIES = [
|
||||
"pairs",
|
||||
"hurst_vpin",
|
||||
"as_mm",
|
||||
"obi",
|
||||
"grid_mm",
|
||||
"composite_mm",
|
||||
"iceberg",
|
||||
"momentum",
|
||||
"mean_rev",
|
||||
"cross_sectional",
|
||||
"spot_perp_basis",
|
||||
"regime_ensemble",
|
||||
]
|
||||
|
||||
INTERVALS = ["1h", "4h", "1d"]
|
||||
|
||||
|
||||
def run_full_validation(
|
||||
strategies: list[str] | None = None,
|
||||
intervals: list[str] | None = None,
|
||||
n_windows: int = 5,
|
||||
fee_tier: int = 0,
|
||||
staking_tier: str = "none",
|
||||
save_results: bool = True,
|
||||
) -> dict:
|
||||
"""Run walk-forward validation on all specified strategies and intervals.
|
||||
|
||||
Returns a dict with strategy → interval → report.
|
||||
"""
|
||||
strats = strategies or VALIDATION_STRATEGIES
|
||||
ints = intervals or INTERVALS
|
||||
|
||||
results: dict[str, dict] = {}
|
||||
total = len(strats) * len(ints)
|
||||
completed = 0
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info("Walk-Forward Validation: %d strategies × %d intervals = %d runs",
|
||||
len(strats), len(ints), total)
|
||||
logger.info("Windows: %d | Fee tier: %d | Staking: %s", n_windows, fee_tier, staking_tier)
|
||||
logger.info("=" * 60)
|
||||
|
||||
for strategy in strats:
|
||||
results[strategy] = {}
|
||||
for interval in ints:
|
||||
completed += 1
|
||||
t_start = time.time()
|
||||
|
||||
logger.info("[%d/%d] %s @ %s...", completed, total, strategy, interval)
|
||||
|
||||
try:
|
||||
wfr = WalkForwardRunner(
|
||||
n_windows=n_windows,
|
||||
fee_tier=fee_tier,
|
||||
staking_tier=staking_tier,
|
||||
)
|
||||
report = wfr.run(strategy=strategy, interval=interval)
|
||||
elapsed = time.time() - t_start
|
||||
|
||||
if report.windows:
|
||||
sig = report.significance_report(n_trials=len(strats) * len(ints))
|
||||
ver = validate_strategy(
|
||||
sharpe=report.avg_oos_sharpe,
|
||||
n_trades=max(report.total_oos_trades, 1),
|
||||
n_trials=len(strats) * len(ints),
|
||||
wf_consistency=report.consistency,
|
||||
)
|
||||
results[strategy][interval] = {
|
||||
"strategy": strategy,
|
||||
"interval": interval,
|
||||
"n_windows": report.n_windows,
|
||||
"consistency": round(report.consistency, 3),
|
||||
"avg_oos_sharpe": round(report.avg_oos_sharpe, 3),
|
||||
"oos_sharpe": round(report.oos_sharpe, 3),
|
||||
"performance_decay": round(report.performance_decay, 3),
|
||||
"total_trades": report.total_oos_trades,
|
||||
"deflated_sharpe": sig["deflated_sharpe"],
|
||||
"psr": sig["psr"],
|
||||
"haircut_sharpe": sig["haircut_sharpe"],
|
||||
"verdict": sig["verdict"],
|
||||
"score": sig["score"],
|
||||
"recommendation": sig["recommendation"],
|
||||
"elapsed_s": round(elapsed, 1),
|
||||
}
|
||||
logger.info(" → W%d WF=%.2f S=%.2f DSR=%.3f %s @ %.1fs",
|
||||
len(report.windows), report.consistency,
|
||||
report.avg_oos_sharpe, sig["deflated_sharpe"],
|
||||
sig["verdict"], elapsed)
|
||||
else:
|
||||
results[strategy][interval] = {
|
||||
"strategy": strategy,
|
||||
"interval": interval,
|
||||
"error": "no_windows",
|
||||
"elapsed_s": round(elapsed, 1),
|
||||
}
|
||||
logger.info(" → No windows (insufficient data)")
|
||||
|
||||
except Exception as e:
|
||||
elapsed = time.time() - t_start
|
||||
results[strategy][interval] = {
|
||||
"strategy": strategy,
|
||||
"interval": interval,
|
||||
"error": str(e)[:100],
|
||||
"elapsed_s": round(elapsed, 1),
|
||||
}
|
||||
logger.warning(" → Error: %s", e)
|
||||
|
||||
# Print unified summary
|
||||
_print_summary(results)
|
||||
|
||||
if save_results:
|
||||
_save_results(results)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _print_summary(results: dict):
|
||||
print(f"\n{'=' * 80}")
|
||||
print(f" Walk-Forward Validation Summary")
|
||||
print(f"{'=' * 80}")
|
||||
print(f"{'Strategy':<20} {'Int':>4} {'W':>3} {'Consist':>8} {'OOS Sh':>7} {'Decay':>7} {'DSR':>6} {'Verdict':>10}")
|
||||
print("-" * 80)
|
||||
|
||||
rankings = []
|
||||
for strategy in sorted(results):
|
||||
for interval in sorted(results.get(strategy, {})):
|
||||
r = results[strategy][interval]
|
||||
if r.get("error"):
|
||||
continue
|
||||
rankings.append(r)
|
||||
print(f"{r['strategy']:<20} {r['interval']:>4} {r['n_windows']:>3} "
|
||||
f"{r['consistency']:>7.0%} {r['avg_oos_sharpe']:>7.2f} "
|
||||
f"{r['performance_decay']:>7.2f} {r['deflated_sharpe']:>6.3f} "
|
||||
f"{r['verdict']:>10}")
|
||||
|
||||
rankings.sort(key=lambda x: x.get("deflated_sharpe", 0), reverse=True)
|
||||
|
||||
print(f"\n--- Top 10 by Deflated Sharpe Ratio ---")
|
||||
for i, r in enumerate(rankings[:10]):
|
||||
deploy_mark = " ✅" if r["verdict"] == "DEPLOY" else (" ⚠️" if r["verdict"] == "SIMULATE" else " ❌")
|
||||
print(f" {i+1:2d}. {r['strategy']:<20s} {r['interval']:>4s} "
|
||||
f"DSR={r['deflated_sharpe']:>6.3f} {r['verdict']}{deploy_mark}")
|
||||
|
||||
deployable = [r for r in rankings if r["verdict"] == "DEPLOY"]
|
||||
simulate = [r for r in rankings if r["verdict"] == "SIMULATE"]
|
||||
discarded = [r for r in rankings if r["verdict"] == "DISCARD"]
|
||||
|
||||
print(f"\nVerdict breakdown:")
|
||||
print(f" DEPLOY: {len(deployable)}")
|
||||
print(f" SIMULATE: {len(simulate)}")
|
||||
print(f" DISCARD: {len(discarded)}")
|
||||
|
||||
if deployable:
|
||||
print(f"\nDeployable strategies (sorted by DSR):")
|
||||
for r in sorted(deployable, key=lambda x: x["deflated_sharpe"], reverse=True):
|
||||
print(f" ✅ {r['strategy']}/{r['interval']}: "
|
||||
f"OOS Sharpe={r['avg_oos_sharpe']:.2f}, DSR={r['deflated_sharpe']:.3f}")
|
||||
|
||||
|
||||
def _save_results(results: dict):
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
out_path = Path(__file__).resolve().parent.parent / "backtests" / "results" / f"wf_validation_{timestamp}.json"
|
||||
|
||||
flat = {}
|
||||
for strategy, intervals in results.items():
|
||||
for interval, report in intervals.items():
|
||||
flat[f"{strategy}/{interval}"] = report
|
||||
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(out_path, "w") as f:
|
||||
json.dump(flat, f, indent=2, default=str)
|
||||
logger.info("Results saved to %s", out_path)
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description="Walk-Forward Validation — All Strategies")
|
||||
p.add_argument("--strategy", "-s", nargs="+",
|
||||
help="Strategies to validate (default: all)")
|
||||
p.add_argument("--interval", "-i", nargs="+",
|
||||
help="Intervals to test (default: 1h,4h,1d)")
|
||||
p.add_argument("--n-windows", type=int, default=5,
|
||||
help="Number of walk-forward windows (default: 5)")
|
||||
p.add_argument("--fee-tier", type=int, default=0,
|
||||
help="VIP fee tier 0-6 (default: 0)")
|
||||
p.add_argument("--staking-tier", default="none",
|
||||
help="Staking tier (default: none)")
|
||||
p.add_argument("--no-save", action="store_true",
|
||||
help="Don't save results to disk")
|
||||
args = p.parse_args()
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
|
||||
run_full_validation(
|
||||
strategies=args.strategy,
|
||||
intervals=args.interval,
|
||||
n_windows=args.n_windows,
|
||||
fee_tier=args.fee_tier,
|
||||
staking_tier=args.staking_tier,
|
||||
save_results=not args.no_save,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user