14a7adae1f
Replace soft REST polling with OKX public tickers WS ingest and browser SSE patches so list quotes stay live while watching an expiry. Co-authored-by: Cursor <cursoragent@cursor.com>
200 lines
6.9 KiB
Python
200 lines
6.9 KiB
Python
"""OKX 公共 WebSocket(同步线程):订阅 tickers / index-tickers,自动重连."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import threading
|
|
import time
|
|
from collections.abc import Callable
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
OKX_PUBLIC_WS_URL = "wss://ws.okx.com:8443/ws/v5/public"
|
|
_SUBSCRIBE_CHUNK = 40
|
|
_APP_PING_SEC = 20.0
|
|
|
|
|
|
class OkxPublicWs:
|
|
"""单连接公共 WS;set_subscriptions 全量对齐目标频道."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
on_data: Callable[[dict[str, Any]], None],
|
|
url: str = OKX_PUBLIC_WS_URL,
|
|
name: str = "okx-public-ws",
|
|
) -> None:
|
|
self._on_data = on_data
|
|
self._url = url
|
|
self._name = name
|
|
self._lock = threading.RLock()
|
|
self._desired: dict[str, dict[str, str]] = {}
|
|
self._active: set[str] = set()
|
|
self._stop = threading.Event()
|
|
self._thread: threading.Thread | None = None
|
|
self._ws: Any = None
|
|
self._connected = False
|
|
self._last_msg_at = 0.0
|
|
|
|
@property
|
|
def connected(self) -> bool:
|
|
return self._connected
|
|
|
|
@property
|
|
def last_msg_at(self) -> float:
|
|
return self._last_msg_at
|
|
|
|
def start(self) -> None:
|
|
if self._thread and self._thread.is_alive():
|
|
return
|
|
self._stop.clear()
|
|
self._thread = threading.Thread(target=self._run_loop, name=self._name, daemon=True)
|
|
self._thread.start()
|
|
|
|
def stop(self) -> None:
|
|
self._stop.set()
|
|
ws = self._ws
|
|
if ws is not None:
|
|
try:
|
|
ws.close()
|
|
except Exception:
|
|
pass
|
|
if self._thread and self._thread.is_alive():
|
|
self._thread.join(timeout=3.0)
|
|
|
|
def set_subscriptions(self, args: list[dict[str, str]]) -> None:
|
|
desired: dict[str, dict[str, str]] = {}
|
|
for raw in args:
|
|
if not isinstance(raw, dict):
|
|
continue
|
|
channel = str(raw.get("channel") or "").strip()
|
|
inst_id = str(raw.get("instId") or "").strip()
|
|
if not channel or not inst_id:
|
|
continue
|
|
key = f"{channel}:{inst_id}"
|
|
desired[key] = {"channel": channel, "instId": inst_id}
|
|
with self._lock:
|
|
self._desired = desired
|
|
ws = self._ws
|
|
connected = self._connected
|
|
active = set(self._active)
|
|
if connected and ws is not None:
|
|
self._sync_subs(ws, active, desired)
|
|
|
|
def _sync_subs(
|
|
self,
|
|
ws: Any,
|
|
active: set[str],
|
|
desired: dict[str, dict[str, str]],
|
|
) -> None:
|
|
unsub_args: list[dict[str, str]] = []
|
|
for key in active - set(desired.keys()):
|
|
channel, _, inst_id = key.partition(":")
|
|
if channel and inst_id:
|
|
unsub_args.append({"channel": channel, "instId": inst_id})
|
|
sub_args = [desired[k] for k in (set(desired.keys()) - active)]
|
|
if unsub_args:
|
|
self._send_op(ws, "unsubscribe", unsub_args)
|
|
if sub_args:
|
|
self._send_op(ws, "subscribe", sub_args)
|
|
with self._lock:
|
|
self._active = set(desired.keys())
|
|
|
|
def _send_op(self, ws: Any, op: str, args: list[dict[str, str]]) -> None:
|
|
for i in range(0, len(args), _SUBSCRIBE_CHUNK):
|
|
chunk = args[i : i + _SUBSCRIBE_CHUNK]
|
|
try:
|
|
ws.send(json.dumps({"op": op, "args": chunk}, ensure_ascii=False))
|
|
except Exception as e:
|
|
logger.warning("%s %s failed: %s", self._name, op, e)
|
|
return
|
|
if i + _SUBSCRIBE_CHUNK < len(args):
|
|
time.sleep(0.08)
|
|
|
|
def _run_loop(self) -> None:
|
|
try:
|
|
import websocket
|
|
except ImportError:
|
|
logger.error("%s: websocket-client not installed", self._name)
|
|
return
|
|
backoff = 1.0
|
|
while not self._stop.is_set():
|
|
opened = False
|
|
try:
|
|
self._connected = False
|
|
with self._lock:
|
|
self._active.clear()
|
|
|
|
def on_open(ws: Any) -> None:
|
|
nonlocal opened
|
|
opened = True
|
|
self._connected = True
|
|
self._last_msg_at = time.time()
|
|
with self._lock:
|
|
desired = dict(self._desired)
|
|
self._sync_subs(ws, set(), desired)
|
|
|
|
def on_message(_ws: Any, message: str) -> None:
|
|
self._last_msg_at = time.time()
|
|
if message == "pong":
|
|
return
|
|
try:
|
|
payload = json.loads(message)
|
|
except Exception:
|
|
return
|
|
if not isinstance(payload, dict):
|
|
return
|
|
if payload.get("event") in ("subscribe", "unsubscribe", "error"):
|
|
if payload.get("event") == "error":
|
|
logger.warning("%s event error: %s", self._name, payload)
|
|
return
|
|
if payload.get("arg") and payload.get("data") is not None:
|
|
try:
|
|
self._on_data(payload)
|
|
except Exception:
|
|
logger.exception("%s on_data failed", self._name)
|
|
|
|
def on_error(_ws: Any, error: Any) -> None:
|
|
logger.warning("%s error: %s", self._name, error)
|
|
|
|
def on_close(_ws: Any, *_args: Any) -> None:
|
|
self._connected = False
|
|
|
|
self._ws = websocket.WebSocketApp(
|
|
self._url,
|
|
on_open=on_open,
|
|
on_message=on_message,
|
|
on_error=on_error,
|
|
on_close=on_close,
|
|
)
|
|
ping_stop = threading.Event()
|
|
|
|
def ping_loop() -> None:
|
|
while not self._stop.is_set() and not ping_stop.is_set():
|
|
ws = self._ws
|
|
if ws is not None and self._connected:
|
|
try:
|
|
ws.send("ping")
|
|
except Exception:
|
|
pass
|
|
if ping_stop.wait(_APP_PING_SEC):
|
|
break
|
|
|
|
ping_thread = threading.Thread(
|
|
target=ping_loop, name=f"{self._name}-ping", daemon=True
|
|
)
|
|
ping_thread.start()
|
|
self._ws.run_forever(ping_interval=0)
|
|
ping_stop.set()
|
|
except Exception as e:
|
|
logger.warning("%s run failed: %s", self._name, e)
|
|
finally:
|
|
self._connected = False
|
|
self._ws = None
|
|
if self._stop.is_set():
|
|
break
|
|
time.sleep(backoff)
|
|
backoff = 1.0 if opened else min(30.0, backoff * 1.7)
|
|
|