184 lines
7.7 KiB
Python
184 lines
7.7 KiB
Python
|
|
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()
|