feat: Add Multi-Pair Divergence Live Trading Module
- Introduced a new module for live trading based on the Multi-Pair Divergence Strategy. - Implemented configuration classes for OKX API and multi-pair settings. - Developed data feed functionality to fetch real-time OHLCV and funding data for multiple assets. - Created a trading bot orchestrator to manage trading cycles, including entry and exit signals based on ML model predictions. - Added comprehensive logging and error handling for robust operation. - Included a README with setup instructions and usage guidelines for the new module.
This commit is contained in:
336
live_trading/multi_pair/data_feed.py
Normal file
336
live_trading/multi_pair/data_feed.py
Normal file
@@ -0,0 +1,336 @@
|
||||
"""
|
||||
Multi-Pair Data Feed for Live Trading.
|
||||
|
||||
Fetches real-time OHLCV and funding data for all assets in the universe.
|
||||
"""
|
||||
import logging
|
||||
from itertools import combinations
|
||||
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
import ta
|
||||
|
||||
from live_trading.okx_client import OKXClient
|
||||
from .config import MultiPairLiveConfig, PathConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TradingPair:
|
||||
"""
|
||||
Represents a tradeable pair for spread analysis.
|
||||
|
||||
Attributes:
|
||||
base_asset: First asset symbol (e.g., ETH/USDT:USDT)
|
||||
quote_asset: Second asset symbol (e.g., BTC/USDT:USDT)
|
||||
pair_id: Unique identifier
|
||||
"""
|
||||
def __init__(self, base_asset: str, quote_asset: str):
|
||||
self.base_asset = base_asset
|
||||
self.quote_asset = quote_asset
|
||||
self.pair_id = f"{base_asset}__{quote_asset}"
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Human-readable pair name."""
|
||||
base = self.base_asset.split("/")[0]
|
||||
quote = self.quote_asset.split("/")[0]
|
||||
return f"{base}/{quote}"
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.pair_id)
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, TradingPair):
|
||||
return False
|
||||
return self.pair_id == other.pair_id
|
||||
|
||||
|
||||
class MultiPairDataFeed:
|
||||
"""
|
||||
Real-time data feed for multi-pair strategy.
|
||||
|
||||
Fetches OHLCV data for all assets and calculates spread features
|
||||
for all pair combinations.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
okx_client: OKXClient,
|
||||
config: MultiPairLiveConfig,
|
||||
path_config: PathConfig
|
||||
):
|
||||
self.client = okx_client
|
||||
self.config = config
|
||||
self.paths = path_config
|
||||
|
||||
# Cache for asset data
|
||||
self._asset_data: dict[str, pd.DataFrame] = {}
|
||||
self._funding_rates: dict[str, float] = {}
|
||||
self._pairs: list[TradingPair] = []
|
||||
|
||||
# Generate pairs
|
||||
self._generate_pairs()
|
||||
|
||||
def _generate_pairs(self) -> None:
|
||||
"""Generate all unique pairs from asset universe."""
|
||||
self._pairs = []
|
||||
for base, quote in combinations(self.config.assets, 2):
|
||||
pair = TradingPair(base_asset=base, quote_asset=quote)
|
||||
self._pairs.append(pair)
|
||||
|
||||
logger.info("Generated %d pairs from %d assets",
|
||||
len(self._pairs), len(self.config.assets))
|
||||
|
||||
@property
|
||||
def pairs(self) -> list[TradingPair]:
|
||||
"""Get list of trading pairs."""
|
||||
return self._pairs
|
||||
|
||||
def fetch_all_ohlcv(self) -> dict[str, pd.DataFrame]:
|
||||
"""
|
||||
Fetch OHLCV data for all assets.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping symbol to OHLCV DataFrame
|
||||
"""
|
||||
self._asset_data = {}
|
||||
|
||||
for symbol in self.config.assets:
|
||||
try:
|
||||
ohlcv = self.client.fetch_ohlcv(
|
||||
symbol,
|
||||
self.config.timeframe,
|
||||
self.config.candles_to_fetch
|
||||
)
|
||||
df = self._ohlcv_to_dataframe(ohlcv)
|
||||
|
||||
if len(df) >= 200:
|
||||
self._asset_data[symbol] = df
|
||||
logger.debug("Fetched %s: %d candles", symbol, len(df))
|
||||
else:
|
||||
logger.warning("Skipping %s: insufficient data (%d)",
|
||||
symbol, len(df))
|
||||
except Exception as e:
|
||||
logger.error("Error fetching %s: %s", symbol, e)
|
||||
|
||||
logger.info("Fetched data for %d/%d assets",
|
||||
len(self._asset_data), len(self.config.assets))
|
||||
return self._asset_data
|
||||
|
||||
def _ohlcv_to_dataframe(self, ohlcv: list) -> pd.DataFrame:
|
||||
"""Convert OHLCV list to DataFrame."""
|
||||
df = pd.DataFrame(
|
||||
ohlcv,
|
||||
columns=['timestamp', 'open', 'high', 'low', 'close', 'volume']
|
||||
)
|
||||
df['timestamp'] = pd.to_datetime(df['timestamp'], unit='ms', utc=True)
|
||||
df.set_index('timestamp', inplace=True)
|
||||
return df
|
||||
|
||||
def fetch_all_funding_rates(self) -> dict[str, float]:
|
||||
"""
|
||||
Fetch current funding rates for all assets.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping symbol to funding rate
|
||||
"""
|
||||
self._funding_rates = {}
|
||||
|
||||
for symbol in self.config.assets:
|
||||
try:
|
||||
rate = self.client.get_funding_rate(symbol)
|
||||
self._funding_rates[symbol] = rate
|
||||
except Exception as e:
|
||||
logger.warning("Could not get funding for %s: %s", symbol, e)
|
||||
self._funding_rates[symbol] = 0.0
|
||||
|
||||
return self._funding_rates
|
||||
|
||||
def calculate_pair_features(
|
||||
self,
|
||||
pair: TradingPair
|
||||
) -> pd.DataFrame | None:
|
||||
"""
|
||||
Calculate features for a single pair.
|
||||
|
||||
Args:
|
||||
pair: Trading pair
|
||||
|
||||
Returns:
|
||||
DataFrame with features, or None if insufficient data
|
||||
"""
|
||||
base = pair.base_asset
|
||||
quote = pair.quote_asset
|
||||
|
||||
if base not in self._asset_data or quote not in self._asset_data:
|
||||
return None
|
||||
|
||||
df_base = self._asset_data[base]
|
||||
df_quote = self._asset_data[quote]
|
||||
|
||||
# Align indices
|
||||
common_idx = df_base.index.intersection(df_quote.index)
|
||||
if len(common_idx) < 200:
|
||||
return None
|
||||
|
||||
df_a = df_base.loc[common_idx]
|
||||
df_b = df_quote.loc[common_idx]
|
||||
|
||||
# Calculate spread (base / quote)
|
||||
spread = df_a['close'] / df_b['close']
|
||||
|
||||
# Z-Score
|
||||
z_window = self.config.z_window
|
||||
rolling_mean = spread.rolling(window=z_window).mean()
|
||||
rolling_std = spread.rolling(window=z_window).std()
|
||||
z_score = (spread - rolling_mean) / rolling_std
|
||||
|
||||
# Spread Technicals
|
||||
spread_rsi = ta.momentum.RSIIndicator(spread, window=14).rsi()
|
||||
spread_roc = spread.pct_change(periods=5) * 100
|
||||
spread_change_1h = spread.pct_change(periods=1)
|
||||
|
||||
# Volume Analysis
|
||||
vol_ratio = df_a['volume'] / (df_b['volume'] + 1e-10)
|
||||
vol_ratio_ma = vol_ratio.rolling(window=12).mean()
|
||||
vol_ratio_rel = vol_ratio / (vol_ratio_ma + 1e-10)
|
||||
|
||||
# Volatility
|
||||
ret_a = df_a['close'].pct_change()
|
||||
ret_b = df_b['close'].pct_change()
|
||||
vol_a = ret_a.rolling(window=z_window).std()
|
||||
vol_b = ret_b.rolling(window=z_window).std()
|
||||
vol_spread_ratio = vol_a / (vol_b + 1e-10)
|
||||
|
||||
# Realized Volatility
|
||||
realized_vol_a = ret_a.rolling(window=24).std()
|
||||
realized_vol_b = ret_b.rolling(window=24).std()
|
||||
|
||||
# ATR (Average True Range)
|
||||
high_a, low_a, close_a = df_a['high'], df_a['low'], df_a['close']
|
||||
|
||||
tr_a = pd.concat([
|
||||
high_a - low_a,
|
||||
(high_a - close_a.shift(1)).abs(),
|
||||
(low_a - close_a.shift(1)).abs()
|
||||
], axis=1).max(axis=1)
|
||||
atr_a = tr_a.rolling(window=self.config.atr_period).mean()
|
||||
atr_pct_a = atr_a / close_a
|
||||
|
||||
# Build feature DataFrame
|
||||
features = pd.DataFrame(index=common_idx)
|
||||
features['pair_id'] = pair.pair_id
|
||||
features['base_asset'] = base
|
||||
features['quote_asset'] = quote
|
||||
|
||||
# Price data
|
||||
features['spread'] = spread
|
||||
features['base_close'] = df_a['close']
|
||||
features['quote_close'] = df_b['close']
|
||||
features['base_volume'] = df_a['volume']
|
||||
|
||||
# Core Features
|
||||
features['z_score'] = z_score
|
||||
features['spread_rsi'] = spread_rsi
|
||||
features['spread_roc'] = spread_roc
|
||||
features['spread_change_1h'] = spread_change_1h
|
||||
features['vol_ratio'] = vol_ratio
|
||||
features['vol_ratio_rel'] = vol_ratio_rel
|
||||
features['vol_diff_ratio'] = vol_spread_ratio
|
||||
|
||||
# Volatility
|
||||
features['realized_vol_base'] = realized_vol_a
|
||||
features['realized_vol_quote'] = realized_vol_b
|
||||
features['realized_vol_avg'] = (realized_vol_a + realized_vol_b) / 2
|
||||
|
||||
# ATR
|
||||
features['atr_base'] = atr_a
|
||||
features['atr_pct_base'] = atr_pct_a
|
||||
|
||||
# Pair encoding
|
||||
assets = self.config.assets
|
||||
features['base_idx'] = assets.index(base) if base in assets else -1
|
||||
features['quote_idx'] = assets.index(quote) if quote in assets else -1
|
||||
|
||||
# Funding rates
|
||||
base_funding = self._funding_rates.get(base, 0.0)
|
||||
quote_funding = self._funding_rates.get(quote, 0.0)
|
||||
features['base_funding'] = base_funding
|
||||
features['quote_funding'] = quote_funding
|
||||
features['funding_diff'] = base_funding - quote_funding
|
||||
features['funding_avg'] = (base_funding + quote_funding) / 2
|
||||
|
||||
# Drop NaN rows in core features
|
||||
core_cols = [
|
||||
'z_score', 'spread_rsi', 'spread_roc', 'spread_change_1h',
|
||||
'vol_ratio', 'vol_ratio_rel', 'vol_diff_ratio',
|
||||
'realized_vol_base', 'atr_base', 'atr_pct_base'
|
||||
]
|
||||
features = features.dropna(subset=core_cols)
|
||||
|
||||
return features
|
||||
|
||||
def calculate_all_pair_features(self) -> dict[str, pd.DataFrame]:
|
||||
"""
|
||||
Calculate features for all pairs.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping pair_id to feature DataFrame
|
||||
"""
|
||||
all_features = {}
|
||||
|
||||
for pair in self._pairs:
|
||||
features = self.calculate_pair_features(pair)
|
||||
if features is not None and len(features) > 0:
|
||||
all_features[pair.pair_id] = features
|
||||
|
||||
logger.info("Calculated features for %d/%d pairs",
|
||||
len(all_features), len(self._pairs))
|
||||
|
||||
return all_features
|
||||
|
||||
def get_latest_data(self) -> dict[str, pd.DataFrame] | None:
|
||||
"""
|
||||
Fetch and process latest market data for all pairs.
|
||||
|
||||
Returns:
|
||||
Dictionary of pair features or None on error
|
||||
"""
|
||||
try:
|
||||
# Fetch OHLCV for all assets
|
||||
self.fetch_all_ohlcv()
|
||||
|
||||
if len(self._asset_data) < 2:
|
||||
logger.warning("Insufficient assets fetched")
|
||||
return None
|
||||
|
||||
# Fetch funding rates
|
||||
self.fetch_all_funding_rates()
|
||||
|
||||
# Calculate features for all pairs
|
||||
pair_features = self.calculate_all_pair_features()
|
||||
|
||||
if not pair_features:
|
||||
logger.warning("No pair features calculated")
|
||||
return None
|
||||
|
||||
logger.info("Processed %d pairs with valid features", len(pair_features))
|
||||
return pair_features
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error fetching market data: %s", e, exc_info=True)
|
||||
return None
|
||||
|
||||
def get_pair_by_id(self, pair_id: str) -> TradingPair | None:
|
||||
"""Get pair object by ID."""
|
||||
for pair in self._pairs:
|
||||
if pair.pair_id == pair_id:
|
||||
return pair
|
||||
return None
|
||||
|
||||
def get_current_price(self, symbol: str) -> float | None:
|
||||
"""Get current price for a symbol."""
|
||||
if symbol in self._asset_data:
|
||||
return self._asset_data[symbol]['close'].iloc[-1]
|
||||
return None
|
||||
Reference in New Issue
Block a user