"""期权链实时报价:OKX 公共 WS tickers → 内存缓存 → SSE 推前端.""" from __future__ import annotations import json import logging import os import queue import threading import time from collections.abc import Iterator from typing import Any, Callable from lib.exchange.okx_public_ws_lib import OkxPublicWs from lib.options.options_pricing_lib import ( expiry_breakeven_from_ask, idx_distance_to_be, ) logger = logging.getLogger(__name__) OPTIONS_QUOTE_SSE_HEARTBEAT_SEC = float(os.getenv("OKX_OPTIONS_QUOTE_SSE_HEARTBEAT_SEC", "20")) OPTIONS_QUOTE_FLUSH_MS = float(os.getenv("OKX_OPTIONS_QUOTE_FLUSH_MS", "120")) # OKX 单连接约 240 频道;当前到期日合约 + 指数通常够用 OPTIONS_QUOTE_MAX_INST = int(os.getenv("OKX_OPTIONS_QUOTE_MAX_INST", "220")) def _safe_float(v: Any) -> float | None: try: if v is None or v == "": return None return float(v) except (TypeError, ValueError): return None class OptionsQuoteLive: def __init__(self) -> None: self._lock = threading.RLock() self._watchers: dict[str, dict[str, Any]] = {} self._meta: dict[str, dict[str, Any]] = {} self._tickers: dict[str, dict[str, Any]] = {} self._index_by_uly: dict[str, float] = {} self._index_insts: set[str] = set() self._dirty_inst: set[str] = set() self._dirty_index: set[str] = set() self._version = 0 self._subscribers: list[queue.Queue[str | None]] = [] self._stop = threading.Event() self._flush_thread: threading.Thread | None = None ws_url = (os.getenv("OKX_PUBLIC_WS_URL") or "").strip() or None self._ws = OkxPublicWs( on_data=self._on_ws_data, name="okx-options-quote-ws", **({"url": ws_url} if ws_url else {}), ) self._started = False def start(self) -> None: if self._started: return self._started = True self._stop.clear() self._ws.start() self._flush_thread = threading.Thread( target=self._flush_loop, name="options-quote-flush", daemon=True ) self._flush_thread.start() def stop(self) -> None: self._stop.set() self._ws.stop() self._broadcast(close=True) self._started = False def status(self) -> dict[str, Any]: with self._lock: uly = "" exp = "" index_inst = "" if self._watchers: last = next(reversed(list(self._watchers.values()))) uly = str(last.get("underlying") or "") exp = str(last.get("exp_time") or "") index_inst = str(last.get("index_inst") or "") return { "ok": True, "started": self._started, "ws_ok": self._ws.connected, "underlying": uly, "index_inst": index_inst, "index_px": self._index_by_uly.get(uly), "watch_exp": exp, "watch_count": len(self._meta), "watcher_count": len(self._watchers), "version": self._version, "last_msg_at": self._ws.last_msg_at, } def watch( self, *, underlying: str, exp_time: str | int | None, contracts: list[dict[str, Any]], index_inst_id: str | None = None, watcher_id: str | None = None, ) -> dict[str, Any]: u = (underlying or "ETH").upper() index_id = (index_inst_id or f"{u}-USD").strip() wid = (watcher_id or "default").strip() or "default" meta: dict[str, dict[str, Any]] = {} for c in contracts or []: if not isinstance(c, dict): continue inst_id = str(c.get("inst_id") or c.get("instId") or "").strip() if not inst_id: continue meta[inst_id] = { "inst_id": inst_id, "opt_type": str(c.get("opt_type") or c.get("optType") or "").upper(), "strike": _safe_float(c.get("strike")), "tick_sz": c.get("tick_sz") or c.get("tickSz"), "underlying": u, } if len(meta) >= max(1, OPTIONS_QUOTE_MAX_INST): break with self._lock: self._watchers[wid] = { "underlying": u, "exp_time": str(exp_time or ""), "index_inst": index_id, "meta": meta, } self._rebuild_subscriptions_locked() if not self._started: self.start() return self.status() def _rebuild_subscriptions_locked(self) -> None: merged: dict[str, dict[str, Any]] = {} index_insts: set[str] = set() for w in self._watchers.values(): index_insts.add(str(w.get("index_inst") or "")) for inst_id, m in (w.get("meta") or {}).items(): if inst_id not in merged: merged[inst_id] = dict(m) if len(merged) >= max(1, OPTIONS_QUOTE_MAX_INST): break if len(merged) >= max(1, OPTIONS_QUOTE_MAX_INST): break index_insts = {x for x in index_insts if x} self._meta = merged self._index_insts = index_insts keep = set(merged.keys()) for k in list(self._tickers.keys()): if k not in keep: self._tickers.pop(k, None) args = [{"channel": "tickers", "instId": iid} for iid in merged] for iid in sorted(index_insts): args.append({"channel": "index-tickers", "instId": iid}) self._ws.set_subscriptions(args) def seed_from_chain( self, chain: dict[str, Any], *, exp_time: str | int | None = None, watcher_id: str | None = None, ) -> None: """REST 拉链后预填报价,并默认监视指定/最近到期.""" if not isinstance(chain, dict): return u = str(chain.get("underlying") or "ETH").upper() index_px = _safe_float(chain.get("index_px")) expiries = chain.get("expiries") or [] target = None if exp_time is not None and str(exp_time): for e in expiries: if str(e.get("exp_time")) == str(exp_time): target = e break if target is None and expiries: target = expiries[0] contracts = list((target or {}).get("contracts") or []) if index_px is not None: with self._lock: self._index_by_uly[u] = index_px self._dirty_index.add(u) for c in contracts: inst_id = str(c.get("inst_id") or "").strip() if not inst_id: continue patch = { "inst_id": inst_id, "ask": c.get("ask"), "bid": c.get("bid"), "ask_sz": c.get("ask_sz"), "bid_sz": c.get("bid_sz"), "mark_px": c.get("mark_px"), "ask_estimated": bool(c.get("ask_estimated")), "expiry_be_px": c.get("expiry_be_px"), "dist_expiry_be": c.get("dist_expiry_be"), "underlying": u, } with self._lock: self._tickers[inst_id] = patch self._dirty_inst.add(inst_id) self.watch( underlying=u, exp_time=(target or {}).get("exp_time"), contracts=contracts, index_inst_id=f"{u}-USD", watcher_id=watcher_id or f"seed:{u}", ) def _on_ws_data(self, payload: dict[str, Any]) -> None: arg = payload.get("arg") or {} channel = str(arg.get("channel") or "") rows = payload.get("data") or [] if not isinstance(rows, list) or not rows: return if channel == "index-tickers": row = rows[0] if isinstance(rows[0], dict) else {} px = _safe_float(row.get("idxPx")) inst = str(row.get("instId") or arg.get("instId") or "") uly = inst.split("-")[0].upper() if inst else "" if px is None or not uly: return with self._lock: if self._index_by_uly.get(uly) == px: return self._index_by_uly[uly] = px self._dirty_index.add(uly) return if channel != "tickers": return for row in rows: if not isinstance(row, dict): continue inst_id = str(row.get("instId") or arg.get("instId") or "").strip() if not inst_id: continue patch = self._ticker_to_patch(inst_id, row) with self._lock: prev = self._tickers.get(inst_id) or {} if ( prev.get("ask") == patch.get("ask") and prev.get("bid") == patch.get("bid") and prev.get("ask_sz") == patch.get("ask_sz") and prev.get("bid_sz") == patch.get("bid_sz") and prev.get("mark_px") == patch.get("mark_px") ): continue self._tickers[inst_id] = patch self._dirty_inst.add(inst_id) def _ticker_to_patch(self, inst_id: str, row: dict[str, Any]) -> dict[str, Any]: ask = _safe_float(row.get("askPx")) bid = _safe_float(row.get("bidPx")) ask_sz = _safe_float(row.get("askSz")) bid_sz = _safe_float(row.get("bidSz")) mark = _safe_float(row.get("markPx")) ask_estimated = False with self._lock: meta = dict(self._meta.get(inst_id) or {}) uly = str(meta.get("underlying") or inst_id.split("-")[0] or "").upper() index_px = self._index_by_uly.get(uly) if ask is None and mark is not None and mark > 0: ask = mark ask_estimated = True ask_sz = None if bid is None and mark is not None and mark > 0: bid = mark be = expiry_breakeven_from_ask( opt_type=str(meta.get("opt_type") or ""), strike=meta.get("strike"), ask_px=None if ask_estimated else ask, mark_px=mark, ) dist = idx_distance_to_be(index_px, be) return { "inst_id": inst_id, "underlying": uly, "ask": ask, "bid": bid, "ask_sz": ask_sz, "bid_sz": bid_sz, "mark_px": mark, "ask_estimated": ask_estimated, "expiry_be_px": be, "dist_expiry_be": dist, } def _flush_loop(self) -> None: interval = max(0.05, OPTIONS_QUOTE_FLUSH_MS / 1000.0) while not self._stop.is_set(): if self._stop.wait(interval): break event = self._build_flush_event() if event is None: continue self._broadcast(event) def _build_flush_event(self) -> str | None: with self._lock: if not self._dirty_inst and not self._dirty_index: return None dirty_uly = set(self._dirty_index) self._dirty_index.clear() quotes: list[dict[str, Any]] = [] for inst_id in list(self._dirty_inst): q = self._tickers.get(inst_id) if q: quotes.append(dict(q)) self._dirty_inst.clear() for uly in dirty_uly: index_px = self._index_by_uly.get(uly) if index_px is None: continue for inst_id, q in list(self._tickers.items()): if str(q.get("underlying") or "").upper() != uly: continue be = q.get("expiry_be_px") dist = idx_distance_to_be(index_px, be if be is not None else None) if q.get("dist_expiry_be") != dist: q2 = dict(q) q2["dist_expiry_be"] = dist self._tickers[inst_id] = q2 quotes.append(q2) self._version += 1 # 多标的时 index_px 取「最近一次 watch」的标的,前端仍以 payload.underlying 过滤 uly = "" exp = "" if self._watchers: last = next(reversed(list(self._watchers.values()))) uly = str(last.get("underlying") or "") exp = str(last.get("exp_time") or "") # 若本批只有单一 underlying 的 quotes/index,优先用它 quote_ulys = {str(q.get("underlying") or "").upper() for q in quotes if q.get("underlying")} if len(dirty_uly) == 1: uly = next(iter(dirty_uly)) elif len(quote_ulys) == 1: uly = next(iter(quote_ulys)) payload = { "ok": True, "live": True, "ws_ok": self._ws.connected, "version": self._version, "underlying": uly, "watch_exp": exp, "index_px": self._index_by_uly.get(uly), "indexes": dict(self._index_by_uly), "quotes": quotes, "ts": int(time.time() * 1000), } return json.dumps(payload, ensure_ascii=False) def _broadcast(self, event: str | None = None, *, close: bool = False) -> None: with self._lock: subs = list(self._subscribers) dead: list[queue.Queue[str | None]] = [] for q in subs: try: q.put_nowait(None if close else event) except Exception: dead.append(q) if dead: with self._lock: for q in dead: if q in self._subscribers: self._subscribers.remove(q) def _subscribe(self) -> queue.Queue[str | None]: q: queue.Queue[str | None] = queue.Queue(maxsize=64) with self._lock: self._subscribers.append(q) return q def _unsubscribe(self, q: queue.Queue[str | None]) -> None: with self._lock: if q in self._subscribers: self._subscribers.remove(q) def iter_sse(self) -> Iterator[str]: q = self._subscribe() try: yield self._format_event( { "ok": True, "reason": "connect", **self.status(), "quotes": [], "ts": int(time.time() * 1000), } ) while True: try: raw = q.get(timeout=OPTIONS_QUOTE_SSE_HEARTBEAT_SEC) except queue.Empty: yield ": heartbeat\n\n" continue if raw is None: break yield f"event: quotes\ndata: {raw}\n\n" finally: self._unsubscribe(q) @staticmethod def _format_event(data: dict[str, Any]) -> str: return "event: quotes\ndata: " + json.dumps(data, ensure_ascii=False) + "\n\n" options_quote_live = OptionsQuoteLive() def start_options_quote_live() -> OptionsQuoteLive: options_quote_live.start() return options_quote_live def register_options_quote_live_routes(app: Any, login_required: Callable) -> None: from flask import Response, jsonify, request, stream_with_context start_options_quote_live() @app.route("/api/options/quotes/stream") @login_required def api_options_quotes_stream(): return Response( stream_with_context(options_quote_live.iter_sse()), mimetype="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no", }, ) @app.route("/api/options/quotes/watch", methods=["POST"]) @login_required def api_options_quotes_watch(): data = request.get_json(silent=True) or {} contracts = data.get("contracts") or [] if not contracts and data.get("inst_ids"): contracts = [{"inst_id": x} for x in (data.get("inst_ids") or [])] st = options_quote_live.watch( underlying=str(data.get("underlying") or "ETH"), exp_time=data.get("exp_time"), contracts=contracts, index_inst_id=data.get("index_inst_id"), watcher_id=str(data.get("watcher_id") or "default"), ) return jsonify({"ok": True, **st}) @app.route("/api/options/quotes/status") @login_required def api_options_quotes_status(): return jsonify(options_quote_live.status())