898 lines
42 KiB
Python
898 lines
42 KiB
Python
|
|
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
|