added Huobi HTX data provider
This commit is contained in:
208
core/huobi_cob_websocket.py
Normal file
208
core/huobi_cob_websocket.py
Normal file
@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Huobi (HTX) COB WebSocket Connector
|
||||
|
||||
Low-latency order book stream for additional market depth aggregation.
|
||||
Features:
|
||||
- GZIP-compressed message handling
|
||||
- Ping/pong keepalive
|
||||
- Automatic reconnection with backoff
|
||||
- Per-symbol tasks; safe for Windows
|
||||
|
||||
This module emits standardized COB snapshots via callbacks:
|
||||
{ 'symbol': 'ETH/USDT', 'bids': [{'price': .., 'size': ..}], 'asks': [...],
|
||||
'exchange': 'huobi', 'source': 'huobi_ws', 'timestamp': datetime }
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
import gzip
|
||||
|
||||
try:
|
||||
import websockets
|
||||
from websockets.client import connect as websockets_connect
|
||||
WEBSOCKETS_AVAILABLE = True
|
||||
except Exception:
|
||||
websockets = None
|
||||
websockets_connect = None
|
||||
WEBSOCKETS_AVAILABLE = False
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HuobiCOBWebSocket:
|
||||
"""Minimal Huobi order book WebSocket for depth updates."""
|
||||
|
||||
def __init__(self, symbols: Optional[List[str]] = None):
|
||||
# Expect symbols like 'ETH/USDT', 'BTC/USDT'
|
||||
self.symbols = symbols or ['ETH/USDT', 'BTC/USDT']
|
||||
self.ws_tasks: Dict[str, asyncio.Task] = {}
|
||||
self.callbacks: List[Callable] = []
|
||||
self._stopping = False
|
||||
|
||||
def add_cob_callback(self, callback: Callable):
|
||||
self.callbacks.append(callback)
|
||||
|
||||
async def start(self):
|
||||
if not WEBSOCKETS_AVAILABLE:
|
||||
logger.warning("Huobi WS not available (websockets missing)")
|
||||
return
|
||||
for symbol in self.symbols:
|
||||
if symbol not in self.ws_tasks or self.ws_tasks[symbol].done():
|
||||
self.ws_tasks[symbol] = asyncio.create_task(self._run_symbol(symbol))
|
||||
|
||||
async def stop(self):
|
||||
self._stopping = True
|
||||
for _, task in list(self.ws_tasks.items()):
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
def _to_huobi_symbol(self, symbol: str) -> str:
|
||||
# 'ETH/USDT' -> 'ethusdt'
|
||||
return symbol.replace('/', '').lower()
|
||||
|
||||
async def _run_symbol(self, symbol: str):
|
||||
huobi_symbol = self._to_huobi_symbol(symbol)
|
||||
sub_msg = {
|
||||
"sub": f"market.{huobi_symbol}.depth.step0",
|
||||
"id": f"sub_{huobi_symbol}_{int(time.time())}"
|
||||
}
|
||||
url = "wss://api.huobi.pro/ws"
|
||||
|
||||
backoff = 1
|
||||
while not self._stopping:
|
||||
try:
|
||||
logger.info(f"Huobi: connecting WS for {symbol} -> {url}")
|
||||
async with websockets_connect(url, ping_interval=20, ping_timeout=60, close_timeout=10) as ws:
|
||||
# Subscribe
|
||||
await ws.send(json.dumps(sub_msg))
|
||||
logger.info(f"Huobi: subscribed {sub_msg}")
|
||||
backoff = 1
|
||||
|
||||
while not self._stopping:
|
||||
raw = await ws.recv()
|
||||
# Huobi sends gzip-compressed bytes
|
||||
if isinstance(raw, (bytes, bytearray)):
|
||||
try:
|
||||
message = gzip.decompress(raw).decode('utf-8')
|
||||
except Exception:
|
||||
# Some servers send raw JSON; try decode directly
|
||||
try:
|
||||
message = raw.decode('utf-8')
|
||||
except Exception:
|
||||
continue
|
||||
else:
|
||||
message = raw
|
||||
|
||||
try:
|
||||
data = json.loads(message)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Ping/pong
|
||||
if 'ping' in data:
|
||||
await ws.send(json.dumps({"pong": data['ping']}))
|
||||
continue
|
||||
|
||||
# Depth update
|
||||
if 'tick' in data and 'ch' in data:
|
||||
tick = data['tick'] or {}
|
||||
bids = tick.get('bids', []) or []
|
||||
asks = tick.get('asks', []) or []
|
||||
|
||||
# Convert to standardized structure
|
||||
std_bids = []
|
||||
std_asks = []
|
||||
for b in bids[:1000]:
|
||||
try:
|
||||
price = float(b[0]); size = float(b[1])
|
||||
if size > 0:
|
||||
std_bids.append({'price': price, 'size': size})
|
||||
except Exception:
|
||||
continue
|
||||
for a in asks[:1000]:
|
||||
try:
|
||||
price = float(a[0]); size = float(a[1])
|
||||
if size > 0:
|
||||
std_asks.append({'price': price, 'size': size})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if std_bids:
|
||||
std_bids.sort(key=lambda x: x['price'], reverse=True)
|
||||
if std_asks:
|
||||
std_asks.sort(key=lambda x: x['price'])
|
||||
|
||||
cob = {
|
||||
'symbol': symbol,
|
||||
'timestamp': datetime.now(),
|
||||
'bids': std_bids,
|
||||
'asks': std_asks,
|
||||
'exchange': 'huobi',
|
||||
'source': 'huobi_ws'
|
||||
}
|
||||
|
||||
# Stats
|
||||
if std_bids and std_asks:
|
||||
best_bid = std_bids[0]['price']
|
||||
best_ask = std_asks[0]['price']
|
||||
mid = (best_bid + best_ask) / 2.0
|
||||
spread = best_ask - best_bid
|
||||
spread_bps = (spread / mid) * 10000 if mid > 0 else 0
|
||||
top_bids = std_bids[:20]; top_asks = std_asks[:20]
|
||||
bid_vol = sum(x['price'] * x['size'] for x in top_bids)
|
||||
ask_vol = sum(x['price'] * x['size'] for x in top_asks)
|
||||
tot = bid_vol + ask_vol
|
||||
cob['stats'] = {
|
||||
'best_bid': best_bid,
|
||||
'best_ask': best_ask,
|
||||
'mid_price': mid,
|
||||
'spread': spread,
|
||||
'spread_bps': spread_bps,
|
||||
'bid_volume': bid_vol,
|
||||
'ask_volume': ask_vol,
|
||||
'imbalance': (bid_vol - ask_vol) / tot if tot > 0 else 0.0,
|
||||
}
|
||||
|
||||
# Notify
|
||||
for cb in self.callbacks:
|
||||
try:
|
||||
await cb(symbol, cob)
|
||||
except Exception as cb_ex:
|
||||
logger.debug(f"Huobi callback error: {cb_ex}")
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"Huobi WS error for {symbol}: {e}")
|
||||
await asyncio.sleep(backoff)
|
||||
backoff = min(backoff * 2, 60)
|
||||
|
||||
|
||||
huobi_cob_websocket: Optional[HuobiCOBWebSocket] = None
|
||||
|
||||
|
||||
async def get_huobi_cob_websocket(symbols: Optional[List[str]] = None) -> HuobiCOBWebSocket:
|
||||
global huobi_cob_websocket
|
||||
if huobi_cob_websocket is None:
|
||||
huobi_cob_websocket = HuobiCOBWebSocket(symbols)
|
||||
await huobi_cob_websocket.start()
|
||||
return huobi_cob_websocket
|
||||
|
||||
|
||||
async def stop_huobi_cob_websocket():
|
||||
global huobi_cob_websocket
|
||||
if huobi_cob_websocket:
|
||||
await huobi_cob_websocket.stop()
|
||||
huobi_cob_websocket = None
|
||||
|
||||
|
Reference in New Issue
Block a user