89ea3336c8
Use fstream /public/stream with lowercase bookTicker streams; keep market connected if futures WS is up. Co-authored-by: Cursor <cursoragent@cursor.com>
217 lines
7.6 KiB
Python
217 lines
7.6 KiB
Python
"""币安公共 WebSocket:USDT 永续 bookTicker + 期权 bookTicker。只读。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
from typing import Any
|
||
from urllib.parse import urlparse
|
||
|
||
import websockets
|
||
from websockets.asyncio.client import ClientConnection
|
||
|
||
from ..book_cache import BookCache
|
||
from .parse import is_option_symbol, safe_float
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class BinancePublicWs:
|
||
def __init__(
|
||
self,
|
||
*,
|
||
futures_ws_base: str,
|
||
options_ws_base: str,
|
||
cache: BookCache,
|
||
proxy: str | None = None,
|
||
ping_interval: float = 20.0,
|
||
) -> None:
|
||
self.futures_ws_base = futures_ws_base.rstrip("/")
|
||
self.options_ws_base = options_ws_base.rstrip("/")
|
||
self.cache = cache
|
||
self.proxy = (proxy or "").strip() or None
|
||
self.ping_interval = ping_interval
|
||
self._inst_ids: list[str] = []
|
||
self._tasks: list[asyncio.Task[None]] = []
|
||
self._stop = asyncio.Event()
|
||
self._alive: set[str] = set()
|
||
|
||
def set_instruments(self, inst_ids: list[str]) -> None:
|
||
self._inst_ids = [i for i in inst_ids if i]
|
||
|
||
def _split(self) -> tuple[list[str], list[str]]:
|
||
perps: list[str] = []
|
||
opts: list[str] = []
|
||
for i in self._inst_ids:
|
||
if is_option_symbol(i):
|
||
opts.append(i)
|
||
else:
|
||
perps.append(i.upper())
|
||
return perps, opts
|
||
|
||
def _mark_alive(self, kind: str, ok: bool) -> None:
|
||
if ok:
|
||
self._alive.add(kind)
|
||
else:
|
||
self._alive.discard(kind)
|
||
# 任一腿连上即认为行情可用(期权腿偶发失败时仍可用 REST/永续)
|
||
self.cache.set_connected(bool(self._alive))
|
||
|
||
async def start(self) -> None:
|
||
if self._tasks and any(not t.done() for t in self._tasks):
|
||
return
|
||
self._stop.clear()
|
||
await self._spawn()
|
||
|
||
async def stop(self) -> None:
|
||
self._stop.set()
|
||
for t in self._tasks:
|
||
t.cancel()
|
||
for t in self._tasks:
|
||
try:
|
||
await t
|
||
except asyncio.CancelledError:
|
||
pass
|
||
self._tasks = []
|
||
self._alive.clear()
|
||
self.cache.set_connected(False)
|
||
|
||
async def resubscribe(self, inst_ids: list[str]) -> None:
|
||
self.set_instruments(inst_ids)
|
||
await self.stop()
|
||
self._stop.clear()
|
||
await self._spawn()
|
||
|
||
async def _spawn(self) -> None:
|
||
perps, opts = self._split()
|
||
self._tasks = []
|
||
self._alive.clear()
|
||
if perps:
|
||
url = self._combined_url(self.futures_ws_base, [f"{p.lower()}@bookTicker" for p in perps])
|
||
self._tasks.append(
|
||
asyncio.create_task(self._run_forever(url, kind="futures"), name="bn-fapi-ws")
|
||
)
|
||
if opts:
|
||
# 期权 stream 名必须小写:eth-260726-1850-c@bookTicker
|
||
streams = [f"{s.lower()}@bookTicker" for s in opts]
|
||
url = self._combined_url(self.options_ws_base, streams)
|
||
self._tasks.append(
|
||
asyncio.create_task(self._run_forever(url, kind="options"), name="bn-eapi-ws")
|
||
)
|
||
if not self._tasks:
|
||
self.cache.set_connected(False)
|
||
|
||
@staticmethod
|
||
def _combined_url(base: str, streams: list[str]) -> str:
|
||
# base like wss://fstream.binance.com/stream or .../eoptions/stream
|
||
if "/stream" in base:
|
||
root = base
|
||
else:
|
||
root = base.rstrip("/") + "/stream"
|
||
return root + "?streams=" + "/".join(streams)
|
||
|
||
async def _open_connection(self, url: str) -> ClientConnection:
|
||
if not self.proxy:
|
||
return await websockets.connect(
|
||
url,
|
||
ping_interval=None,
|
||
max_size=2**22,
|
||
open_timeout=20,
|
||
)
|
||
from python_socks.async_.asyncio import Proxy
|
||
|
||
parsed = urlparse(url)
|
||
host = parsed.hostname or "fstream.binance.com"
|
||
port = parsed.port or (443 if parsed.scheme == "wss" else 80)
|
||
sock = await Proxy.from_url(self.proxy).connect(dest_host=host, dest_port=port)
|
||
return await websockets.connect(
|
||
url,
|
||
sock=sock,
|
||
server_hostname=host,
|
||
ping_interval=None,
|
||
max_size=2**22,
|
||
open_timeout=20,
|
||
)
|
||
|
||
async def _run_forever(self, url: str, *, kind: str) -> None:
|
||
backoff = 1.0
|
||
while not self._stop.is_set():
|
||
try:
|
||
async with await self._open_connection(url) as ws:
|
||
self._mark_alive(kind, True)
|
||
backoff = 1.0
|
||
logger.info("Binance %s WS connected: %s", kind, url[:160])
|
||
waiter = asyncio.create_task(self._stop.wait())
|
||
reader = asyncio.create_task(self._read_loop(ws))
|
||
pinger = asyncio.create_task(self._ping_loop(ws))
|
||
done, pending = await asyncio.wait(
|
||
{waiter, reader, pinger},
|
||
return_when=asyncio.FIRST_COMPLETED,
|
||
)
|
||
for t in pending:
|
||
t.cancel()
|
||
for t in done:
|
||
exc = t.exception()
|
||
if exc and not isinstance(exc, asyncio.CancelledError):
|
||
raise exc
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as e:
|
||
logger.warning("Binance %s WS disconnected: %s", kind, e)
|
||
self._mark_alive(kind, False)
|
||
try:
|
||
await asyncio.wait_for(self._stop.wait(), timeout=backoff)
|
||
break
|
||
except asyncio.TimeoutError:
|
||
backoff = min(backoff * 2, 30.0)
|
||
self._mark_alive(kind, False)
|
||
|
||
async def _ping_loop(self, ws: ClientConnection) -> None:
|
||
while True:
|
||
await asyncio.sleep(self.ping_interval)
|
||
try:
|
||
await ws.ping()
|
||
except Exception:
|
||
return
|
||
|
||
async def _read_loop(self, ws: ClientConnection) -> None:
|
||
try:
|
||
async for raw in ws:
|
||
if isinstance(raw, bytes):
|
||
raw = raw.decode("utf-8", errors="ignore")
|
||
try:
|
||
msg = json.loads(raw)
|
||
except json.JSONDecodeError:
|
||
continue
|
||
data = msg.get("data") if isinstance(msg, dict) and "stream" in msg else msg
|
||
if isinstance(data, dict):
|
||
self._handle_event(data)
|
||
except websockets.exceptions.ConnectionClosed:
|
||
return
|
||
|
||
def _handle_event(self, data: dict[str, Any]) -> None:
|
||
et = str(data.get("e") or "")
|
||
sym = str(data.get("s") or "")
|
||
if not sym:
|
||
return
|
||
ts = safe_float(data.get("E") or data.get("T"))
|
||
ts_ms = int(ts) if ts is not None else None
|
||
if et in ("bookTicker", "") or ("b" in data and "a" in data and "s" in data):
|
||
bid = safe_float(data.get("b"))
|
||
ask = safe_float(data.get("a"))
|
||
bid_sz = safe_float(data.get("B"))
|
||
ask_sz = safe_float(data.get("A"))
|
||
if bid is not None or ask is not None:
|
||
self.cache.upsert_top(
|
||
sym,
|
||
bid=bid,
|
||
ask=ask,
|
||
bid_sz=bid_sz,
|
||
ask_sz=ask_sz,
|
||
ts_ms=ts_ms,
|
||
)
|
||
# 永续可用中间价近似 mark
|
||
if not is_option_symbol(sym) and bid and ask:
|
||
self.cache.set_mark_px(sym, (bid + ask) / 2.0, ts_ms=ts_ms)
|