Stockbit Research research Playground @hansputera
websocket_client.py — 10.2 KB Download
""" WebSocket client for Stockbit real-time data. Handles connection, reconnection, protobuf subscription, and message decoding. """ import asyncio import json import logging from typing import Optional, Callable, Dict, Any, List from dataclasses import dataclass, field try: import websockets except ModuleNotFoundError: websockets = None # Optional; install websockets package for WS support from .auth import AuthManager from .models import LivePrice, OrderBook, OrderBookEntry, RunningTrade logger = logging.getLogger(__name__) @dataclass class SubscriptionChannels: watchlist: List[str] = field(default_factory=list) order_book: List[str] = field(default_factory=list) running_trade: List[str] = field(default_factory=list) is_hotlist: bool = False running_trade_batch: List[str] = field(default_factory=list) liveprice: List[str] = field(default_factory=list) liveprice_v3: List[str] = field(default_factory=list) iepiev: List[str] = field(default_factory=list) intraday: List[str] = field(default_factory=list) intraday_v3: List[str] = field(default_factory=list) best_bid_offer: List[str] = field(default_factory=list) order_book_v3: List[str] = field(default_factory=list) def to_dict(self) -> dict: d = {} for key, value in self.__dict__.items(): if value: d[key] = value return d class StockbitWebSocket: WS_URL = "wss://wss-trading.stockbit.com/ws" def __init__(self, auth: AuthManager, ws_type: str = "", channels: Optional[SubscriptionChannels] = None): self._auth = auth self._ws: Optional[websockets.WebSocketClientProtocol] = None self._callbacks: Dict[str, List[Callable]] = {} self._running = False self._channels: Optional[SubscriptionChannels] = channels or SubscriptionChannels() self._ws_type = ws_type # "chart" or empty for main def on(self, channel: str, callback: Callable): if channel not in self._callbacks: self._callbacks[channel] = [] self._callbacks[channel].append(callback) def on_message(self, callback: Callable): self.on("_all", callback) def subscribe(self, channels: SubscriptionChannels): self._channels = channels def subscribe_dict(self, **kwargs): channels = SubscriptionChannels() for key, value in kwargs.items(): if hasattr(channels, key): setattr(channels, key, value) self.subscribe(channels) def _ws_url(self) -> str: if self._ws_type: return f"{self.WS_URL}?type={self._ws_type}" return self.WS_URL async def connect(self): wskey = self._get_wskey() token = self._auth.main_token self._ws = await websockets.connect( self._ws_url(), subprotocols=["web"], ping_interval=30, ping_timeout=10, ) if self._channels: await self._send_subscription(wskey, token) logger.info("WebSocket connected (%s)", self._ws_url()) def _get_wskey(self) -> str: import requests resp = requests.get( f"{self._auth.EXODUS_BASE}/auth/websocket/key", headers={"Authorization": f"Bearer {self._auth.main_token}"}, ) resp.raise_for_status() return resp.json()["data"]["key"] async def _send_subscription(self, wskey: str, token: str): if not self._ws: return uid = self._get_user_id() channels = self._channels or SubscriptionChannels() try: from stockbit_api.proto.stockbit_pb2 import WebsocketRequest auth_msg = WebsocketRequest() auth_msg.user_id = uid auth_msg.key = wskey auth_msg.access_token = token sub_msg = WebsocketRequest() sub_msg.user_id = uid sub_msg.key = wskey sub_msg.access_token = token if channels.liveprice: sub_msg.channel.liveprice.extend(channels.liveprice) if channels.running_trade_batch: sub_msg.channel.running_trade_batch.extend(channels.running_trade_batch) if channels.order_book: sub_msg.channel.order_book.extend(channels.order_book) if channels.iepiev: sub_msg.channel.iepiev.extend(channels.iepiev) await self._ws.send(auth_msg.SerializeToString()) await self._ws.send(sub_msg.SerializeToString()) except ImportError: logger.warning("protobuf not available, sending JSON") payload = { "user_id": uid, "channel": channels.to_dict(), "key": wskey, "access_token": token, } await self._ws.send(json.dumps(payload).encode()) def _get_user_id(self) -> str: import jwt as pyjwt try: decoded = pyjwt.decode(self._auth.main_token, options={"verify_signature": False}) return str(decoded.get("uid", "")) except Exception: return "" async def run(self): self._running = True reconnect_attempts = 0 ping_task = None while self._running: try: await self.connect() reconnect_attempts = 0 if self._ws_type == "chart": ping_task = asyncio.create_task(self._chart_ping_loop()) async for message in self._ws: await self._handle_message(message) except websockets.exceptions.ConnectionClosed as e: logger.warning(f"WebSocket closed: {e.code} {e.reason}") except Exception as e: logger.error(f"WebSocket error: {e}") if not self._running: break reconnect_attempts += 1 delay = min(2 ** reconnect_attempts, 60) logger.info(f"Reconnecting in {delay}s (attempt {reconnect_attempts})") await asyncio.sleep(delay) async def _handle_message(self, message: bytes): try: if isinstance(message, bytes): data = self._decode_frame(message) else: data = json.loads(message) channel = data.get("channel", data.get("case", "unknown")) self._emit(channel, data) self._emit("_all", data) except Exception as e: logger.error(f"Failed to handle message: {e}") def _decode_frame(self, frame: bytes) -> dict: try: text = frame.decode("utf-8") return json.loads(text) except (UnicodeDecodeError, json.JSONDecodeError): pass return self._decode_protobuf_frame(frame) def _decode_protobuf_frame(self, frame: bytes) -> dict: try: import sys sys.path.insert(0, ".") try: from stockbit_api.proto.stockbit_pb2 import (WebsocketWrapMessage, IEPIEV) msg = WebsocketWrapMessage() msg.ParseFromString(frame) which = msg.WhichOneof("event") result = {"event": which} if which == "liveprice": lp = msg.liveprice result["stock"] = lp.stock_code result["lastprice"] = lp.lastprice result["volume"] = lp.volume result["high"] = lp.high result["low"] = lp.low result["prev"] = lp.prev elif which == "running_trade": trades = [] for t in msg.running_trade.trades: trades.append({ "stock": t.stock, "price": t.price, "volume": t.volume, "action": t.action, }) result["trades"] = trades elif which == "orderbook": ob = msg.orderbook result["stock"] = ob.symbol result["orderbook_data"] = ob.orderbook_data.hex() result["timestamp"] = ob.timestamp return result except ImportError: pass except Exception: pass except Exception: pass # Fallback: try to decode as IEPIEV directly try: iepiev = IEPIEV() iepiev.ParseFromString(frame) if iepiev.stock_code: return { "event": "iepiev", "stock": iepiev.stock_code, "iep": iepiev.iep, "iev": iepiev.iev, "iep_change": iepiev.iep_change, "total_buy_lot": iepiev.total_buy_lot, "total_sell_lot": iepiev.total_sell_lot, } except Exception: pass return {"channel": "unknown", "raw": frame.hex()} def _emit(self, channel: str, data: dict): for cb in self._callbacks.get(channel, []): try: cb(data) except Exception as e: logger.error(f"Callback error on {channel}: {e}") async def _chart_ping_loop(self): try: from stockbit_api.proto.stockbit_pb2 import WebsocketRequest ping_req = WebsocketRequest() ping_req.ping.message = "ping" while self._running and self._ws: await asyncio.sleep(30) if self._ws: await self._ws.send(ping_req.SerializeToString()) except asyncio.CancelledError: pass async def close(self): self._running = False if self._ws: await self._ws.close() @classmethod def for_chart(cls, auth: AuthManager, symbols: List[str]) -> "StockbitWebSocket": channels = SubscriptionChannels(liveprice=symbols, iepiev=symbols) return cls(auth, ws_type="chart", channels=channels) async def __aenter__(self): return self async def __aexit__(self, *args): await self.close()