Files
siloqy/prod/bingx/observer.py

184 lines
7.7 KiB
Python
Raw Normal View History

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()