""" Bitget WebSocket 实时价格服务 通过 WebSocket 订阅实时 ticker 价格更新 """ import asyncio import json import logging from typing import Dict, Callable, Optional, Set, Any from datetime import datetime try: import websockets WEBSOCKETS_AVAILABLE = True except ImportError: WEBSOCKETS_AVAILABLE = False from app.utils.logger import logger class BitgetWebSocketClient: """ Bitget WebSocket 客户端 - 实时价格订阅 使用异步 WebSocket 连接获取实时价格更新 """ # Bitget WebSocket v2 端点 WS_URL = "wss://ws.bitget.com/v2/ws/public" # 公共频道 v2 # 心跳间隔(秒) HEARTBEAT_INTERVAL = 25 # 重连间隔(秒) RECONNECT_INTERVAL = 5 # 订阅限制:每个连接最多订阅 50 个交易对 MAX_SUBSCRIPTIONS = 50 def __init__(self): """初始化 WebSocket 客户端""" if not WEBSOCKETS_AVAILABLE: raise ImportError("需要安装 websockets 库: pip install websockets") self._ws: Optional[websockets.WebSocketClientProtocol] = None self._running = False self._subscribed_symbols: Set[str] = set() # 价格缓存 self._prices: Dict[str, float] = {} # 回调函数 self._callbacks: Dict[str, Set[Callable]] = {} # 心跳任务 self._heartbeat_task: Optional[asyncio.Task] = None self._reconnect_task: Optional[asyncio.Task] = None logger.info("Bitget WebSocket 客户端初始化完成") async def connect(self) -> bool: """ 连接到 Bitget WebSocket Returns: 连接是否成功 """ try: logger.info(f"正在连接 Bitget WebSocket: {self.WS_URL}") self._ws = await websockets.connect( self.WS_URL, ping_interval=self.HEARTBEAT_INTERVAL, ping_timeout=10, close_timeout=10 ) self._running = True logger.info("✅ Bitget WebSocket 连接成功") # 启动消息接收循环 asyncio.create_task(self._message_loop()) # 启动心跳任务 self._heartbeat_task = asyncio.create_task(self._heartbeat_loop()) return True except Exception as e: logger.error(f"❌ Bitget WebSocket 连接失败: {e}") return False async def disconnect(self): """断开 WebSocket 连接""" logger.info("正在断开 Bitget WebSocket...") self._running = False # 取消心跳任务 if self._heartbeat_task: self._heartbeat_task.cancel() self._heartbeat_task = None # 取消重连任务 if self._reconnect_task: self._reconnect_task.cancel() self._reconnect_task = None # 关闭 WebSocket 连接 if self._ws: await self._ws.close() self._ws = None logger.info("Bitget WebSocket 已断开") async def subscribe(self, symbols: list) -> bool: """ 订阅交易对价格 Args: symbols: 交易对列表,如 ['BTCUSDT', 'ETHUSDT'] Returns: 是否订阅成功 """ if not self._ws or not self._running: logger.warning("WebSocket 未连接,无法订阅") return False try: # 构建订阅消息 (根据 Bitget 官方文档) # USDT-FUTURES = USDT 永续合约 args = [] for symbol in symbols: if symbol not in self._subscribed_symbols: args.append({ "instType": "USDT-FUTURES", "channel": "ticker", "instId": symbol }) self._subscribed_symbols.add(symbol) if not args: logger.info("所有交易对已订阅") return True message = { "op": "subscribe", "args": args } await self._ws.send(json.dumps(message)) logger.info(f"✅ 订阅 {len(args)} 个交易对: {[s['instId'] for s in args]}") return True except Exception as e: logger.error(f"订阅失败: {e}") return False async def unsubscribe(self, symbols: list) -> bool: """ 取消订阅交易对价格 Args: symbols: 交易对列表 Returns: 是否取消成功 """ if not self._ws or not self._running: return False try: args = [] for symbol in symbols: if symbol in self._subscribed_symbols: args.append({ "instType": "USDT-FUTURES", "channel": "ticker", "instId": symbol }) self._subscribed_symbols.discard(symbol) if not args: return True message = { "op": "unsubscribe", "args": args } await self._ws.send(json.dumps(message)) logger.info(f"取消订阅 {len(args)} 个交易对") return True except Exception as e: logger.error(f"取消订阅失败: {e}") return False def get_price(self, symbol: str) -> Optional[float]: """ 获取交易对的最新价格(从缓存) Args: symbol: 交易对 Returns: 最新价格,如果未订阅则返回 None """ return self._prices.get(symbol) def get_all_prices(self) -> Dict[str, float]: """ 获取所有已订阅交易对的价格 Returns: {symbol: price} 字典 """ return self._prices.copy() def on_price_update(self, symbol: str, callback: Callable[[str, float, Dict], None]): """ 注册价格更新回调 Args: symbol: 交易对,'*' 表示所有交易对 callback: 回调函数 (symbol, price, data) -> None """ if symbol not in self._callbacks: self._callbacks[symbol] = set() self._callbacks[symbol].add(callback) logger.debug(f"注册价格回调: {symbol}") def off_price_update(self, symbol: str, callback: Callable): """ 取消价格更新回调 Args: symbol: 交易对 callback: 回调函数 """ if symbol in self._callbacks: self._callbacks[symbol].discard(callback) async def _message_loop(self): """消息接收循环""" try: async for message in self._ws: await self._handle_message(message) except websockets.ConnectionClosed: logger.warning("WebSocket 连接已关闭") if self._running: # 自动重连 self._schedule_reconnect() except Exception as e: logger.error(f"消息循环错误: {e}") if self._running: self._schedule_reconnect() async def _handle_message(self, message: str): """ 处理接收到的消息 (v2 API 格式) Args: message: WebSocket 消息 """ try: data = json.loads(message) # 调试:记录所有收到的消息 logger.info(f"📨 收到消息: action={data.get('action', 'unknown')}, event={data.get('event', 'none')}") # v2 API: 订阅/取消订阅确认事件 (使用 event 字段) if data.get('event') == 'subscribe': logger.info(f"✅ 订阅确认: {data}") return if data.get('event') == 'unsubscribe': logger.info(f"✅ 取消订阅确认: {data}") return if data.get('event') == 'error': logger.error(f"❌ WebSocket 错误: {data}") return # v2 API: ticker 数据格式 (使用 action 字段) # {"action": "snapshot" or "update", "data": [...], "arg": {...}} if 'data' in data and isinstance(data['data'], list): # 处理 data 数组中的每个 ticker for ticker_item in data['data']: if 'instId' in ticker_item or 'lastPr' in ticker_item: self._process_ticker(ticker_item) except json.JSONDecodeError: logger.warning(f"无法解析消息: {message[:100]}") except Exception as e: logger.error(f"处理消息错误: {e}") def _process_ticker(self, ticker: Dict[str, Any]): """ 处理 ticker 数据 (v2 API 格式) Args: ticker: ticker 数据,包含 instId 和 lastPr 字段 """ try: # v2 API: 直接从 ticker 获取 instId 和 lastPr symbol = ticker.get('instId', '') price_str = ticker.get('lastPr', '0') if not symbol: logger.debug(f"跳过无效 ticker (无 instId): {ticker}") return try: price = float(price_str) except (ValueError, TypeError): logger.debug(f"跳过无效 ticker (价格无效): symbol={symbol}, price_str={price_str}") return if price == 0: logger.debug(f"跳过无效 ticker (价格为0): symbol={symbol}") return # 更新价格缓存 old_price = self._prices.get(symbol) self._prices[symbol] = price # 触发回调 self._trigger_callbacks(symbol, price, ticker) # 价格变化日志 - 改为 info 级别方便调试 logger.info(f"💰 {symbol}: ${price:,.2f}") if old_price and old_price != price: change = ((price - old_price) / old_price) * 100 logger.debug(f" 变化: {change:+.2f}%") except Exception as e: logger.error(f"解析 ticker 数据错误: {e}") def _trigger_callbacks(self, symbol: str, price: float, data: Dict): """ 触发价格更新回调 Args: symbol: 交易对 price: 价格 data: 完整的 ticker 数据 """ # 触发该交易对的回调 if symbol in self._callbacks: for callback in self._callbacks[symbol]: try: callback(symbol, price, data) except Exception as e: logger.error(f"回调函数错误 ({symbol}): {e}") # 触发全局回调('*') if '*' in self._callbacks: for callback in self._callbacks['*']: try: callback(symbol, price, data) except Exception as e: logger.error(f"全局回调函数错误: {e}") async def _heartbeat_loop(self): """心跳循环""" while self._running and self._ws: try: await asyncio.sleep(self.HEARTBEAT_INTERVAL) # 发送 ping await self._ws.ping() except asyncio.CancelledError: break except Exception as e: logger.error(f"心跳错误: {e}") self._schedule_reconnect() break def _schedule_reconnect(self): """安排重连""" if not self._running: return if self._reconnect_task and not self._reconnect_task.done(): return # 已经有重连任务在运行 async def reconnect(): await asyncio.sleep(self.RECONNECT_INTERVAL) if self._running: logger.info("尝试重新连接...") await self.disconnect() if await self.connect(): # 重新订阅之前的交易对 if self._subscribed_symbols: await self.subscribe(list(self._subscribed_symbols)) self._reconnect_task = asyncio.create_task(reconnect()) @property def is_connected(self) -> bool: """是否已连接""" return self._ws is not None and self._running @property def subscribed_symbols(self) -> Set[str]: """已订阅的交易对""" return self._subscribed_symbols.copy() # 全局实例 _bitget_ws_client: Optional[BitgetWebSocketClient] = None def get_bitget_ws_client() -> Optional[BitgetWebSocketClient]: """ 获取 Bitget WebSocket 客户端单例 Returns: WebSocket 客户端实例,如果 websockets 库未安装则返回 None """ global _bitget_ws_client if _bitget_ws_client is None: try: _bitget_ws_client = BitgetWebSocketClient() except ImportError: logger.warning("websockets 库未安装,WebSocket 功能不可用") return None return _bitget_ws_client