Files
siloqy/prod/bingx/characterization.py

898 lines
42 KiB
Python
Raw Normal View History

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