repo hygiene: track the PINK launcher import closure
67 production .py modules that the running PINK service imports but which were never committed: prod/bingx/ (HTTP client, market/user streams, journal, config), prod/clean_arch/ adapters/persistence/runtime/dita/dita_v2 production modules and their co-located tests. Rule going forward: every module imported by launch_dolphin_pink.py / pink_direct.py must appear in git ls-files. Excludes _backup dirs, __pycache__, and non-code files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
183
prod/bingx/observer.py
Normal file
183
prod/bingx/observer.py
Normal file
@@ -0,0 +1,183 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user