from __future__ import annotations import asyncio import contextlib from collections.abc import Callable from dataclasses import dataclass from typing import Any from .config import BingxExecClientConfig from .http import BingxHttpClient from .websocket import BingxUserStream TERMINAL_ORDER_STATUSES = {"FILLED", "CANCELED", "CANCELLED", "REJECTED", "EXPIRED"} @dataclass(frozen=True) class BingxObservedOrder: key: str row: dict[str, Any] terminal: bool class BingxOrderUpdateObserver: def __init__( self, client: BingxHttpClient, config: BingxExecClientConfig, *, on_health: Callable[[bool], None] | None = None, ) -> None: self._client = client self._config = config self._stream = BingxUserStream( client=client, config=config, on_event=self._on_event, on_health=on_health, ) self._task: asyncio.Task | None = None self._lock = asyncio.Lock() self._latest: dict[str, dict[str, Any]] = {} self._events: dict[str, asyncio.Event] = {} self._closed = False async def start(self) -> None: if self._task is None: self._task = asyncio.create_task(self._stream.run_forever()) async def close(self) -> None: self._closed = True await self._stream.close() if self._task is not None: self._task.cancel() with contextlib.suppress(asyncio.CancelledError): await self._task self._task = None async def stop(self) -> None: await self.close() async def latest(self, key: str) -> dict[str, Any] | None: async with self._lock: row = self._latest.get(key) return dict(row) if isinstance(row, dict) else None async def wait_for_terminal(self, key: str, *, timeout_s: float = 20.0) -> BingxObservedOrder | None: deadline = asyncio.get_running_loop().time() + timeout_s last_row: dict[str, Any] | None = None while not self._closed: async with self._lock: row = self._latest.get(key) if isinstance(row, dict): last_row = dict(row) status = str(last_row.get("status") or last_row.get("X") or "").upper() if status in TERMINAL_ORDER_STATUSES: return BingxObservedOrder(key=key, row=last_row, terminal=True) event = self._events.setdefault(key, asyncio.Event()) remaining = deadline - asyncio.get_running_loop().time() if remaining <= 0: break try: await asyncio.wait_for(event.wait(), timeout=remaining) except TimeoutError: break finally: event.clear() if last_row is not None: return BingxObservedOrder(key=key, row=last_row, terminal=False) return None async def wait_for_fill(self, key: str, *, timeout_s: float = 20.0) -> BingxObservedOrder | None: deadline = asyncio.get_running_loop().time() + timeout_s last_row: dict[str, Any] | None = None while not self._closed: async with self._lock: row = self._latest.get(key) if isinstance(row, dict): last_row = dict(row) last_fill_qty = str( last_row.get("lastFilledQty") or last_row.get("l") or "0", ) if last_fill_qty not in {"0", "0.0", "0.00000000", ""}: return BingxObservedOrder(key=key, row=last_row, terminal=False) status = str(last_row.get("status") or last_row.get("X") or "").upper() if status in TERMINAL_ORDER_STATUSES: return BingxObservedOrder(key=key, row=last_row, terminal=True) event = self._events.setdefault(key, asyncio.Event()) remaining = deadline - asyncio.get_running_loop().time() if remaining <= 0: break try: await asyncio.wait_for(event.wait(), timeout=remaining) except TimeoutError: break finally: event.clear() if last_row is not None: return BingxObservedOrder(key=key, row=last_row, terminal=False) return None async def _on_event(self, payload: dict[str, Any]) -> None: data = payload.get("data") if isinstance(payload.get("data"), dict) else None event_type = str((data or payload).get("e") or "").upper() data_type = str(payload.get("dataType") or "").lower() if event_type not in {"ORDER_TRADE_UPDATE", "EXECUTIONREPORT"} and data_type != "spot.executionreport": return if event_type == "ORDER_TRADE_UPDATE": order_update = payload.get("o") if not isinstance(order_update, dict): return else: source = data or payload order_update = { "s": source.get("s"), "c": source.get("c") or source.get("clientOrderId") or source.get("clientOrderID"), "i": source.get("i") or source.get("orderId") or source.get("orderID"), "X": source.get("X"), "x": source.get("x"), "p": source.get("p") or source.get("price"), "ap": source.get("ap") or source.get("avgPrice"), "z": source.get("z") or source.get("executedQty") or source.get("cumFilledQty"), "l": source.get("l") or source.get("lastFilledQty") or source.get("lastExecutedQty"), "L": source.get("L") or source.get("lastFillPrice") or source.get("avgPrice"), "n": source.get("n") or source.get("commission"), "N": source.get("N") or source.get("commissionAsset"), "positionID": source.get("positionID") or source.get("positionId"), "triggerOrderId": source.get("triggerOrderId"), "mainOrderId": source.get("mainOrderId"), } client_order_id = str(order_update.get("c") or "") order_id = str(order_update.get("i") or "") status = str(order_update.get("X") or order_update.get("x") or "").upper() row = { "symbol": order_update.get("s"), "clientOrderId": client_order_id, "clientOrderID": client_order_id, "orderId": order_id, "orderID": order_id, "status": status, "price": order_update.get("p"), "avgPrice": order_update.get("ap") or order_update.get("L") or order_update.get("p"), "executedQty": order_update.get("z") or "0", "cumFilledQty": order_update.get("z") or "0", "lastFilledQty": order_update.get("l") or "0", "lastFillPrice": order_update.get("L") or order_update.get("ap") or order_update.get("p"), "commission": order_update.get("n") or "0", "commissionAsset": order_update.get("N"), "executionType": order_update.get("x"), "positionID": order_update.get("positionID") or order_update.get("positionId"), "triggerOrderId": order_update.get("triggerOrderId"), "mainOrderId": order_update.get("mainOrderId"), "raw": order_update, } async with self._lock: if client_order_id: self._latest[client_order_id] = row self._events.setdefault(client_order_id, asyncio.Event()).set() if order_id: self._latest[order_id] = row self._events.setdefault(order_id, asyncio.Event()).set()