Add check_symbols.py for ETH perpetuals filtering and enhance backtester with size handling
- Introduced `check_symbols.py` to load and filter ETH perpetual markets from the OKX exchange using CCXT. - Updated the backtester to normalize signals to a 5-tuple format, incorporating size management for trades. - Enhanced portfolio functions to support variable size and leverage adjustments based on initial capital. - Added a new method in `CryptoQuantClient` for chunked historical data fetching to avoid API limits. - Improved market symbol normalization in `market.py` to handle different formats. - Updated regime strategy parameters based on recent research findings for optimal performance.
This commit is contained in:
@@ -80,14 +80,23 @@ def _build_registry() -> dict[str, StrategyConfig]:
|
||||
"regime": StrategyConfig(
|
||||
strategy_class=RegimeReversionStrategy,
|
||||
default_params={
|
||||
'horizon': 96,
|
||||
'z_window': 24,
|
||||
'stop_loss': 0.06,
|
||||
'take_profit': 0.05
|
||||
# Optimal from walk-forward research (research/horizon_optimization_results.csv)
|
||||
'horizon': 102, # 4.25 days - best Net PnL
|
||||
'z_window': 24, # 24h rolling Z-score window
|
||||
'z_entry_threshold': 1.0, # Enter when |Z| > 1.0
|
||||
'profit_target': 0.005, # 0.5% target for ML labels
|
||||
'stop_loss': 0.06, # 6% stop loss
|
||||
'take_profit': 0.05, # 5% take profit
|
||||
'train_ratio': 0.7, # 70% train / 30% test
|
||||
'trend_window': 0, # Disabled SMA filter
|
||||
'use_funding_filter': True, # Enabled Funding filter
|
||||
'funding_threshold': 0.005 # 0.005% threshold (Proven profitable)
|
||||
},
|
||||
grid_params={
|
||||
'horizon': [72, 96, 120],
|
||||
'stop_loss': [0.04, 0.06, 0.08]
|
||||
'horizon': [84, 96, 102, 108, 120],
|
||||
'z_entry_threshold': [0.8, 1.0, 1.2],
|
||||
'stop_loss': [0.04, 0.06, 0.08],
|
||||
'funding_threshold': [0.005, 0.01, 0.02]
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,21 +28,39 @@ class RegimeReversionStrategy(BaseStrategy):
|
||||
- Eliminates look-ahead bias for realistic backtest results
|
||||
"""
|
||||
|
||||
# Optimal parameters from walk-forward research (2025-10 to 2025-12)
|
||||
# Research: research/horizon_optimization_results.csv
|
||||
OPTIMAL_HORIZON = 102 # 4.25 days - best Net PnL (+232%)
|
||||
OPTIMAL_Z_WINDOW = 24 # 24h rolling window for spread Z-score
|
||||
OPTIMAL_TRAIN_RATIO = 0.7 # 70% train / 30% test split
|
||||
OPTIMAL_PROFIT_TARGET = 0.005 # 0.5% profit threshold for target definition
|
||||
OPTIMAL_Z_ENTRY = 1.0 # Enter when |Z| > 1.0
|
||||
|
||||
def __init__(self,
|
||||
model_path: str = "data/regime_model.pkl",
|
||||
horizon: int = 96, # 4 Days based on research
|
||||
z_window: int = 24,
|
||||
stop_loss: float = 0.06, # 6% to survive 2% avg MAE
|
||||
take_profit: float = 0.05, # Swing target
|
||||
train_ratio: float = 0.7 # Walk-forward: train on first 70%
|
||||
horizon: int = OPTIMAL_HORIZON,
|
||||
z_window: int = OPTIMAL_Z_WINDOW,
|
||||
z_entry_threshold: float = OPTIMAL_Z_ENTRY,
|
||||
profit_target: float = OPTIMAL_PROFIT_TARGET,
|
||||
stop_loss: float = 0.06, # 6% - accommodates 1.95% avg MAE
|
||||
take_profit: float = 0.05, # 5% swing target
|
||||
train_ratio: float = OPTIMAL_TRAIN_RATIO,
|
||||
trend_window: int = 0, # Disable SMA filter
|
||||
use_funding_filter: bool = True, # Enable Funding Rate filter
|
||||
funding_threshold: float = 0.005 # Tightened to 0.005%
|
||||
):
|
||||
super().__init__()
|
||||
self.model_path = model_path
|
||||
self.horizon = horizon
|
||||
self.z_window = z_window
|
||||
self.z_entry_threshold = z_entry_threshold
|
||||
self.profit_target = profit_target
|
||||
self.stop_loss = stop_loss
|
||||
self.take_profit = take_profit
|
||||
self.train_ratio = train_ratio
|
||||
self.trend_window = trend_window
|
||||
self.use_funding_filter = use_funding_filter
|
||||
self.funding_threshold = funding_threshold
|
||||
|
||||
# Default Strategy Config
|
||||
self.default_market_type = MarketType.PERPETUAL
|
||||
@@ -68,7 +86,8 @@ class RegimeReversionStrategy(BaseStrategy):
|
||||
try:
|
||||
# Load BTC data (Context) - Must match the timeframe of the backtest
|
||||
# Research was done on 1h candles, so strategy should be run on 1h
|
||||
df_btc = self.dm.load_data("okx", "BTC-USDT", "1h", MarketType.SPOT)
|
||||
# Use PERPETUAL data to match the trading instrument (ETH Perp)
|
||||
df_btc = self.dm.load_data("okx", "BTC-USDT", "1h", MarketType.PERPETUAL)
|
||||
|
||||
# Align BTC to ETH (close)
|
||||
df_btc = df_btc.reindex(close.index, method='ffill')
|
||||
@@ -141,11 +160,76 @@ class RegimeReversionStrategy(BaseStrategy):
|
||||
probs = self.model.predict_proba(X_test)[:, 1]
|
||||
|
||||
# 8. Generate Entry Signals (TEST period only)
|
||||
# If Z > 1 (Spread High, ETH Expensive) -> Short ETH
|
||||
# If Z < -1 (Spread Low, ETH Cheap) -> Long ETH
|
||||
# If Z > threshold (Spread High, ETH Expensive) -> Short ETH
|
||||
# If Z < -threshold (Spread Low, ETH Cheap) -> Long ETH
|
||||
z_thresh = self.z_entry_threshold
|
||||
|
||||
short_signal_test = (probs > 0.5) & (test_features['z_score'].values > 1.0)
|
||||
long_signal_test = (probs > 0.5) & (test_features['z_score'].values < -1.0)
|
||||
short_signal_test = (probs > 0.5) & (test_features['z_score'].values > z_thresh)
|
||||
long_signal_test = (probs > 0.5) & (test_features['z_score'].values < -z_thresh)
|
||||
|
||||
# 8b. Apply Trend Filter (Macro Regime)
|
||||
# Rule: Long only if BTC > SMA (Bull), Short only if BTC < SMA (Bear)
|
||||
if self.trend_window > 0:
|
||||
# Calculate SMA on full BTC history first
|
||||
btc_sma = btc_close.rolling(window=self.trend_window).mean()
|
||||
|
||||
# Align with test period
|
||||
test_btc_close = btc_close.reindex(test_features.index)
|
||||
test_btc_sma = btc_sma.reindex(test_features.index)
|
||||
|
||||
# Define Regimes
|
||||
is_bull = (test_btc_close > test_btc_sma).values
|
||||
is_bear = (test_btc_close < test_btc_sma).values
|
||||
|
||||
# Apply Filter
|
||||
long_signal_test = long_signal_test & is_bull
|
||||
short_signal_test = short_signal_test & is_bear
|
||||
|
||||
# 8c. Apply Funding Rate Filter
|
||||
# Rule: If Funding > Threshold (Greedy) -> No Longs.
|
||||
# If Funding < -Threshold (Fearful) -> No Shorts.
|
||||
if self.use_funding_filter and 'btc_funding' in test_features.columns:
|
||||
funding = test_features['btc_funding'].values
|
||||
thresh = self.funding_threshold
|
||||
|
||||
# Greedy Market (High Positive Funding) -> Risk of Long Squeeze -> Block Longs
|
||||
# (Or implies trend is up? Actually for Mean Reversion, high funding often marks tops)
|
||||
# We block Longs because we don't want to buy into an overheated market?
|
||||
# Actually, "Greedy" means Longs are paying Shorts.
|
||||
# If we Long, we pay funding.
|
||||
# If we Short, we receive funding.
|
||||
# So High Funding = Good for Shorts (receive yield + reversion).
|
||||
# Bad for Longs (pay yield + likely top).
|
||||
|
||||
is_overheated = funding > thresh
|
||||
is_oversold = funding < -thresh
|
||||
|
||||
# Block Longs if Overheated
|
||||
long_signal_test = long_signal_test & (~is_overheated)
|
||||
|
||||
# Block Shorts if Oversold (Negative Funding) -> Risk of Short Squeeze
|
||||
short_signal_test = short_signal_test & (~is_oversold)
|
||||
|
||||
n_blocked_long = (is_overheated & (probs > 0.5) & (test_features['z_score'].values < -z_thresh)).sum()
|
||||
n_blocked_short = (is_oversold & (probs > 0.5) & (test_features['z_score'].values > z_thresh)).sum()
|
||||
|
||||
if n_blocked_long > 0 or n_blocked_short > 0:
|
||||
logger.info(f"Funding Filter: Blocked {n_blocked_long} Longs, {n_blocked_short} Shorts")
|
||||
|
||||
# 9. Calculate Position Sizing (Probability-Based)
|
||||
# Base size = 1.0 (100% of equity)
|
||||
# Scale: 1.0 + (Prob - 0.5) * 2
|
||||
# Example: Prob=0.6 -> Size=1.2, Prob=0.8 -> Size=1.6
|
||||
|
||||
# Align probabilities to close index
|
||||
probs_series = pd.Series(0.0, index=test_features.index)
|
||||
probs_series[:] = probs
|
||||
probs_aligned = probs_series.reindex(close.index, fill_value=0.0)
|
||||
|
||||
# Calculate dynamic size
|
||||
dynamic_size = 1.0 + (probs_aligned - 0.5) * 2.0
|
||||
# Cap leverage between 1x and 2x
|
||||
size = dynamic_size.clip(lower=1.0, upper=2.0)
|
||||
|
||||
# Create full-length signal series (False for training period)
|
||||
long_entries = pd.Series(False, index=close.index)
|
||||
@@ -171,7 +255,7 @@ class RegimeReversionStrategy(BaseStrategy):
|
||||
n_short = short_entries.sum()
|
||||
logger.info(f"Generated {n_long} long signals, {n_short} short signals (test period only)")
|
||||
|
||||
return long_entries, long_exits, short_entries, short_exits
|
||||
return long_entries, long_exits, short_entries, short_exits, size
|
||||
|
||||
def prepare_features(self, df_btc, df_eth, cq_df=None):
|
||||
"""Replicate research feature engineering"""
|
||||
@@ -236,19 +320,20 @@ class RegimeReversionStrategy(BaseStrategy):
|
||||
Args:
|
||||
train_features: DataFrame containing features for training period only
|
||||
"""
|
||||
threshold = 0.005
|
||||
threshold = self.profit_target
|
||||
horizon = self.horizon
|
||||
z_thresh = self.z_entry_threshold
|
||||
|
||||
# Define targets using ONLY training data
|
||||
# For Short Spread (Z > 1): Did spread drop below target within horizon?
|
||||
# For Short Spread (Z > threshold): Did spread drop below target within horizon?
|
||||
future_min = train_features['spread'].rolling(window=horizon).min().shift(-horizon)
|
||||
target_short = train_features['spread'] * (1 - threshold)
|
||||
success_short = (train_features['z_score'] > 1.0) & (future_min < target_short)
|
||||
success_short = (train_features['z_score'] > z_thresh) & (future_min < target_short)
|
||||
|
||||
# For Long Spread (Z < -1): Did spread rise above target within horizon?
|
||||
# For Long Spread (Z < -threshold): Did spread rise above target within horizon?
|
||||
future_max = train_features['spread'].rolling(window=horizon).max().shift(-horizon)
|
||||
target_long = train_features['spread'] * (1 + threshold)
|
||||
success_long = (train_features['z_score'] < -1.0) & (future_max > target_long)
|
||||
success_long = (train_features['z_score'] < -z_thresh) & (future_max > target_long)
|
||||
|
||||
targets = np.select([success_short, success_long], [1, 1], default=0)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user