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)