from __future__ import annotations import contextlib import asyncio import json import time import statistics import uuid from dataclasses import asdict from dataclasses import dataclass from datetime import datetime from datetime import timezone from decimal import Decimal from pathlib import Path from typing import Any, Callable from nautilus_trader.model.enums import OrderSide from nautilus_trader.model.enums import OrderType from nautilus_trader.model.enums import TimeInForce from nautilus_trader.model.identifiers import ClientOrderId from nautilus_trader.model.objects import Price from nautilus_trader.model.objects import Quantity from prod.ch_writer import ch_put from .execution import BingxExecutionClient from .friction import estimate_friction from .schemas import BingxOrderAck from .schemas import unwrap_order_payload ReferencePriceFn = Callable[[str], Decimal | None] _TERMINAL_ORDER_STATUSES = {"FILLED", "CANCELED", "CANCELLED", "REJECTED", "EXPIRED"} def _order_row(payload: dict[str, Any] | None) -> dict[str, Any] | None: if not isinstance(payload, dict): return None row = payload.get("order") return row if isinstance(row, dict) else payload def _position_rows(payload: Any) -> list[dict[str, Any]]: if isinstance(payload, list): return [row for row in payload if isinstance(row, dict)] if isinstance(payload, dict): rows = payload.get("positions") if not isinstance(rows, list): rows = payload.get("data") if isinstance(rows, list): return [row for row in rows if isinstance(row, dict)] return [] async def _cancel_characterization_order( adapter: BingxExecutionClient, *, symbol: str, venue_order_id: str | None, client_order_id: str | None, ) -> bool: attempts: list[dict[str, str]] = [] if venue_order_id: attempts.append({"symbol": symbol, "orderId": venue_order_id}) if client_order_id: attempts.append({"symbol": symbol, "clientOrderId": client_order_id}) for params in attempts: with contextlib.suppress(Exception): await adapter._client.signed_delete("/openApi/swap/v2/trade/order", params) # noqa: SLF001 return True with contextlib.suppress(Exception): orders = await adapter._client.signed_get("/openApi/swap/v2/trade/openOrders", {"symbol": symbol}) # noqa: SLF001 rows = orders if isinstance(orders, list) else orders.get("orders", []) for row in rows: if not isinstance(row, dict): continue row_order_id = str(row.get("orderId") or row.get("orderID") or "") row_client_order_id = str(row.get("clientOrderId") or row.get("clientOrderID") or "") if venue_order_id and row_order_id == venue_order_id: await adapter._client.signed_delete("/openApi/swap/v2/trade/order", {"symbol": symbol, "orderId": row_order_id}) # noqa: SLF001 return True if client_order_id and row_client_order_id == client_order_id: await adapter._client.signed_delete("/openApi/swap/v2/trade/order", {"symbol": symbol, "clientOrderId": row_client_order_id}) # noqa: SLF001 return True with contextlib.suppress(Exception): await adapter._client.signed_delete("/openApi/swap/v2/trade/allOpenOrders", {"symbol": symbol}) # noqa: SLF001 return False @dataclass(frozen=True) class BingxSampleSpec: symbol: str quantity: str order_type: str = "MARKET" side: str = "SELL" price: str | None = None trigger_price: str | None = None trigger_intent: str = "auto" time_in_force: str = "GTC" post_only: bool = False reduce_only: bool = True close_position: bool = False trailing_offset: str | None = None working_type: str = "MARK_PRICE" label: str = "" @dataclass(frozen=True) class BingxSampleResult: spec: BingxSampleSpec trial_index: int submitted_at_ns: int accepted_at_ns: int | None final_at_ns: int | None observed_latency_ms: str | None venue_order_id: str | None status: str reference_submit_px: str | None reference_fill_px: str | None reference_drift_bps: str | None fill_px: str | None fill_qty: str | None commission_quote: str | None fee_rate: str | None fee_bps: str | None slippage_bps: str | None gross_friction_quote: str | None liquidity_side: str | None error: str | None = None @dataclass(frozen=True) class BingxCharacterizationReport: started_at: str finished_at: str environment: str results: list[BingxSampleResult] def summary(self) -> dict[str, Any]: total = len(self.results) filled = sum(1 for item in self.results if item.status == "FILLED") rejected = sum(1 for item in self.results if item.status == "REJECTED") avg_fee_bps = _avg_decimal(item.fee_bps for item in self.results) avg_slip_bps = _avg_decimal(item.slippage_bps for item in self.results) return { "total": total, "filled": filled, "rejected": rejected, "avg_fee_bps": avg_fee_bps, "avg_slippage_bps": avg_slip_bps, "by_order_type": self.by_order_type(), "by_symbol": self.by_symbol(), } def by_order_type(self) -> dict[str, dict[str, Any]]: return _group_stats(self.results, lambda item: item.spec.order_type) def by_symbol(self) -> dict[str, dict[str, Any]]: return _group_stats(self.results, lambda item: item.spec.symbol) def build_representative_specs( symbols: list[str], *, sizes: list[str] | None = None, sides: list[str] | None = None, ) -> list[BingxSampleSpec]: sizes = sizes or ["1", "5", "25"] sides = sides or ["SELL", "BUY"] specs: list[BingxSampleSpec] = [] for symbol in symbols: for side in sides: for qty in sizes: specs.append(BingxSampleSpec(symbol=symbol, quantity=qty, order_type="MARKET", side=side, label=f"{symbol}:mkt:{side}:{qty}")) specs.append(BingxSampleSpec(symbol=symbol, quantity=qty, order_type="LIMIT", side=side, post_only=True, label=f"{symbol}:maker:{side}:{qty}")) specs.append(BingxSampleSpec(symbol=symbol, quantity=qty, order_type="STOP_MARKET", side=side, trigger_intent="stop_loss", label=f"{symbol}:stop_mkt:{side}:{qty}")) specs.append(BingxSampleSpec(symbol=symbol, quantity=qty, order_type="STOP", side=side, price=None, trigger_intent="stop_loss", label=f"{symbol}:stop:{side}:{qty}")) specs.append(BingxSampleSpec(symbol=symbol, quantity=qty, order_type="TAKE_PROFIT_MARKET", side=side, trigger_intent="take_profit", label=f"{symbol}:tp_mkt:{side}:{qty}")) specs.append(BingxSampleSpec(symbol=symbol, quantity=qty, order_type="TAKE_PROFIT", side=side, price=None, trigger_intent="take_profit", label=f"{symbol}:tp:{side}:{qty}")) specs.append(BingxSampleSpec(symbol=symbol, quantity=qty, order_type="TRAILING_STOP_MARKET", side=side, trailing_offset="0.1", label=f"{symbol}:trail:{side}:{qty}")) return specs class _SampleOrder: def __init__(self, spec: BingxSampleSpec) -> None: self.instrument_id = None self.side = OrderSide.SELL if spec.side.upper() == "SELL" else OrderSide.BUY self.order_type = _map_order_type(spec.order_type) self.quantity = Quantity.from_str(spec.quantity) self.client_order_id = ClientOrderId(f"bx-{uuid.uuid4().hex[:12]}") self.is_post_only = bool(spec.post_only) self.is_reduce_only = bool(spec.reduce_only) self.time_in_force = _map_tif(spec.time_in_force) self.has_price = spec.price is not None self.price = Price.from_str(spec.price) if spec.price is not None else None self.has_trigger_price = spec.trigger_price is not None self.trigger_price = Price.from_str(spec.trigger_price) if spec.trigger_price is not None else None self.strategy_id = ClientOrderId("bingx-sampler") self.trailing_offset = spec.trailing_offset self.close_position = bool(spec.close_position) self.trigger_type = spec.working_type async def characterize( adapter: BingxExecutionClient, specs: list[BingxSampleSpec], *, reference_price: ReferencePriceFn | None = None, reference_price_fill: ReferencePriceFn | None = None, order_observer: Any | None = None, repetitions: int = 1, timeout_s: float = 20.0, poll_interval_s: float = 0.35, strict_ws_observation: bool = True, ) -> BingxCharacterizationReport: started = datetime.now(timezone.utc).isoformat() results: list[BingxSampleResult] = [] reps = max(1, int(repetitions)) for trial_index in range(reps): for spec in specs: results.append( await _sample_one( adapter, spec, trial_index=trial_index, reference_price_submit=reference_price, reference_price_fill=reference_price_fill or reference_price, order_observer=order_observer, timeout_s=timeout_s, poll_interval_s=poll_interval_s, strict_ws_observation=strict_ws_observation, ) ) await _respect_rate_limits(adapter) finished = datetime.now(timezone.utc).isoformat() return BingxCharacterizationReport( started_at=started, finished_at=finished, environment=str(getattr(adapter._config.environment, "value", adapter._config.environment)), # noqa: SLF001 results=results, ) async def persist_report(report: BingxCharacterizationReport, *, path: str | Path | None = None) -> None: payload = { "started_at": report.started_at, "finished_at": report.finished_at, "environment": report.environment, "summary": report.summary(), "results": [asdict(item) for item in report.results], } if path is not None: Path(path).write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") ch_put( "account_events", { "ts": report.finished_at, "event_type": "BINGX_EXECUTION_SAMPLE", "strategy": "bingx", "posture": "N/A", "capital": 0.0, "peak_capital": 0.0, "drawdown_pct": 0.0, "pnl_today": 0.0, "trades_today": len(report.results), "open_positions": 0, "boost": 1.0, "beta": 1.0, "notes": json.dumps(payload, sort_keys=True, separators=(",", ":")), }, ) async def _sample_one( adapter: BingxExecutionClient, spec: BingxSampleSpec, *, trial_index: int, reference_price_submit: ReferencePriceFn | None, reference_price_fill: ReferencePriceFn | None, order_observer: Any | None, timeout_s: float, poll_interval_s: float, strict_ws_observation: bool, ) -> BingxSampleResult: submitted_at = time.time_ns() order = _SampleOrder(spec) order.instrument_id = _resolve_instrument_id(adapter, spec.symbol) submit_ref = reference_price_submit(spec.symbol) if reference_price_submit is not None else None fill_ref = reference_price_fill(spec.symbol) if reference_price_fill is not None else submit_ref pre_position_qty = await _snapshot_position_qty(adapter, spec.symbol) if order.order_type == OrderType.LIMIT and not order.has_price and submit_ref is None: raise ValueError(f"Reference price is required for maker characterization on {spec.symbol}") _materialize_order_prices(order, spec, submit_ref) order_payload = adapter._map_submit_order(order) # noqa: SLF001 accepted_at: int | None = None final_at: int | None = None venue_order_id: str | None = None status = "UNKNOWN" fill_px: str | None = None fill_qty: str | None = None friction: dict[str, Any] | None = None needs_cleanup = False try: ack = await adapter._client.signed_post("/openApi/swap/v2/trade/order", order_payload) # noqa: SLF001 ack_row = BingxOrderAck.from_http(ack) ack_http_row = unwrap_order_payload(ack) if isinstance(ack, dict) else None accepted_at = time.time_ns() venue_order_id_raw = ack_row.order_id client_order_id_raw = ack_row.client_order_id or order.client_order_id.value venue_order_id = str(venue_order_id_raw or client_order_id_raw) needs_cleanup = True lookup_by_client_order_id = venue_order_id_raw in (None, "", 0, "0") last_row: dict[str, Any] | None = None terminal_wait = getattr(adapter, "wait_for_order_terminal", None) fill_wait = getattr(adapter, "wait_for_order_fill", None) observed_row: dict[str, Any] | None = None observer_timeout_s = timeout_s wait_keys = [order.client_order_id.value] if venue_order_id and venue_order_id not in wait_keys: wait_keys.append(venue_order_id) if isinstance(ack_http_row, dict) and _is_meaningful_observation_row(ack_http_row): observed_row = dict(ack_http_row) final_at = accepted_at if _needs_observation_tail(order): observer_timeout_s = min(timeout_s, 1.0) if callable(fill_wait) and observed_row is None: for wait_key in wait_keys: observed_row = await fill_wait(wait_key, timeout_s=observer_timeout_s) if _is_meaningful_observation_row(observed_row): break observed_row = None if callable(terminal_wait) and observed_row is None: if observed_row is None: for wait_key in wait_keys: observed_row = await terminal_wait(wait_key, timeout_s=observer_timeout_s) if _is_meaningful_observation_row(observed_row): break observed_row = None elif order_observer is not None and observed_row is None: for wait_key in wait_keys: observed = await order_observer.wait_for_terminal(wait_key, timeout_s=observer_timeout_s) observed_row = dict(observed.row) if observed is not None else None if _is_meaningful_observation_row(observed_row): break observed_row = None if not _is_meaningful_observation_row(observed_row): observed_row = None if observed_row is not None: last_row = dict(observed_row) status = str(last_row.get("status") or last_row.get("X") or last_row.get("x") or "NEW").upper() fill_px = str(last_row.get("avgPrice") or last_row.get("avgFilledPrice") or last_row.get("lastFillPrice") or "0") fill_qty = str(last_row.get("executedQty") or last_row.get("cumFilledQty") or last_row.get("lastFilledQty") or "0") else: if status == "NEW": status = "PENDING" if observed_row is None and _needs_fast_position_inference(order): fast_deadline = time.monotonic() + max(0.5, min(timeout_s, 2.0)) while time.monotonic() < fast_deadline: inferred = await _infer_from_account_state(adapter, spec, order, pre_position_qty) if inferred is not None: status, fill_px, fill_qty, last_row = inferred if submit_ref is not None and fill_px is not None: friction = estimate_friction( order, last_row, last_qty=Quantity.from_str(fill_qty or "0"), last_px=Price.from_str(fill_px or "0"), quote_currency="USDT", base_currency=spec.symbol.replace("USDT", ""), maker_fee=Decimal("0.0002"), taker_fee=Decimal("0.0005"), ) friction["reference_px"] = str(submit_ref) fill = Decimal(fill_px) delta = (fill - submit_ref) if order.side == OrderSide.BUY else (submit_ref - fill) friction["slippage_bps"] = _decimal_text((delta / submit_ref) * Decimal("10000")) break await asyncio.sleep(max(0.05, poll_interval_s)) if not strict_ws_observation and status not in {"FILLED", "PARTIALLY_FILLED"}: lookup = {"symbol": order_payload["symbol"]} if lookup_by_client_order_id: lookup["clientOrderId"] = client_order_id_raw else: lookup["orderId"] = venue_order_id with contextlib.suppress(Exception): row = _order_row(await adapter._client.signed_get( # noqa: SLF001 "/openApi/swap/v2/trade/order", lookup, )) if isinstance(row, dict): last_row = row status = str(row.get("status") or row.get("X") or row.get("x") or status or "UNKNOWN").upper() fill_px = str(row.get("avgPrice") or row.get("avgFilledPrice") or row.get("lastFillPrice") or fill_px or "0") fill_qty = str(row.get("executedQty") or row.get("cumFilledQty") or row.get("lastFilledQty") or fill_qty or "0") if status in {"UNKNOWN", "NEW"}: with contextlib.suppress(Exception): open_orders = await adapter._client.signed_get( # noqa: SLF001 "/openApi/swap/v2/trade/openOrders", {"symbol": order_payload["symbol"]}, ) rows = open_orders if isinstance(open_orders, list) else open_orders.get("orders", []) active = False for row in rows: if not isinstance(row, dict): continue row_order_id = str(row.get("orderId") or row.get("orderID") or "") row_client_order_id = str(row.get("clientOrderId") or row.get("clientOrderID") or "") if venue_order_id and row_order_id == venue_order_id: active = True break if client_order_id_raw and row_client_order_id == str(client_order_id_raw): active = True break if not active: status = "CANCELED" if observed_row is None and status not in {"FILLED", "CANCELED", "CANCELLED", "REJECTED", "EXPIRED"} and not _needs_fast_position_inference(order): if not strict_ws_observation: deadline = time.monotonic() + timeout_s while time.monotonic() < deadline: lookup = {"symbol": order_payload["symbol"]} if lookup_by_client_order_id: lookup["clientOrderId"] = client_order_id_raw else: lookup["orderId"] = venue_order_id row = _order_row(await adapter._client.signed_get( # noqa: SLF001 "/openApi/swap/v2/trade/order", lookup, )) if isinstance(row, dict): last_row = row status = str(row.get("status") or row.get("X") or row.get("x") or "NEW").upper() if status in {"FILLED", "CANCELED", "CANCELLED", "REJECTED", "EXPIRED"}: break fill_px = str(row.get("avgPrice") or row.get("avgFilledPrice") or row.get("lastFillPrice") or "0") fill_qty = str(row.get("executedQty") or row.get("cumFilledQty") or row.get("lastFilledQty") or "0") await asyncio.sleep(poll_interval_s) if status not in {"FILLED", "CANCELED", "CANCELLED", "REJECTED", "EXPIRED"}: positions = await adapter._client.signed_get( # noqa: SLF001 "/openApi/swap/v2/user/positions", ) rows = _position_rows(positions) symbol_key = spec.symbol.replace("-", "") position_row = None for row in rows: if not isinstance(row, dict): continue row_symbol = str(row.get("symbol") or row.get("s") or "").replace("-", "") if row_symbol == symbol_key: position_row = row break if isinstance(position_row, dict): pos_qty = Decimal(str(position_row.get("positionAmt") or position_row.get("positionQty") or "0")) if pos_qty != 0: last_row = { "status": "FILLED", "avgPrice": position_row.get("avgPrice") or position_row.get("entryPrice") or "0", "executedQty": str(abs(pos_qty)), "lastFilledQty": str(abs(pos_qty)), "lastFillPrice": position_row.get("avgPrice") or position_row.get("entryPrice") or "0", "commission": "0", "commissionAsset": spec.symbol.replace("USDT", ""), } status = "FILLED" fill_px = str(last_row["avgPrice"]) fill_qty = str(abs(pos_qty)) elif status == "NEW": status = "PENDING" if observed_row is None and not _needs_fast_position_inference(order) and status == "NEW": status = "PENDING" final_at = time.time_ns() if last_row is not None: fill_px = str(last_row.get("avgPrice") or last_row.get("avgFilledPrice") or last_row.get("lastFillPrice") or fill_px or "0") fill_qty = str(last_row.get("executedQty") or last_row.get("cumFilledQty") or last_row.get("lastFilledQty") or fill_qty or "0") ref = submit_ref if ref is not None: friction = estimate_friction( order, last_row, last_qty=Quantity.from_str(fill_qty or "0"), last_px=Price.from_str(fill_px or "0"), quote_currency="USDT", base_currency=spec.symbol.replace("USDT", ""), maker_fee=Decimal("0.0002"), taker_fee=Decimal("0.0005"), ) friction["reference_px"] = str(ref) if fill_px and ref > 0: fill = Decimal(fill_px) delta = (fill - ref) if order.side == OrderSide.BUY else (ref - fill) friction["slippage_bps"] = _decimal_text((delta / ref) * Decimal("10000")) if not status or status == "NEW": status = str(last_row.get("status") or "NEW").upper() if status in {"NEW", "PENDING"}: inferred = await _infer_from_account_state(adapter, spec, order, pre_position_qty) if inferred is not None: status, fill_px, fill_qty, last_row = inferred if submit_ref is not None and fill_px is not None: friction = estimate_friction( order, last_row, last_qty=Quantity.from_str(fill_qty or "0"), last_px=Price.from_str(fill_px or "0"), quote_currency="USDT", base_currency=spec.symbol.replace("USDT", ""), maker_fee=Decimal("0.0002"), taker_fee=Decimal("0.0005"), ) friction["reference_px"] = str(submit_ref) fill = Decimal(fill_px) delta = (fill - submit_ref) if order.side == OrderSide.BUY else (submit_ref - fill) friction["slippage_bps"] = _decimal_text((delta / submit_ref) * Decimal("10000")) if status in {"NEW", "PENDING"} and _needs_observation_tail(order): tail_deadline = time.monotonic() + max(0.0, timeout_s - observer_timeout_s) while time.monotonic() < tail_deadline: inferred = await _infer_from_account_state(adapter, spec, order, pre_position_qty) if inferred is not None: status, fill_px, fill_qty, last_row = inferred if submit_ref is not None and fill_px is not None: friction = estimate_friction( order, last_row, last_qty=Quantity.from_str(fill_qty or "0"), last_px=Price.from_str(fill_px or "0"), quote_currency="USDT", base_currency=spec.symbol.replace("USDT", ""), maker_fee=Decimal("0.0002"), taker_fee=Decimal("0.0005"), ) friction["reference_px"] = str(submit_ref) fill = Decimal(fill_px) delta = (fill - submit_ref) if order.side == OrderSide.BUY else (submit_ref - fill) friction["slippage_bps"] = _decimal_text((delta / submit_ref) * Decimal("10000")) break await asyncio.sleep(max(0.1, poll_interval_s)) if not strict_ws_observation and status not in {"FILLED", "CANCELED", "CANCELLED", "REJECTED", "EXPIRED"} and venue_order_id: canceled = await _cancel_characterization_order( adapter, symbol=order_payload["symbol"], venue_order_id=venue_order_id, client_order_id=str(client_order_id_raw or ""), ) if canceled: status = "CANCELED" final_at = time.time_ns() except Exception as exc: final_at = time.time_ns() if needs_cleanup and not strict_ws_observation: await _cancel_characterization_order( adapter, symbol=order_payload["symbol"], venue_order_id=venue_order_id, client_order_id=str(client_order_id_raw or ""), ) if "order not exist" not in str(exc).lower(): return BingxSampleResult( spec=spec, trial_index=trial_index, submitted_at_ns=submitted_at, accepted_at_ns=accepted_at, final_at_ns=final_at, observed_latency_ms=_latency_ms(submitted_at, final_at), venue_order_id=venue_order_id, status="ERROR", reference_submit_px=str(submit_ref) if submit_ref is not None else None, reference_fill_px=str(fill_ref) if fill_ref is not None else None, reference_drift_bps=_reference_drift_bps(submit_ref, fill_ref), fill_px=fill_px, fill_qty=fill_qty, commission_quote=None, fee_rate=None, fee_bps=None, slippage_bps=None, gross_friction_quote=None, liquidity_side=None, error=str(exc), ) inferred = await _infer_from_account_state(adapter, spec, order, pre_position_qty) if inferred is not None: status, fill_px, fill_qty, last_row = inferred if submit_ref is not None and fill_px is not None: friction = estimate_friction( order, last_row, last_qty=Quantity.from_str(fill_qty or "0"), last_px=Price.from_str(fill_px or "0"), quote_currency="USDT", base_currency=spec.symbol.replace("USDT", ""), maker_fee=Decimal("0.0002"), taker_fee=Decimal("0.0005"), ) friction["reference_px"] = str(submit_ref) fill = Decimal(fill_px) delta = (fill - submit_ref) if order.side == OrderSide.BUY else (submit_ref - fill) friction["slippage_bps"] = _decimal_text((delta / submit_ref) * Decimal("10000")) return BingxSampleResult( spec=spec, trial_index=trial_index, submitted_at_ns=submitted_at, accepted_at_ns=accepted_at, final_at_ns=final_at, observed_latency_ms=_latency_ms(submitted_at, final_at), venue_order_id=venue_order_id, status=status, reference_submit_px=str(submit_ref) if submit_ref is not None else None, reference_fill_px=str(fill_ref) if fill_ref is not None else None, reference_drift_bps=_reference_drift_bps(submit_ref, fill_ref), fill_px=fill_px, fill_qty=fill_qty, commission_quote=(friction or {}).get("commission_quote") if status == "FILLED" else None, fee_rate=(friction or {}).get("fee_rate") if status == "FILLED" else None, fee_bps=(friction or {}).get("fee_bps") if status == "FILLED" else None, slippage_bps=(friction or {}).get("slippage_bps") if status == "FILLED" else None, gross_friction_quote=(friction or {}).get("gross_friction_quote") if status == "FILLED" else None, liquidity_side=(friction or {}).get("liquidity_side"), error=None, ) return BingxSampleResult( spec=spec, trial_index=trial_index, submitted_at_ns=submitted_at, accepted_at_ns=accepted_at, final_at_ns=final_at, observed_latency_ms=_latency_ms(submitted_at, final_at), venue_order_id=venue_order_id, status="PENDING", reference_submit_px=str(submit_ref) if submit_ref is not None else None, reference_fill_px=str(fill_ref) if fill_ref is not None else None, reference_drift_bps=_reference_drift_bps(submit_ref, fill_ref), fill_px=fill_px, fill_qty=fill_qty, commission_quote=None, fee_rate=None, fee_bps=None, slippage_bps=None, gross_friction_quote=None, liquidity_side=None, error=None, ) return BingxSampleResult( spec=spec, trial_index=trial_index, submitted_at_ns=submitted_at, accepted_at_ns=accepted_at, final_at_ns=final_at, observed_latency_ms=_latency_ms(submitted_at, final_at), venue_order_id=venue_order_id, status=status, reference_submit_px=str(submit_ref) if submit_ref is not None else None, reference_fill_px=str(fill_ref) if fill_ref is not None else None, reference_drift_bps=_reference_drift_bps(submit_ref, fill_ref), fill_px=fill_px, fill_qty=fill_qty, commission_quote=(friction or {}).get("commission_quote") if status == "FILLED" else None, fee_rate=(friction or {}).get("fee_rate") if status == "FILLED" else None, fee_bps=(friction or {}).get("fee_bps") if status == "FILLED" else None, slippage_bps=(friction or {}).get("slippage_bps") if status == "FILLED" else None, gross_friction_quote=(friction or {}).get("gross_friction_quote") if status == "FILLED" else None, liquidity_side=(friction or {}).get("liquidity_side"), ) async def _respect_rate_limits(adapter: BingxExecutionClient) -> None: snap = adapter._client.rate_limit_snapshot() # noqa: SLF001 if snap.rest_remaining is not None and snap.rest_remaining <= 5: await asyncio.sleep(max(0.5, float(snap.rest_reset_ms or 1000) / 1000.0)) def _resolve_instrument_id(adapter: BingxExecutionClient, symbol: str): for instrument in adapter._provider.list_all(): # noqa: SLF001 if instrument.symbol.value == symbol or instrument.raw_symbol.value == symbol or instrument.symbol.value == symbol.replace("-", ""): return instrument.id raise ValueError(f"Unknown BingX instrument {symbol}") def _map_order_type(value: str) -> OrderType: mapping = { "MARKET": OrderType.MARKET, "LIMIT": OrderType.LIMIT, "STOP_MARKET": OrderType.STOP_MARKET, "STOP": OrderType.STOP_LIMIT, "TAKE_PROFIT": OrderType.LIMIT_IF_TOUCHED, "TAKE_PROFIT_MARKET": OrderType.MARKET_IF_TOUCHED, "TRAILING_STOP_MARKET": OrderType.TRAILING_STOP_MARKET, } return mapping[value.upper()] def _map_tif(value: str) -> TimeInForce: mapping = {"GTC": TimeInForce.GTC, "IOC": TimeInForce.IOC, "FOK": TimeInForce.FOK} return mapping.get(value.upper(), TimeInForce.GTC) def _avg_decimal(values: Any) -> str | None: nums = [Decimal(str(v)) for v in values if v not in (None, "")] if not nums: return None return str(sum(nums) / Decimal(len(nums))) def _decimal_text(value: Decimal) -> str: text = format(value.normalize(), "f") if "." in text: text = text.rstrip("0").rstrip(".") return text or "0" def _latency_ms(start_ns: int, end_ns: int | None) -> str | None: if end_ns is None: return None return _decimal_text(Decimal(end_ns - start_ns) / Decimal("1000000")) def _reference_drift_bps(submit_ref: Decimal | None, fill_ref: Decimal | None) -> str | None: if submit_ref is None or fill_ref is None or submit_ref <= 0: return None return _decimal_text(((fill_ref - submit_ref) / submit_ref) * Decimal("10000")) def _group_stats(results: list[BingxSampleResult], key_fn) -> dict[str, dict[str, Any]]: grouped: dict[str, list[BingxSampleResult]] = {} for item in results: grouped.setdefault(str(key_fn(item)), []).append(item) return {key: _stats_for_items(items) for key, items in grouped.items()} def _stats_for_items(items: list[BingxSampleResult]) -> dict[str, Any]: return { "count": len(items), "filled": sum(1 for item in items if item.status == "FILLED"), "rejected": sum(1 for item in items if item.status == "REJECTED"), "avg_fee_bps": _avg_decimal(item.fee_bps for item in items), "stdev_fee_bps": _stdev_decimal(item.fee_bps for item in items), "avg_slippage_bps": _avg_decimal(item.slippage_bps for item in items), "stdev_slippage_bps": _stdev_decimal(item.slippage_bps for item in items), "avg_latency_ms": _avg_decimal(item.observed_latency_ms for item in items), "p10_latency_ms": _percentile_decimal((item.observed_latency_ms for item in items), 10), "p25_latency_ms": _percentile_decimal((item.observed_latency_ms for item in items), 25), "p50_latency_ms": _percentile_decimal((item.observed_latency_ms for item in items), 50), "p75_latency_ms": _percentile_decimal((item.observed_latency_ms for item in items), 75), "p90_latency_ms": _percentile_decimal((item.observed_latency_ms for item in items), 90), "p95_latency_ms": _percentile_decimal((item.observed_latency_ms for item in items), 95), "p99_latency_ms": _percentile_decimal((item.observed_latency_ms for item in items), 99), } def _stdev_decimal(values: Any) -> str | None: nums = [Decimal(str(v)) for v in values if v not in (None, "")] if len(nums) < 2: return None return _decimal_text(Decimal(str(statistics.pstdev([float(v) for v in nums])))) def _percentile_decimal(values: Any, percentile: int) -> str | None: nums = sorted(Decimal(str(v)) for v in values if v not in (None, "")) if not nums: return None if len(nums) == 1: return _decimal_text(nums[0]) idx = max(0, min(len(nums) - 1, int(round((percentile / 100) * (len(nums) - 1))))) return _decimal_text(nums[idx]) def _materialize_order_prices(order: _SampleOrder, spec: BingxSampleSpec, submit_ref: Decimal | None) -> None: if submit_ref is None: return side_is_buy = order.side == OrderSide.BUY trigger_intent = str(getattr(spec, "trigger_intent", "auto") or "auto").lower() if trigger_intent not in {"auto", "stop_loss", "take_profit"}: trigger_intent = "auto" if trigger_intent == "auto": if order.order_type in {OrderType.STOP_LIMIT, OrderType.STOP_MARKET}: trigger_intent = "stop_loss" elif order.order_type in {OrderType.MARKET_IF_TOUCHED, OrderType.LIMIT_IF_TOUCHED}: trigger_intent = "take_profit" def _trigger_px() -> Decimal: if trigger_intent == "stop_loss": return submit_ref * (Decimal("0.999") if side_is_buy else Decimal("1.001")) if trigger_intent == "take_profit": return submit_ref * (Decimal("1.001") if side_is_buy else Decimal("0.999")) return submit_ref * (Decimal("0.999") if side_is_buy else Decimal("1.001")) if order.order_type == OrderType.LIMIT and not order.has_price: order.has_price = True tif = getattr(order, "time_in_force", None) if tif in {TimeInForce.IOC, TimeInForce.FOK}: price = submit_ref * (Decimal("1.001") if side_is_buy else Decimal("0.999")) else: price = submit_ref * (Decimal("0.999") if side_is_buy else Decimal("1.001")) order.price = Price.from_str(str(price)) if order.order_type in {OrderType.STOP_LIMIT, OrderType.STOP_MARKET} and not order.has_trigger_price: order.has_trigger_price = True trigger = _trigger_px() order.trigger_price = Price.from_str(str(trigger)) if order.order_type == OrderType.STOP_LIMIT and not order.has_price: order.has_price = True order.price = Price.from_str(str(trigger * (Decimal("0.999") if side_is_buy else Decimal("1.001")))) if order.order_type in {OrderType.MARKET_IF_TOUCHED, OrderType.LIMIT_IF_TOUCHED} and not order.has_trigger_price: order.has_trigger_price = True trigger = _trigger_px() order.trigger_price = Price.from_str(str(trigger)) if order.order_type == OrderType.LIMIT_IF_TOUCHED and not order.has_price: order.has_price = True order.price = Price.from_str(str(trigger * (Decimal("1.001") if side_is_buy else Decimal("0.999")))) if order.order_type == OrderType.TRAILING_STOP_MARKET and order.trailing_offset is None: order.trailing_offset = "0.1" def _needs_observation_tail(order: _SampleOrder) -> bool: if order.order_type == OrderType.MARKET: return True if order.order_type == OrderType.LIMIT and order.time_in_force in {TimeInForce.IOC, TimeInForce.FOK}: return True return False def _needs_fast_position_inference(order: _SampleOrder) -> bool: return order.order_type == OrderType.MARKET or ( order.order_type == OrderType.LIMIT and order.time_in_force in {TimeInForce.IOC, TimeInForce.FOK} ) def _is_meaningful_observation_row(row: dict[str, Any] | None) -> bool: if not isinstance(row, dict): return False status = str(row.get("status") or row.get("X") or row.get("x") or "").upper() if status in _TERMINAL_ORDER_STATUSES: return True fill_qty = row.get("lastFilledQty") or row.get("executedQty") or row.get("cumFilledQty") or row.get("z") try: return Decimal(str(fill_qty or "0")) > 0 except Exception: return False async def _snapshot_position_qty(adapter: BingxExecutionClient, symbol: str) -> Decimal: try: positions = await adapter._client.signed_get("/openApi/swap/v2/user/positions", {}) # noqa: SLF001 except Exception: return Decimal("0") rows = _position_rows(positions) symbol_key = symbol.replace("-", "") for row in rows: if not isinstance(row, dict): continue row_symbol = str(row.get("symbol") or row.get("s") or "").replace("-", "") if row_symbol == symbol_key: return Decimal(str(row.get("positionAmt") or row.get("positionQty") or "0")) return Decimal("0") async def _infer_from_account_state( adapter: BingxExecutionClient, spec: BingxSampleSpec, order: _SampleOrder, pre_position_qty: Decimal, ) -> tuple[str, str | None, str | None, dict[str, Any]] | None: try: positions = await adapter._client.signed_get("/openApi/swap/v2/user/positions", {}) # noqa: SLF001 except Exception: return None rows = _position_rows(positions) symbol_key = spec.symbol.replace("-", "") post_row: dict[str, Any] | None = None for row in rows: if not isinstance(row, dict): continue row_symbol = str(row.get("symbol") or row.get("s") or "").replace("-", "") if row_symbol == symbol_key: post_row = row break if post_row is None: return None post_position_qty = Decimal(str(post_row.get("positionAmt") or post_row.get("positionQty") or "0")) delta = post_position_qty - pre_position_qty if delta == 0: return None fill_qty = _decimal_text(abs(delta)) fill_px = str(post_row.get("avgPrice") or post_row.get("entryPrice") or "0") status = "FILLED" if abs(delta) < Decimal(str(order.quantity)): status = "PARTIALLY_FILLED" row = { "status": status, "avgPrice": fill_px, "executedQty": fill_qty, "cumFilledQty": fill_qty, "lastFilledQty": fill_qty, "lastFillPrice": fill_px, "commission": "0", "commissionAsset": spec.symbol.replace("USDT", ""), } return status, fill_px, fill_qty, row