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
+243
View File
@@ -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)
+420
View File
@@ -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
+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]),
}
+251
View File
@@ -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
+246
View File
@@ -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()