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:
2026-01-14 09:46:51 +08:00
parent 10bb371054
commit 1e4cb87da3
8 changed files with 617 additions and 111 deletions

View File

@@ -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)