- Added optional logger parameter to various classes including `BaseDataCollector`, `CollectorManager`, `RealTimeCandleProcessor`, and `BatchCandleProcessor` to support conditional logging. - Implemented error-only logging mode, allowing components to log only error and critical messages when specified. - Updated logging calls to utilize new helper methods for improved readability and maintainability. - Enhanced documentation to include details on the new logging system and its usage across components. - Ensured that child components inherit the logger from their parent components for consistent logging behavior.
739 lines
31 KiB
Python
739 lines
31 KiB
Python
"""
|
|
OKX-specific data processing utilities.
|
|
|
|
This module provides OKX-specific data validation, transformation, and processing
|
|
utilities that extend the common data processing framework.
|
|
"""
|
|
|
|
import re
|
|
from datetime import datetime, timezone
|
|
from decimal import Decimal
|
|
from typing import Dict, List, Optional, Any, Union, Tuple
|
|
from enum import Enum
|
|
|
|
from ...base_collector import DataType, MarketDataPoint
|
|
from ...common import (
|
|
DataValidationResult,
|
|
StandardizedTrade,
|
|
OHLCVCandle,
|
|
CandleProcessingConfig,
|
|
RealTimeCandleProcessor,
|
|
BaseDataValidator,
|
|
ValidationResult,
|
|
BaseDataTransformer,
|
|
UnifiedDataTransformer,
|
|
create_standardized_trade
|
|
)
|
|
|
|
|
|
class OKXMessageType(Enum):
|
|
"""OKX WebSocket message types."""
|
|
DATA = "data"
|
|
SUBSCRIPTION_SUCCESS = "subscribe"
|
|
UNSUBSCRIPTION_SUCCESS = "unsubscribe"
|
|
ERROR = "error"
|
|
PING = "ping"
|
|
PONG = "pong"
|
|
|
|
|
|
class OKXTradeField(Enum):
|
|
"""OKX trade data field names."""
|
|
INST_ID = "instId"
|
|
TRADE_ID = "tradeId"
|
|
PRICE = "px"
|
|
SIZE = "sz"
|
|
SIDE = "side"
|
|
TIMESTAMP = "ts"
|
|
|
|
|
|
class OKXOrderbookField(Enum):
|
|
"""OKX orderbook data field names."""
|
|
INST_ID = "instId"
|
|
ASKS = "asks"
|
|
BIDS = "bids"
|
|
TIMESTAMP = "ts"
|
|
SEQID = "seqId"
|
|
|
|
|
|
class OKXTickerField(Enum):
|
|
"""OKX ticker data field names."""
|
|
INST_ID = "instId"
|
|
LAST = "last"
|
|
LAST_SZ = "lastSz"
|
|
ASK_PX = "askPx"
|
|
ASK_SZ = "askSz"
|
|
BID_PX = "bidPx"
|
|
BID_SZ = "bidSz"
|
|
OPEN_24H = "open24h"
|
|
HIGH_24H = "high24h"
|
|
LOW_24H = "low24h"
|
|
VOL_24H = "vol24h"
|
|
VOL_CNY_24H = "volCcy24h"
|
|
TIMESTAMP = "ts"
|
|
|
|
|
|
class OKXDataValidator(BaseDataValidator):
|
|
"""
|
|
OKX-specific data validator extending the common base validator.
|
|
|
|
This class provides OKX-specific validation for message formats,
|
|
symbol patterns, and data structures.
|
|
"""
|
|
|
|
def __init__(self, component_name: str = "okx_data_validator", logger = None):
|
|
"""Initialize OKX data validator."""
|
|
super().__init__("okx", component_name, logger)
|
|
|
|
# OKX-specific patterns
|
|
self._symbol_pattern = re.compile(r'^[A-Z0-9]+-[A-Z0-9]+$') # BTC-USDT, ETH-USDC
|
|
self._trade_id_pattern = re.compile(r'^\d+$') # OKX uses numeric trade IDs
|
|
|
|
# OKX-specific valid channels
|
|
self._valid_channels = {
|
|
'trades', 'books5', 'books50', 'books-l2-tbt', 'tickers',
|
|
'candle1m', 'candle5m', 'candle15m', 'candle1H', 'candle4H', 'candle1D'
|
|
}
|
|
|
|
if self.logger:
|
|
self.logger.debug("Initialized OKX data validator")
|
|
|
|
def validate_symbol_format(self, symbol: str) -> ValidationResult:
|
|
"""Validate OKX symbol format (e.g., BTC-USDT)."""
|
|
errors = []
|
|
warnings = []
|
|
|
|
if not isinstance(symbol, str):
|
|
errors.append(f"Symbol must be string, got {type(symbol)}")
|
|
return ValidationResult(False, errors, warnings)
|
|
|
|
if not self._symbol_pattern.match(symbol):
|
|
errors.append(f"Invalid OKX symbol format: {symbol}. Expected format: BASE-QUOTE (e.g., BTC-USDT)")
|
|
|
|
return ValidationResult(len(errors) == 0, errors, warnings)
|
|
|
|
def validate_websocket_message(self, message: Dict[str, Any]) -> DataValidationResult:
|
|
"""Validate OKX WebSocket message structure."""
|
|
errors = []
|
|
warnings = []
|
|
|
|
try:
|
|
# Check basic message structure
|
|
if not isinstance(message, dict):
|
|
errors.append(f"Message must be a dictionary, got {type(message)}")
|
|
return DataValidationResult(False, errors, warnings)
|
|
|
|
# Identify message type
|
|
message_type = self._identify_message_type(message)
|
|
|
|
if message_type == OKXMessageType.DATA:
|
|
return self._validate_data_message(message)
|
|
elif message_type in [OKXMessageType.SUBSCRIPTION_SUCCESS, OKXMessageType.UNSUBSCRIPTION_SUCCESS]:
|
|
return self._validate_subscription_message(message)
|
|
elif message_type == OKXMessageType.ERROR:
|
|
return self._validate_error_message(message)
|
|
elif message_type in [OKXMessageType.PING, OKXMessageType.PONG]:
|
|
return DataValidationResult(True, [], []) # Ping/pong are always valid
|
|
else:
|
|
warnings.append("Unknown message type, basic validation only")
|
|
return DataValidationResult(True, [], warnings)
|
|
|
|
except Exception as e:
|
|
errors.append(f"Exception during message validation: {str(e)}")
|
|
return DataValidationResult(False, errors, warnings)
|
|
|
|
def validate_trade_data(self, data: Dict[str, Any], symbol: Optional[str] = None) -> DataValidationResult:
|
|
"""Validate OKX trade data structure and values."""
|
|
errors = []
|
|
warnings = []
|
|
sanitized_data = data.copy()
|
|
|
|
try:
|
|
# Check required fields
|
|
required_fields = [field.value for field in OKXTradeField]
|
|
missing_fields = []
|
|
for field in required_fields:
|
|
if field not in data:
|
|
missing_fields.append(field)
|
|
|
|
if missing_fields:
|
|
errors.extend([f"Missing required trade field: {field}" for field in missing_fields])
|
|
return DataValidationResult(False, errors, warnings)
|
|
|
|
# Validate individual fields using base validator methods
|
|
symbol_result = self.validate_symbol_format(data[OKXTradeField.INST_ID.value])
|
|
if not symbol_result.is_valid:
|
|
errors.extend(symbol_result.errors)
|
|
|
|
if symbol:
|
|
match_result = self.validate_symbol_match(data[OKXTradeField.INST_ID.value], symbol)
|
|
warnings.extend(match_result.warnings)
|
|
|
|
trade_id_result = self.validate_trade_id(data[OKXTradeField.TRADE_ID.value])
|
|
if not trade_id_result.is_valid:
|
|
errors.extend(trade_id_result.errors)
|
|
warnings.extend(trade_id_result.warnings)
|
|
|
|
price_result = self.validate_price(data[OKXTradeField.PRICE.value])
|
|
if not price_result.is_valid:
|
|
errors.extend(price_result.errors)
|
|
else:
|
|
sanitized_data[OKXTradeField.PRICE.value] = str(price_result.sanitized_data)
|
|
warnings.extend(price_result.warnings)
|
|
|
|
size_result = self.validate_size(data[OKXTradeField.SIZE.value])
|
|
if not size_result.is_valid:
|
|
errors.extend(size_result.errors)
|
|
else:
|
|
sanitized_data[OKXTradeField.SIZE.value] = str(size_result.sanitized_data)
|
|
warnings.extend(size_result.warnings)
|
|
|
|
side_result = self.validate_trade_side(data[OKXTradeField.SIDE.value])
|
|
if not side_result.is_valid:
|
|
errors.extend(side_result.errors)
|
|
|
|
timestamp_result = self.validate_timestamp(data[OKXTradeField.TIMESTAMP.value])
|
|
if not timestamp_result.is_valid:
|
|
errors.extend(timestamp_result.errors)
|
|
warnings.extend(timestamp_result.warnings)
|
|
|
|
return DataValidationResult(len(errors) == 0, errors, warnings, sanitized_data)
|
|
|
|
except Exception as e:
|
|
errors.append(f"Exception during trade validation: {str(e)}")
|
|
return DataValidationResult(False, errors, warnings)
|
|
|
|
def validate_orderbook_data(self, data: Dict[str, Any], symbol: Optional[str] = None) -> DataValidationResult:
|
|
"""Validate OKX orderbook data structure and values."""
|
|
errors = []
|
|
warnings = []
|
|
sanitized_data = data.copy()
|
|
|
|
try:
|
|
# Check required fields
|
|
required_fields = [OKXOrderbookField.INST_ID.value, OKXOrderbookField.ASKS.value,
|
|
OKXOrderbookField.BIDS.value, OKXOrderbookField.TIMESTAMP.value]
|
|
missing_fields = []
|
|
for field in required_fields:
|
|
if field not in data:
|
|
missing_fields.append(field)
|
|
|
|
if missing_fields:
|
|
errors.extend([f"Missing required orderbook field: {field}" for field in missing_fields])
|
|
return DataValidationResult(False, errors, warnings)
|
|
|
|
# Validate symbol
|
|
symbol_result = self.validate_symbol_format(data[OKXOrderbookField.INST_ID.value])
|
|
if not symbol_result.is_valid:
|
|
errors.extend(symbol_result.errors)
|
|
|
|
if symbol:
|
|
match_result = self.validate_symbol_match(data[OKXOrderbookField.INST_ID.value], symbol)
|
|
warnings.extend(match_result.warnings)
|
|
|
|
# Validate timestamp
|
|
timestamp_result = self.validate_timestamp(data[OKXOrderbookField.TIMESTAMP.value])
|
|
if not timestamp_result.is_valid:
|
|
errors.extend(timestamp_result.errors)
|
|
warnings.extend(timestamp_result.warnings)
|
|
|
|
# Validate asks and bids using base validator
|
|
asks_result = self.validate_orderbook_side(data[OKXOrderbookField.ASKS.value], "asks")
|
|
if not asks_result.is_valid:
|
|
errors.extend(asks_result.errors)
|
|
else:
|
|
sanitized_data[OKXOrderbookField.ASKS.value] = asks_result.sanitized_data
|
|
warnings.extend(asks_result.warnings)
|
|
|
|
bids_result = self.validate_orderbook_side(data[OKXOrderbookField.BIDS.value], "bids")
|
|
if not bids_result.is_valid:
|
|
errors.extend(bids_result.errors)
|
|
else:
|
|
sanitized_data[OKXOrderbookField.BIDS.value] = bids_result.sanitized_data
|
|
warnings.extend(bids_result.warnings)
|
|
|
|
# Validate sequence ID if present
|
|
if OKXOrderbookField.SEQID.value in data:
|
|
seq_id = data[OKXOrderbookField.SEQID.value]
|
|
if not isinstance(seq_id, (int, str)) or (isinstance(seq_id, str) and not seq_id.isdigit()):
|
|
errors.append("Invalid sequence ID format")
|
|
|
|
return DataValidationResult(len(errors) == 0, errors, warnings, sanitized_data)
|
|
|
|
except Exception as e:
|
|
errors.append(f"Exception during orderbook validation: {str(e)}")
|
|
return DataValidationResult(False, errors, warnings)
|
|
|
|
def validate_ticker_data(self, data: Dict[str, Any], symbol: Optional[str] = None) -> DataValidationResult:
|
|
"""Validate OKX ticker data structure and values."""
|
|
errors = []
|
|
warnings = []
|
|
sanitized_data = data.copy()
|
|
|
|
try:
|
|
# Check required fields
|
|
required_fields = [OKXTickerField.INST_ID.value, OKXTickerField.LAST.value, OKXTickerField.TIMESTAMP.value]
|
|
missing_fields = []
|
|
for field in required_fields:
|
|
if field not in data:
|
|
missing_fields.append(field)
|
|
|
|
if missing_fields:
|
|
errors.extend([f"Missing required ticker field: {field}" for field in missing_fields])
|
|
return DataValidationResult(False, errors, warnings)
|
|
|
|
# Validate symbol
|
|
symbol_result = self.validate_symbol_format(data[OKXTickerField.INST_ID.value])
|
|
if not symbol_result.is_valid:
|
|
errors.extend(symbol_result.errors)
|
|
|
|
if symbol:
|
|
match_result = self.validate_symbol_match(data[OKXTickerField.INST_ID.value], symbol)
|
|
warnings.extend(match_result.warnings)
|
|
|
|
# Validate timestamp
|
|
timestamp_result = self.validate_timestamp(data[OKXTickerField.TIMESTAMP.value])
|
|
if not timestamp_result.is_valid:
|
|
errors.extend(timestamp_result.errors)
|
|
warnings.extend(timestamp_result.warnings)
|
|
|
|
# Validate price fields (optional fields)
|
|
price_fields = [OKXTickerField.LAST, OKXTickerField.ASK_PX, OKXTickerField.BID_PX,
|
|
OKXTickerField.OPEN_24H, OKXTickerField.HIGH_24H, OKXTickerField.LOW_24H]
|
|
|
|
for field in price_fields:
|
|
if field.value in data and data[field.value] not in [None, ""]:
|
|
price_result = self.validate_price(data[field.value])
|
|
if not price_result.is_valid:
|
|
errors.extend([f"{field.value}: {error}" for error in price_result.errors])
|
|
else:
|
|
sanitized_data[field.value] = str(price_result.sanitized_data)
|
|
warnings.extend([f"{field.value}: {warning}" for warning in price_result.warnings])
|
|
|
|
# Validate size fields (optional fields)
|
|
size_fields = [OKXTickerField.LAST_SZ, OKXTickerField.ASK_SZ, OKXTickerField.BID_SZ]
|
|
for field in size_fields:
|
|
if field.value in data and data[field.value] not in [None, ""]:
|
|
size_result = self.validate_size(data[field.value])
|
|
if not size_result.is_valid:
|
|
errors.extend([f"{field.value}: {error}" for error in size_result.errors])
|
|
else:
|
|
sanitized_data[field.value] = str(size_result.sanitized_data)
|
|
warnings.extend([f"{field.value}: {warning}" for warning in size_result.warnings])
|
|
|
|
# Validate volume fields (optional fields)
|
|
volume_fields = [OKXTickerField.VOL_24H, OKXTickerField.VOL_CNY_24H]
|
|
for field in volume_fields:
|
|
if field.value in data and data[field.value] not in [None, ""]:
|
|
volume_result = self.validate_volume(data[field.value])
|
|
if not volume_result.is_valid:
|
|
errors.extend([f"{field.value}: {error}" for error in volume_result.errors])
|
|
warnings.extend([f"{field.value}: {warning}" for warning in volume_result.warnings])
|
|
|
|
return DataValidationResult(len(errors) == 0, errors, warnings, sanitized_data)
|
|
|
|
except Exception as e:
|
|
errors.append(f"Exception during ticker validation: {str(e)}")
|
|
return DataValidationResult(False, errors, warnings)
|
|
|
|
# Private helper methods for OKX-specific validation
|
|
|
|
def _identify_message_type(self, message: Dict[str, Any]) -> OKXMessageType:
|
|
"""Identify the type of OKX WebSocket message."""
|
|
if 'event' in message:
|
|
event = message['event']
|
|
if event == 'subscribe':
|
|
return OKXMessageType.SUBSCRIPTION_SUCCESS
|
|
elif event == 'unsubscribe':
|
|
return OKXMessageType.UNSUBSCRIPTION_SUCCESS
|
|
elif event == 'error':
|
|
return OKXMessageType.ERROR
|
|
|
|
if 'data' in message and 'arg' in message:
|
|
return OKXMessageType.DATA
|
|
|
|
# Default to data type for unknown messages
|
|
return OKXMessageType.DATA
|
|
|
|
def _validate_data_message(self, message: Dict[str, Any]) -> DataValidationResult:
|
|
"""Validate OKX data message structure."""
|
|
errors = []
|
|
warnings = []
|
|
|
|
# Check required fields
|
|
if 'arg' not in message:
|
|
errors.append("Missing 'arg' field in data message")
|
|
if 'data' not in message:
|
|
errors.append("Missing 'data' field in data message")
|
|
|
|
if errors:
|
|
return DataValidationResult(False, errors, warnings)
|
|
|
|
# Validate arg structure
|
|
arg = message['arg']
|
|
if not isinstance(arg, dict):
|
|
errors.append("'arg' field must be a dictionary")
|
|
else:
|
|
if 'channel' not in arg:
|
|
errors.append("Missing 'channel' in arg")
|
|
elif arg['channel'] not in self._valid_channels:
|
|
warnings.append(f"Unknown channel: {arg['channel']}")
|
|
|
|
if 'instId' not in arg:
|
|
errors.append("Missing 'instId' in arg")
|
|
|
|
# Validate data structure
|
|
data = message['data']
|
|
if not isinstance(data, list):
|
|
errors.append("'data' field must be a list")
|
|
elif len(data) == 0:
|
|
warnings.append("Empty data array")
|
|
|
|
return DataValidationResult(len(errors) == 0, errors, warnings)
|
|
|
|
def _validate_subscription_message(self, message: Dict[str, Any]) -> DataValidationResult:
|
|
"""Validate subscription/unsubscription message."""
|
|
errors = []
|
|
warnings = []
|
|
|
|
if 'event' not in message:
|
|
errors.append("Missing 'event' field")
|
|
if 'arg' not in message:
|
|
errors.append("Missing 'arg' field")
|
|
|
|
return DataValidationResult(len(errors) == 0, errors, warnings)
|
|
|
|
def _validate_error_message(self, message: Dict[str, Any]) -> DataValidationResult:
|
|
"""Validate error message."""
|
|
errors = []
|
|
warnings = []
|
|
|
|
if 'event' not in message or message['event'] != 'error':
|
|
errors.append("Invalid error message structure")
|
|
|
|
if 'msg' in message:
|
|
warnings.append(f"OKX error: {message['msg']}")
|
|
|
|
return DataValidationResult(len(errors) == 0, errors, warnings)
|
|
|
|
|
|
class OKXDataTransformer(BaseDataTransformer):
|
|
"""
|
|
OKX-specific data transformer extending the common base transformer.
|
|
|
|
This class handles transformation of OKX data formats to standardized formats.
|
|
"""
|
|
|
|
def __init__(self, component_name: str = "okx_data_transformer", logger = None):
|
|
"""Initialize OKX data transformer."""
|
|
super().__init__("okx", component_name, logger)
|
|
|
|
def transform_trade_data(self, raw_data: Dict[str, Any], symbol: str) -> Optional[StandardizedTrade]:
|
|
"""Transform OKX trade data to standardized format."""
|
|
try:
|
|
return create_standardized_trade(
|
|
symbol=raw_data[OKXTradeField.INST_ID.value],
|
|
trade_id=raw_data[OKXTradeField.TRADE_ID.value],
|
|
price=raw_data[OKXTradeField.PRICE.value],
|
|
size=raw_data[OKXTradeField.SIZE.value],
|
|
side=raw_data[OKXTradeField.SIDE.value],
|
|
timestamp=raw_data[OKXTradeField.TIMESTAMP.value],
|
|
exchange="okx",
|
|
raw_data=raw_data,
|
|
is_milliseconds=True
|
|
)
|
|
except Exception as e:
|
|
if self.logger:
|
|
self.logger.error(f"{self.component_name}: Error transforming OKX trade data: {e}")
|
|
return None
|
|
|
|
def transform_orderbook_data(self, raw_data: Dict[str, Any], symbol: str) -> Optional[Dict[str, Any]]:
|
|
"""Transform OKX orderbook data to standardized format."""
|
|
try:
|
|
# Basic transformation - can be enhanced as needed
|
|
return {
|
|
'symbol': raw_data[OKXOrderbookField.INST_ID.value],
|
|
'asks': raw_data[OKXOrderbookField.ASKS.value],
|
|
'bids': raw_data[OKXOrderbookField.BIDS.value],
|
|
'timestamp': self.timestamp_to_datetime(raw_data[OKXOrderbookField.TIMESTAMP.value]),
|
|
'exchange': 'okx',
|
|
'raw_data': raw_data
|
|
}
|
|
except Exception as e:
|
|
if self.logger:
|
|
self.logger.error(f"{self.component_name}: Error transforming OKX orderbook data: {e}")
|
|
return None
|
|
|
|
def transform_ticker_data(self, raw_data: Dict[str, Any], symbol: str) -> Optional[Dict[str, Any]]:
|
|
"""Transform OKX ticker data to standardized format."""
|
|
try:
|
|
# Transform ticker data to standardized format
|
|
ticker_data = {
|
|
'symbol': raw_data[OKXTickerField.INST_ID.value],
|
|
'timestamp': self.timestamp_to_datetime(raw_data[OKXTickerField.TIMESTAMP.value]),
|
|
'exchange': 'okx',
|
|
'raw_data': raw_data
|
|
}
|
|
|
|
# Add available price fields
|
|
price_fields = {
|
|
'last': OKXTickerField.LAST.value,
|
|
'bid': OKXTickerField.BID_PX.value,
|
|
'ask': OKXTickerField.ASK_PX.value,
|
|
'open_24h': OKXTickerField.OPEN_24H.value,
|
|
'high_24h': OKXTickerField.HIGH_24H.value,
|
|
'low_24h': OKXTickerField.LOW_24H.value
|
|
}
|
|
|
|
for std_field, okx_field in price_fields.items():
|
|
if okx_field in raw_data and raw_data[okx_field] not in [None, ""]:
|
|
decimal_price = self.safe_decimal_conversion(raw_data[okx_field], std_field)
|
|
if decimal_price:
|
|
ticker_data[std_field] = decimal_price
|
|
|
|
# Add volume fields
|
|
if OKXTickerField.VOL_24H.value in raw_data:
|
|
volume = self.safe_decimal_conversion(raw_data[OKXTickerField.VOL_24H.value], 'volume_24h')
|
|
if volume:
|
|
ticker_data['volume_24h'] = volume
|
|
|
|
return ticker_data
|
|
|
|
except Exception as e:
|
|
if self.logger:
|
|
self.logger.error(f"{self.component_name}: Error transforming OKX ticker data: {e}")
|
|
return None
|
|
|
|
|
|
class OKXDataProcessor:
|
|
"""
|
|
Main OKX data processor using common utilities.
|
|
|
|
This class provides a simplified interface for OKX data processing,
|
|
leveraging the common validation, transformation, and aggregation utilities.
|
|
"""
|
|
|
|
def __init__(self,
|
|
symbol: str,
|
|
config: Optional[CandleProcessingConfig] = None,
|
|
component_name: str = "okx_data_processor",
|
|
logger = None):
|
|
"""
|
|
Initialize OKX data processor.
|
|
|
|
Args:
|
|
symbol: Trading symbol to process
|
|
config: Candle processing configuration
|
|
component_name: Name for logging
|
|
"""
|
|
self.symbol = symbol
|
|
self.component_name = component_name
|
|
self.logger = logger
|
|
|
|
# Core components using common utilities
|
|
self.validator = OKXDataValidator(f"{component_name}_validator", self.logger)
|
|
self.transformer = OKXDataTransformer(f"{component_name}_transformer", self.logger)
|
|
self.unified_transformer = UnifiedDataTransformer(self.transformer, f"{component_name}_unified", self.logger)
|
|
|
|
# Real-time candle processing using common utilities
|
|
self.config = config or CandleProcessingConfig()
|
|
self.candle_processor = RealTimeCandleProcessor(
|
|
symbol, "okx", self.config, f"{component_name}_candles", self.logger
|
|
)
|
|
|
|
# Callbacks
|
|
self.trade_callbacks: List[callable] = []
|
|
self.candle_callbacks: List[callable] = []
|
|
|
|
# Connect candle processor callbacks
|
|
self.candle_processor.add_candle_callback(self._emit_candle_to_callbacks)
|
|
|
|
if self.logger:
|
|
self.logger.info(f"{self.component_name}: Initialized OKX data processor for {symbol} with real-time candle processing")
|
|
|
|
def add_trade_callback(self, callback: callable) -> None:
|
|
"""Add callback for processed trades."""
|
|
self.trade_callbacks.append(callback)
|
|
|
|
def add_candle_callback(self, callback: callable) -> None:
|
|
"""Add callback for completed candles."""
|
|
self.candle_callbacks.append(callback)
|
|
|
|
def validate_and_process_message(self, message: Dict[str, Any], expected_symbol: Optional[str] = None) -> Tuple[bool, List[MarketDataPoint], List[str]]:
|
|
"""
|
|
Validate and process complete OKX WebSocket message.
|
|
|
|
This is the main entry point for real-time WebSocket data.
|
|
|
|
Args:
|
|
message: Complete WebSocket message from OKX
|
|
expected_symbol: Expected trading symbol for validation
|
|
|
|
Returns:
|
|
Tuple of (success, list of market data points, list of errors)
|
|
"""
|
|
try:
|
|
# First validate the message structure
|
|
validation_result = self.validator.validate_websocket_message(message)
|
|
|
|
if not validation_result.is_valid:
|
|
if self.logger:
|
|
self.logger.error(f"{self.component_name}: Message validation failed: {validation_result.errors}")
|
|
return False, [], validation_result.errors
|
|
|
|
# Log warnings if any
|
|
if validation_result.warnings:
|
|
if self.logger:
|
|
self.logger.warning(f"{self.component_name}: Message validation warnings: {validation_result.warnings}")
|
|
|
|
# Process data if it's a data message
|
|
if 'data' in message and 'arg' in message:
|
|
return self._process_data_message(message, expected_symbol)
|
|
|
|
# Non-data messages are considered successfully processed but return no data points
|
|
return True, [], []
|
|
|
|
except Exception as e:
|
|
error_msg = f"{self.component_name}: Exception during message validation and processing: {str(e)}"
|
|
if self.logger:
|
|
self.logger.error(error_msg)
|
|
return False, [], [error_msg]
|
|
|
|
def _process_data_message(self, message: Dict[str, Any], expected_symbol: Optional[str] = None) -> Tuple[bool, List[MarketDataPoint], List[str]]:
|
|
"""Process OKX data message and return market data points."""
|
|
errors = []
|
|
market_data_points = []
|
|
|
|
try:
|
|
arg = message['arg']
|
|
channel = arg['channel']
|
|
inst_id = arg['instId']
|
|
data_list = message['data']
|
|
|
|
# Determine data type from channel
|
|
data_type = self._channel_to_data_type(channel)
|
|
if not data_type:
|
|
errors.append(f"Unsupported channel: {channel}")
|
|
return False, [], errors
|
|
|
|
# Process each data item
|
|
for data_item in data_list:
|
|
try:
|
|
# Validate and transform based on channel type
|
|
if channel == 'trades':
|
|
validation_result = self.validator.validate_trade_data(data_item, expected_symbol)
|
|
elif channel in ['books5', 'books50', 'books-l2-tbt']:
|
|
validation_result = self.validator.validate_orderbook_data(data_item, expected_symbol)
|
|
elif channel == 'tickers':
|
|
validation_result = self.validator.validate_ticker_data(data_item, expected_symbol)
|
|
else:
|
|
errors.append(f"Unsupported channel for validation: {channel}")
|
|
continue
|
|
|
|
if not validation_result.is_valid:
|
|
errors.extend(validation_result.errors)
|
|
continue
|
|
|
|
if validation_result.warnings:
|
|
if self.logger:
|
|
self.logger.warning(f"{self.component_name}: Data validation warnings: {validation_result.warnings}")
|
|
|
|
# Create MarketDataPoint using sanitized data
|
|
sanitized_data = validation_result.sanitized_data or data_item
|
|
timestamp_ms = sanitized_data.get('ts')
|
|
if timestamp_ms:
|
|
timestamp = datetime.fromtimestamp(int(timestamp_ms) / 1000, tz=timezone.utc)
|
|
else:
|
|
timestamp = datetime.now(timezone.utc)
|
|
|
|
market_data_point = MarketDataPoint(
|
|
exchange="okx",
|
|
symbol=inst_id,
|
|
timestamp=timestamp,
|
|
data_type=data_type,
|
|
data=sanitized_data
|
|
)
|
|
market_data_points.append(market_data_point)
|
|
|
|
# Real-time processing for trades
|
|
if channel == 'trades' and inst_id == self.symbol:
|
|
self._process_real_time_trade(sanitized_data)
|
|
|
|
except Exception as e:
|
|
if self.logger:
|
|
self.logger.error(f"{self.component_name}: Error processing data item: {e}")
|
|
errors.append(f"{self.component_name}: Error processing data item: {str(e)}")
|
|
|
|
return len(errors) == 0, market_data_points, errors
|
|
|
|
except Exception as e:
|
|
error_msg = f"{self.component_name}: Exception during data message processing: {str(e)}"
|
|
errors.append(error_msg)
|
|
return False, [], errors
|
|
|
|
def _process_real_time_trade(self, trade_data: Dict[str, Any]) -> None:
|
|
"""Process real-time trade for candle generation."""
|
|
try:
|
|
# Transform to standardized format using the unified transformer
|
|
standardized_trade = self.unified_transformer.transform_trade_data(trade_data, self.symbol)
|
|
|
|
if standardized_trade:
|
|
# Process for real-time candles using common utilities
|
|
completed_candles = self.candle_processor.process_trade(standardized_trade)
|
|
|
|
# Emit trade to callbacks
|
|
for callback in self.trade_callbacks:
|
|
try:
|
|
callback(standardized_trade)
|
|
except Exception as e:
|
|
if self.logger:
|
|
self.logger.error(f"{self.component_name}: Error in trade callback: {e}")
|
|
|
|
# Note: Candle callbacks are handled by _emit_candle_to_callbacks
|
|
|
|
except Exception as e:
|
|
if self.logger:
|
|
self.logger.error(f"{self.component_name}: Error processing real-time trade: {e}")
|
|
|
|
def _emit_candle_to_callbacks(self, candle: OHLCVCandle) -> None:
|
|
"""Emit candle to all registered callbacks."""
|
|
for callback in self.candle_callbacks:
|
|
try:
|
|
callback(candle)
|
|
except Exception as e:
|
|
if self.logger:
|
|
self.logger.error(f"{self.component_name}: Error in candle callback: {e}")
|
|
|
|
def _channel_to_data_type(self, channel: str) -> Optional[DataType]:
|
|
"""Convert OKX channel name to DataType enum."""
|
|
channel_mapping = {
|
|
'trades': DataType.TRADE,
|
|
'books5': DataType.ORDERBOOK,
|
|
'books50': DataType.ORDERBOOK,
|
|
'books-l2-tbt': DataType.ORDERBOOK,
|
|
'tickers': DataType.TICKER
|
|
}
|
|
return channel_mapping.get(channel)
|
|
|
|
def get_processing_stats(self) -> Dict[str, Any]:
|
|
"""Get comprehensive processing statistics."""
|
|
return {
|
|
'candle_processor': self.candle_processor.get_stats(),
|
|
'current_candles': self.candle_processor.get_current_candles(),
|
|
'callbacks': {
|
|
'trade_callbacks': len(self.trade_callbacks),
|
|
'candle_callbacks': len(self.candle_callbacks)
|
|
},
|
|
'validator_info': self.validator.get_validator_info(),
|
|
'transformer_info': self.unified_transformer.get_transformer_info()
|
|
}
|
|
|
|
|
|
__all__ = [
|
|
'OKXMessageType',
|
|
'OKXTradeField',
|
|
'OKXOrderbookField',
|
|
'OKXTickerField',
|
|
'OKXDataValidator',
|
|
'OKXDataTransformer',
|
|
'OKXDataProcessor'
|
|
] |