- 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.
337 lines
11 KiB
Python
337 lines
11 KiB
Python
"""
|
|
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
|