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)
|
||||
Reference in New Issue
Block a user