repo hygiene: track the PINK launcher import closure
67 production .py modules that the running PINK service imports but which were never committed: prod/bingx/ (HTTP client, market/user streams, journal, config), prod/clean_arch/ adapters/persistence/runtime/dita/dita_v2 production modules and their co-located tests. Rule going forward: every module imported by launch_dolphin_pink.py / pink_direct.py must appear in git ls-files. Excludes _backup dirs, __pycache__, and non-code files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
19
prod/bingx/__init__.py
Normal file
19
prod/bingx/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""BingX execution adapter components for Nautilus Trader."""
|
||||
|
||||
from .config import BingxExecClientConfig
|
||||
from .config import BingxInstrumentProviderConfig
|
||||
from .data_config import BingxDataClientConfig
|
||||
from .enums import BingxEnvironment
|
||||
from .data_factories import BingxLiveDataClientFactory
|
||||
from .factories import BingxLiveExecClientFactory
|
||||
from .observer import BingxOrderUpdateObserver
|
||||
|
||||
__all__ = [
|
||||
"BingxEnvironment",
|
||||
"BingxDataClientConfig",
|
||||
"BingxExecClientConfig",
|
||||
"BingxInstrumentProviderConfig",
|
||||
"BingxLiveDataClientFactory",
|
||||
"BingxLiveExecClientFactory",
|
||||
"BingxOrderUpdateObserver",
|
||||
]
|
||||
897
prod/bingx/characterization.py
Normal file
897
prod/bingx/characterization.py
Normal file
@@ -0,0 +1,897 @@
|
||||
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
|
||||
71
prod/bingx/config.py
Normal file
71
prod/bingx/config.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from nautilus_trader.config import InstrumentProviderConfig
|
||||
from nautilus_trader.config import LiveExecClientConfig
|
||||
from nautilus_trader.config import PositiveInt
|
||||
|
||||
from .enums import BINGX_VENUE
|
||||
from .enums import BingxEnvironment
|
||||
|
||||
|
||||
def require_mainnet_opt_in(environment: BingxEnvironment, allow_mainnet: bool, *, context: str) -> None:
|
||||
"""
|
||||
Fail closed unless the caller explicitly opts into BingX LIVE mainnet.
|
||||
"""
|
||||
if environment is BingxEnvironment.LIVE and not allow_mainnet:
|
||||
raise ValueError(f"{context} LIVE requires allow_mainnet=True")
|
||||
|
||||
|
||||
class BingxInstrumentProviderConfig(InstrumentProviderConfig, frozen=True):
|
||||
"""
|
||||
Configuration for the BingX perpetual futures instrument provider.
|
||||
"""
|
||||
|
||||
symbol_filters: tuple[str, ...] | None = None
|
||||
default_maker_fee: Decimal = Decimal("0.0002")
|
||||
default_taker_fee: Decimal = Decimal("0.0005")
|
||||
|
||||
|
||||
class BingxExecClientConfig(LiveExecClientConfig, frozen=True):
|
||||
"""
|
||||
Configuration for the BingX live execution client.
|
||||
"""
|
||||
|
||||
venue = BINGX_VENUE
|
||||
api_key: str | None = None
|
||||
secret_key: str | None = None
|
||||
environment: BingxEnvironment = BingxEnvironment.VST
|
||||
allow_mainnet: bool = False
|
||||
base_url_http: str | None = None
|
||||
base_url_http_backup: str | None = None
|
||||
base_url_ws_private: str | None = None
|
||||
http_timeout_secs: PositiveInt = 10
|
||||
recv_window_ms: PositiveInt = 5_000
|
||||
max_retries: PositiveInt = 3
|
||||
retry_delay_initial_ms: PositiveInt = 250
|
||||
retry_delay_max_ms: PositiveInt = 2_000
|
||||
instrument_provider: BingxInstrumentProviderConfig = BingxInstrumentProviderConfig(load_all=True)
|
||||
use_gtd: bool = False
|
||||
use_reduce_only: bool = True
|
||||
use_position_ids: bool = False
|
||||
prefer_websocket: bool = True
|
||||
ws_listenkey_keepalive_interval_secs: PositiveInt = 1_800
|
||||
ws_event_stale_after_ms: PositiveInt = 15_000
|
||||
ws_reconnect_initial_ms: PositiveInt = 500
|
||||
ws_reconnect_max_ms: PositiveInt = 10_000
|
||||
poll_open_orders_interval_ms: PositiveInt = 500
|
||||
poll_account_interval_ms: PositiveInt = 2_000
|
||||
poll_positions_interval_ms: PositiveInt = 2_000
|
||||
default_leverage: PositiveInt = 1
|
||||
exchange_leverage_cap: PositiveInt = 3
|
||||
sizing_mode: str = "engine"
|
||||
leverage_by_symbol: dict[str, PositiveInt] | None = None
|
||||
margin_type_by_symbol: dict[str, str] | None = None
|
||||
enforce_integer_leverage: bool = True
|
||||
journal_strategy: str | None = None
|
||||
journal_db: str | None = None
|
||||
|
||||
def validate_mainnet_opt_in(self) -> None:
|
||||
require_mainnet_opt_in(self.environment, self.allow_mainnet, context="BingX execution client")
|
||||
225
prod/bingx/data_client.py
Normal file
225
prod/bingx/data_client.py
Normal file
@@ -0,0 +1,225 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from nautilus_trader.cache.cache import Cache
|
||||
from nautilus_trader.common.component import LiveClock
|
||||
from nautilus_trader.common.component import MessageBus
|
||||
from nautilus_trader.common.enums import LogColor
|
||||
from nautilus_trader.common.providers import InstrumentProvider
|
||||
from nautilus_trader.core.datetime import millis_to_nanos
|
||||
from nautilus_trader.data.messages import SubscribeOrderBook
|
||||
from nautilus_trader.data.messages import SubscribeQuoteTicks
|
||||
from nautilus_trader.data.messages import UnsubscribeOrderBook
|
||||
from nautilus_trader.data.messages import UnsubscribeQuoteTicks
|
||||
from nautilus_trader.live.data_client import LiveMarketDataClient
|
||||
from nautilus_trader.model.data import BookOrder
|
||||
from nautilus_trader.model.data import OrderBookDelta
|
||||
from nautilus_trader.model.data import OrderBookDeltas
|
||||
from nautilus_trader.model.data import QuoteTick
|
||||
from nautilus_trader.model.enums import BookAction
|
||||
from nautilus_trader.model.enums import OrderSide
|
||||
from nautilus_trader.model.identifiers import ClientId
|
||||
from nautilus_trader.model.identifiers import InstrumentId
|
||||
from nautilus_trader.model.objects import Price
|
||||
from nautilus_trader.model.objects import Quantity
|
||||
|
||||
from .data_config import BingxDataClientConfig
|
||||
from .enums import BINGX_VENUE
|
||||
from .http import BingxHttpClient
|
||||
from .market_stream import BingxMarketStream
|
||||
from .urls import get_public_ws_url
|
||||
|
||||
|
||||
class BingxMarketDataClient(LiveMarketDataClient):
|
||||
"""
|
||||
Nautilus `LiveMarketDataClient` for BingX USDT-M perpetuals.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
client: BingxHttpClient,
|
||||
msgbus: MessageBus,
|
||||
cache: Cache,
|
||||
clock: LiveClock,
|
||||
instrument_provider: InstrumentProvider,
|
||||
config: BingxDataClientConfig,
|
||||
name: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
loop=loop,
|
||||
client_id=ClientId(name or config.venue.value),
|
||||
venue=BINGX_VENUE,
|
||||
msgbus=msgbus,
|
||||
cache=cache,
|
||||
clock=clock,
|
||||
instrument_provider=instrument_provider,
|
||||
)
|
||||
self._client = client
|
||||
self._cfg = config
|
||||
|
||||
ws_url = config.base_url_ws_market or get_public_ws_url(config.environment)
|
||||
self._ws_url = ws_url
|
||||
self._stream = BingxMarketStream(
|
||||
ws_url=ws_url,
|
||||
on_event=self._handle_ws_event,
|
||||
on_health=self._handle_ws_health,
|
||||
reconnect_initial_ms=int(config.ws_reconnect_initial_ms),
|
||||
reconnect_max_ms=int(config.ws_reconnect_max_ms),
|
||||
http_timeout_secs=int(config.http_timeout_secs),
|
||||
)
|
||||
self._stream_task: asyncio.Task | None = None
|
||||
|
||||
self._raw_to_instrument_id: dict[str, InstrumentId] = {}
|
||||
self._book_sequences: dict[InstrumentId, int] = {}
|
||||
self._quote_subs: set[InstrumentId] = set()
|
||||
self._book_subs: dict[InstrumentId, int] = {}
|
||||
|
||||
def _instrument_for(self, instrument_id: InstrumentId):
|
||||
return self._instrument_provider.get_all().get(instrument_id)
|
||||
|
||||
def _send_all_instruments_to_data_engine(self) -> None:
|
||||
for instrument in self._instrument_provider.get_all().values():
|
||||
self._handle_data(instrument)
|
||||
|
||||
for currency in self._instrument_provider.currencies().values():
|
||||
self._cache.add_currency(currency)
|
||||
|
||||
async def _connect(self) -> None:
|
||||
await self._instrument_provider.initialize()
|
||||
self._send_all_instruments_to_data_engine()
|
||||
|
||||
for instrument_id, instrument in self._instrument_provider.get_all().items():
|
||||
raw = getattr(instrument, "raw_symbol", None)
|
||||
if raw is None:
|
||||
continue
|
||||
self._raw_to_instrument_id[str(raw)] = instrument_id
|
||||
|
||||
self._log.info(f"BingX market WS {self._ws_url}", LogColor.BLUE)
|
||||
self._stream_task = self.create_task(self._stream.run_forever(), log_msg="bingx_market_stream") # type: ignore[arg-type]
|
||||
|
||||
async def _disconnect(self) -> None:
|
||||
if self._stream_task is not None:
|
||||
self._stream_task.cancel()
|
||||
await self._stream.close()
|
||||
|
||||
async def _subscribe_quote_ticks(self, command: SubscribeQuoteTicks) -> None:
|
||||
instrument = self._instrument_for(command.instrument_id)
|
||||
if instrument is None:
|
||||
self._log.warning(f"BingX quote subscription skipped, instrument not found: {command.instrument_id}")
|
||||
return
|
||||
self._quote_subs.add(command.instrument_id)
|
||||
raw_symbol = str(getattr(instrument, "raw_symbol"))
|
||||
self._stream.subscribe(f"{raw_symbol}@bookTicker")
|
||||
|
||||
async def _unsubscribe_quote_ticks(self, command: UnsubscribeQuoteTicks) -> None:
|
||||
self._quote_subs.discard(command.instrument_id)
|
||||
instrument = self._instrument_for(command.instrument_id)
|
||||
if instrument is None:
|
||||
return
|
||||
raw_symbol = str(getattr(instrument, "raw_symbol"))
|
||||
self._stream.unsubscribe(f"{raw_symbol}@bookTicker")
|
||||
|
||||
async def _subscribe_order_book_deltas(self, command: SubscribeOrderBook) -> None:
|
||||
instrument = self._instrument_for(command.instrument_id)
|
||||
if instrument is None:
|
||||
self._log.warning(f"BingX book subscription skipped, instrument not found: {command.instrument_id}")
|
||||
return
|
||||
self._book_subs[command.instrument_id] = int(command.depth or self._cfg.depth_level)
|
||||
raw_symbol = str(getattr(instrument, "raw_symbol"))
|
||||
self._stream.subscribe(f"{raw_symbol}@incrDepth")
|
||||
|
||||
async def _unsubscribe_order_book_deltas(self, command: UnsubscribeOrderBook) -> None:
|
||||
self._book_subs.pop(command.instrument_id, None)
|
||||
instrument = self._instrument_for(command.instrument_id)
|
||||
if instrument is None:
|
||||
return
|
||||
raw_symbol = str(getattr(instrument, "raw_symbol"))
|
||||
self._stream.unsubscribe(f"{raw_symbol}@incrDepth")
|
||||
|
||||
async def _subscribe_order_book_depth(self, command: SubscribeOrderBook) -> None:
|
||||
await self._subscribe_order_book_deltas(command)
|
||||
|
||||
async def _unsubscribe_order_book_depth(self, command: UnsubscribeOrderBook) -> None:
|
||||
await self._unsubscribe_order_book_deltas(command)
|
||||
|
||||
async def _handle_ws_event(self, payload: dict[str, Any]) -> None:
|
||||
data_type = str(payload.get("dataType") or "")
|
||||
data = payload.get("data")
|
||||
if not isinstance(data, dict) or not data_type:
|
||||
return
|
||||
|
||||
sym = str(data.get("s") or data.get("symbol") or "")
|
||||
if not sym:
|
||||
sym = data_type.split("@", 1)[0]
|
||||
instrument_id = self._raw_to_instrument_id.get(sym)
|
||||
if instrument_id is None:
|
||||
return
|
||||
|
||||
ts_ms = int(data.get("T") or 0)
|
||||
ts_event = millis_to_nanos(ts_ms) if ts_ms else self._clock.timestamp_ns()
|
||||
ts_init = self._clock.timestamp_ns()
|
||||
|
||||
if data_type.endswith("@bookTicker") and instrument_id in self._quote_subs:
|
||||
qt = QuoteTick(
|
||||
instrument_id,
|
||||
Price.from_str(str(data.get("b") or "0")),
|
||||
Price.from_str(str(data.get("a") or "0")),
|
||||
Quantity.from_str(str(data.get("B") or "0")),
|
||||
Quantity.from_str(str(data.get("A") or "0")),
|
||||
ts_event,
|
||||
ts_init,
|
||||
)
|
||||
self._handle_data(qt)
|
||||
return
|
||||
|
||||
if data_type.endswith("@incrDepth") and instrument_id in self._book_subs:
|
||||
action = str(data.get("action") or "")
|
||||
last_update_id = int(data.get("lastUpdateId") or 0)
|
||||
bids = data.get("bids")
|
||||
asks = data.get("asks")
|
||||
if not isinstance(bids, list) or not isinstance(asks, list):
|
||||
return
|
||||
|
||||
deltas: list[OrderBookDelta] = []
|
||||
if action == "all":
|
||||
deltas.append(OrderBookDelta(instrument_id, BookAction.CLEAR, None, 0, last_update_id, ts_event, ts_init))
|
||||
else:
|
||||
prev = self._book_sequences.get(instrument_id)
|
||||
if prev is not None and last_update_id and last_update_id != prev + 1:
|
||||
deltas.append(OrderBookDelta(instrument_id, BookAction.CLEAR, None, 0, last_update_id, ts_event, ts_init))
|
||||
if last_update_id:
|
||||
self._book_sequences[instrument_id] = last_update_id
|
||||
|
||||
depth = int(self._book_subs[instrument_id])
|
||||
|
||||
def _emit(side: OrderSide, rows: list) -> None:
|
||||
n = 0
|
||||
for item in rows:
|
||||
if n >= depth:
|
||||
break
|
||||
if not isinstance(item, (list, tuple)) or len(item) < 2:
|
||||
continue
|
||||
px_s = str(item[0])
|
||||
qty_s = str(item[1])
|
||||
qty = Quantity.from_str(qty_s)
|
||||
if qty.as_double() == 0.0:
|
||||
order = BookOrder(side, Price.from_str(px_s), Quantity.from_str("0"), 0)
|
||||
deltas.append(OrderBookDelta(instrument_id, BookAction.DELETE, order, 0, last_update_id, ts_event, ts_init))
|
||||
else:
|
||||
order = BookOrder(side, Price.from_str(px_s), qty, 0)
|
||||
deltas.append(OrderBookDelta(instrument_id, BookAction.UPDATE, order, 0, last_update_id, ts_event, ts_init))
|
||||
n += 1
|
||||
|
||||
_emit(OrderSide.BUY, bids)
|
||||
_emit(OrderSide.SELL, asks)
|
||||
if deltas:
|
||||
self._handle_data(OrderBookDeltas(instrument_id, deltas))
|
||||
|
||||
def _handle_ws_health(self, healthy: bool) -> None:
|
||||
if healthy:
|
||||
self._log.info("BingX market WS healthy", LogColor.GREEN)
|
||||
else:
|
||||
self._log.warning("BingX market WS unhealthy")
|
||||
44
prod/bingx/data_config.py
Normal file
44
prod/bingx/data_config.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from nautilus_trader.config import LiveDataClientConfig
|
||||
from nautilus_trader.config import PositiveInt
|
||||
|
||||
from .config import BingxInstrumentProviderConfig
|
||||
from .config import require_mainnet_opt_in
|
||||
from .enums import BINGX_VENUE
|
||||
from .enums import BingxEnvironment
|
||||
|
||||
|
||||
class BingxDataClientConfig(LiveDataClientConfig, frozen=True):
|
||||
"""
|
||||
Configuration for the BingX live market data client.
|
||||
"""
|
||||
|
||||
venue = BINGX_VENUE
|
||||
environment: BingxEnvironment = BingxEnvironment.VST
|
||||
allow_mainnet: bool = False
|
||||
base_url_ws_market: str | None = None
|
||||
http_timeout_secs: PositiveInt = 10
|
||||
instrument_provider: BingxInstrumentProviderConfig = BingxInstrumentProviderConfig(load_all=True)
|
||||
|
||||
use_book_ticker: bool = True
|
||||
use_incr_depth: bool = True
|
||||
depth_level: PositiveInt = 20
|
||||
|
||||
ws_reconnect_initial_ms: PositiveInt = 500
|
||||
ws_reconnect_max_ms: PositiveInt = 10_000
|
||||
|
||||
def validate_mainnet_opt_in(self) -> None:
|
||||
require_mainnet_opt_in(self.environment, self.allow_mainnet, context="BingX data client")
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
import enum
|
||||
if isinstance(self.environment, enum.Enum):
|
||||
env_val = self.environment.value
|
||||
else:
|
||||
env_val = str(self.environment)
|
||||
if env_val.upper() == "LIVE" and not self.allow_mainnet:
|
||||
raise ValueError(
|
||||
"BingXDataClientConfig: LIVE environment requires allow_mainnet=True. "
|
||||
"Pass allow_mainnet=True explicitly to opt in."
|
||||
)
|
||||
47
prod/bingx/data_factories.py
Normal file
47
prod/bingx/data_factories.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from nautilus_trader.cache.cache import Cache
|
||||
from nautilus_trader.common.component import LiveClock
|
||||
from nautilus_trader.common.component import MessageBus
|
||||
from nautilus_trader.live.factories import LiveDataClientFactory
|
||||
|
||||
from .config import BingxExecClientConfig
|
||||
from .data_client import BingxMarketDataClient
|
||||
from .data_config import BingxDataClientConfig
|
||||
from .http import BingxHttpClient
|
||||
from .instrument_provider import BingxInstrumentProvider
|
||||
|
||||
|
||||
class BingxLiveDataClientFactory(LiveDataClientFactory):
|
||||
@staticmethod
|
||||
def create( # type: ignore[override]
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
name: str,
|
||||
config: BingxDataClientConfig,
|
||||
msgbus: MessageBus,
|
||||
cache: Cache,
|
||||
clock: LiveClock,
|
||||
) -> BingxMarketDataClient:
|
||||
config.validate_mainnet_opt_in()
|
||||
exec_cfg = BingxExecClientConfig(
|
||||
api_key=None,
|
||||
secret_key=None,
|
||||
environment=config.environment,
|
||||
allow_mainnet=config.allow_mainnet,
|
||||
http_timeout_secs=config.http_timeout_secs,
|
||||
instrument_provider=config.instrument_provider,
|
||||
)
|
||||
client = BingxHttpClient(exec_cfg)
|
||||
provider = BingxInstrumentProvider(client=client, config=config.instrument_provider)
|
||||
return BingxMarketDataClient(
|
||||
loop=loop,
|
||||
client=client,
|
||||
msgbus=msgbus,
|
||||
cache=cache,
|
||||
clock=clock,
|
||||
instrument_provider=provider,
|
||||
config=config,
|
||||
name=name,
|
||||
)
|
||||
128
prod/bingx/dns_cache.py
Normal file
128
prod/bingx/dns_cache.py
Normal file
@@ -0,0 +1,128 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Mapping
|
||||
|
||||
|
||||
_STATIC_ENDPOINT_IPS: dict[str, tuple[str, ...]] = {
|
||||
"open-api.bingx.com": (
|
||||
"18.66.122.103",
|
||||
"18.66.122.71",
|
||||
"18.66.122.3",
|
||||
"18.66.122.36",
|
||||
"2600:9000:2250:4800:1a:4f2d:b440:93a1",
|
||||
"2600:9000:2250:c400:1a:4f2d:b440:93a1",
|
||||
"2600:9000:2250:6c00:1a:4f2d:b440:93a1",
|
||||
"2600:9000:2250:7200:1a:4f2d:b440:93a1",
|
||||
"2600:9000:2250:4600:1a:4f2d:b440:93a1",
|
||||
"2600:9000:2250:ee00:1a:4f2d:b440:93a1",
|
||||
"2600:9000:2250:fc00:1a:4f2d:b440:93a1",
|
||||
"2600:9000:2250:6800:1a:4f2d:b440:93a1",
|
||||
),
|
||||
"open-api-vst.bingx.com": (
|
||||
"18.165.122.31",
|
||||
"18.165.122.22",
|
||||
"18.165.122.47",
|
||||
"18.165.122.63",
|
||||
"2600:9000:2375:a200:14:7788:7980:93a1",
|
||||
"2600:9000:2375:9000:14:7788:7980:93a1",
|
||||
"2600:9000:2375:2c00:14:7788:7980:93a1",
|
||||
"2600:9000:2375:7800:14:7788:7980:93a1",
|
||||
"2600:9000:2375:f600:14:7788:7980:93a1",
|
||||
"2600:9000:2375:ce00:14:7788:7980:93a1",
|
||||
"2600:9000:2375:e800:14:7788:7980:93a1",
|
||||
"2600:9000:2375:e200:14:7788:7980:93a1",
|
||||
),
|
||||
"open-api-swap.bingx.com": (
|
||||
"3.164.68.61",
|
||||
"3.164.68.42",
|
||||
"3.164.68.75",
|
||||
"3.164.68.125",
|
||||
"2600:9000:278c:7400:1f:3dec:7600:93a1",
|
||||
"2600:9000:278c:3e00:1f:3dec:7600:93a1",
|
||||
"2600:9000:278c:5800:1f:3dec:7600:93a1",
|
||||
"2600:9000:278c:3400:1f:3dec:7600:93a1",
|
||||
"2600:9000:278c:ce00:1f:3dec:7600:93a1",
|
||||
"2600:9000:278c:9a00:1f:3dec:7600:93a1",
|
||||
"2600:9000:278c:9000:1f:3dec:7600:93a1",
|
||||
"2600:9000:278c:c800:1f:3dec:7600:93a1",
|
||||
),
|
||||
"open-api.bingx.pro": (
|
||||
"2606:4700:4403::ac40:9313",
|
||||
"2a06:98c1:310d::6812:28ed",
|
||||
),
|
||||
"open-api-vst.bingx.pro": (
|
||||
"2a06:98c1:310d::6812:28ed",
|
||||
"2606:4700:4403::ac40:9313",
|
||||
),
|
||||
"open-api-swap.bingx.pro": (
|
||||
"2a06:98c1:310d::6812:28ed",
|
||||
"2606:4700:4403::ac40:9313",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BingxDnsRecord:
|
||||
hostname: str
|
||||
ips: tuple[str, ...]
|
||||
source: str
|
||||
updated_at_ns: int
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BingxDnsFallbackCache:
|
||||
default_ips: Mapping[str, tuple[str, ...]] = field(default_factory=lambda: dict(_STATIC_ENDPOINT_IPS))
|
||||
_records: dict[str, BingxDnsRecord] = field(default_factory=dict, init=False, repr=False)
|
||||
|
||||
def resolve(self, hostname: str) -> tuple[str, ...]:
|
||||
record = self._records.get(hostname)
|
||||
if record is not None:
|
||||
return record.ips
|
||||
return tuple(self.default_ips.get(hostname, ()))
|
||||
|
||||
def record_static(self, hostname: str) -> tuple[str, ...]:
|
||||
ips = self.resolve(hostname)
|
||||
if ips:
|
||||
self._records[hostname] = BingxDnsRecord(
|
||||
hostname=hostname,
|
||||
ips=ips,
|
||||
source="static",
|
||||
updated_at_ns=time.monotonic_ns(),
|
||||
)
|
||||
return ips
|
||||
|
||||
def refresh_from_dns(self, hostname: str) -> tuple[str, ...]:
|
||||
seen: list[str] = []
|
||||
for family in (socket.AF_UNSPEC,):
|
||||
infos = socket.getaddrinfo(hostname, None, family=family, type=socket.SOCK_STREAM)
|
||||
for info in infos:
|
||||
address = info[4][0]
|
||||
if address not in seen:
|
||||
seen.append(address)
|
||||
ips = tuple(seen)
|
||||
if ips:
|
||||
self._records[hostname] = BingxDnsRecord(
|
||||
hostname=hostname,
|
||||
ips=ips,
|
||||
source="dns",
|
||||
updated_at_ns=time.monotonic_ns(),
|
||||
)
|
||||
return ips
|
||||
|
||||
def maybe_refresh_from_dns(self, hostname: str, *, min_interval_secs: int = 300) -> tuple[str, ...] | None:
|
||||
record = self._records.get(hostname)
|
||||
if record is not None:
|
||||
age_secs = (time.monotonic_ns() - record.updated_at_ns) / 1_000_000_000
|
||||
if age_secs < min_interval_secs:
|
||||
return record.ips
|
||||
try:
|
||||
return self.refresh_from_dns(hostname)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def is_bingx_hostname(hostname: str | None) -> bool:
|
||||
return hostname in _STATIC_ENDPOINT_IPS
|
||||
22
prod/bingx/enums.py
Normal file
22
prod/bingx/enums.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from nautilus_trader.model.identifiers import Venue
|
||||
|
||||
|
||||
BINGX_VENUE = Venue("BINGX")
|
||||
PINK_DEFAULT_ENV = None # resolved later
|
||||
|
||||
|
||||
class BingxEnvironment(str, Enum):
|
||||
LIVE = "prod-live"
|
||||
VST = "prod-vst"
|
||||
|
||||
@property
|
||||
def is_vst(self) -> bool:
|
||||
return self is BingxEnvironment.VST
|
||||
|
||||
|
||||
# Deferred assignment after enum definition
|
||||
PINK_DEFAULT_ENV = BingxEnvironment.VST
|
||||
2338
prod/bingx/execution.py
Normal file
2338
prod/bingx/execution.py
Normal file
File diff suppressed because it is too large
Load Diff
38
prod/bingx/factories.py
Normal file
38
prod/bingx/factories.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from nautilus_trader.cache.cache import Cache
|
||||
from nautilus_trader.common.component import LiveClock
|
||||
from nautilus_trader.common.component import MessageBus
|
||||
from nautilus_trader.live.factories import LiveExecClientFactory
|
||||
|
||||
from .config import BingxExecClientConfig
|
||||
from .http import BingxHttpClient
|
||||
from .instrument_provider import BingxInstrumentProvider
|
||||
from .execution import BingxExecutionClient
|
||||
|
||||
|
||||
class BingxLiveExecClientFactory(LiveExecClientFactory):
|
||||
@staticmethod
|
||||
def create( # type: ignore[override]
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
name: str,
|
||||
config: BingxExecClientConfig,
|
||||
msgbus: MessageBus,
|
||||
cache: Cache,
|
||||
clock: LiveClock,
|
||||
) -> BingxExecutionClient:
|
||||
config.validate_mainnet_opt_in()
|
||||
client = BingxHttpClient(config)
|
||||
provider = BingxInstrumentProvider(client=client, config=config.instrument_provider)
|
||||
return BingxExecutionClient(
|
||||
loop=loop,
|
||||
client=client,
|
||||
msgbus=msgbus,
|
||||
cache=cache,
|
||||
clock=clock,
|
||||
instrument_provider=provider,
|
||||
config=config,
|
||||
name=name,
|
||||
)
|
||||
226
prod/bingx/friction.py
Normal file
226
prod/bingx/friction.py
Normal file
@@ -0,0 +1,226 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from nautilus_trader.model.enums import LiquiditySide
|
||||
from nautilus_trader.model.enums import OrderType
|
||||
from nautilus_trader.model.enums import TimeInForce
|
||||
from nautilus_trader.model.objects import Price
|
||||
from nautilus_trader.model.objects import Quantity
|
||||
from nautilus_trader.model.orders import Order
|
||||
|
||||
|
||||
def _decimal(value: Any) -> Decimal | None:
|
||||
if value in (None, "", "null"):
|
||||
return None
|
||||
try:
|
||||
return Decimal(str(value))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _decimal_text(value: Decimal | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = format(value.normalize(), "f")
|
||||
if "." in text:
|
||||
text = text.rstrip("0").rstrip(".")
|
||||
return text or "0"
|
||||
|
||||
|
||||
def _fill_quality_score(
|
||||
*,
|
||||
slippage_bps: Decimal | None,
|
||||
fee_bps: Decimal | None,
|
||||
liquidity_side: LiquiditySide,
|
||||
) -> tuple[Decimal, str]:
|
||||
"""Return a compact fill-quality score and label.
|
||||
|
||||
The score is intentionally conservative: 100 is ideal, and lower scores
|
||||
reflect adverse slippage plus fee drag. Maker fills are allowed a small
|
||||
bonus because they are typically less toxic than taker fills.
|
||||
"""
|
||||
score = Decimal("100")
|
||||
if slippage_bps is not None:
|
||||
score -= abs(slippage_bps)
|
||||
if fee_bps is not None:
|
||||
score -= abs(fee_bps)
|
||||
if liquidity_side == LiquiditySide.MAKER:
|
||||
score += Decimal("1")
|
||||
score = max(Decimal("0"), min(Decimal("100"), score))
|
||||
if score >= Decimal("99"):
|
||||
label = "excellent"
|
||||
elif score >= Decimal("95"):
|
||||
label = "good"
|
||||
elif score >= Decimal("85"):
|
||||
label = "fair"
|
||||
else:
|
||||
label = "poor"
|
||||
return score, label
|
||||
|
||||
|
||||
def _price_from_order(order: Order) -> Decimal | None:
|
||||
if getattr(order, "has_price", False):
|
||||
price = getattr(order, "price", None)
|
||||
if price is not None:
|
||||
try:
|
||||
return price.as_decimal()
|
||||
except Exception:
|
||||
return _decimal(price)
|
||||
return None
|
||||
|
||||
|
||||
def infer_liquidity_side(order: Order, row: dict[str, Any] | None = None) -> LiquiditySide:
|
||||
row = row or {}
|
||||
explicit = row.get("liquiditySide") or row.get("liquidity_side")
|
||||
if isinstance(explicit, str):
|
||||
upper = explicit.upper()
|
||||
if "MAKER" in upper:
|
||||
return LiquiditySide.MAKER
|
||||
if "TAKER" in upper:
|
||||
return LiquiditySide.TAKER
|
||||
for key in ("isMaker", "maker", "m"):
|
||||
value = row.get(key)
|
||||
if isinstance(value, bool):
|
||||
return LiquiditySide.MAKER if value else LiquiditySide.TAKER
|
||||
if isinstance(value, str):
|
||||
lower = value.strip().lower()
|
||||
if lower in {"true", "1", "yes", "maker"}:
|
||||
return LiquiditySide.MAKER
|
||||
if lower in {"false", "0", "no", "taker"}:
|
||||
return LiquiditySide.TAKER
|
||||
if bool(getattr(order, "is_post_only", False)):
|
||||
return LiquiditySide.MAKER
|
||||
tif = getattr(order, "time_in_force", None)
|
||||
if tif in {TimeInForce.IOC, TimeInForce.FOK}:
|
||||
return LiquiditySide.TAKER
|
||||
if order.order_type in {
|
||||
OrderType.MARKET,
|
||||
OrderType.STOP_MARKET,
|
||||
OrderType.MARKET_IF_TOUCHED,
|
||||
OrderType.TRAILING_STOP_MARKET,
|
||||
}:
|
||||
return LiquiditySide.TAKER
|
||||
if order.order_type in {
|
||||
OrderType.LIMIT,
|
||||
OrderType.STOP_LIMIT,
|
||||
OrderType.LIMIT_IF_TOUCHED,
|
||||
OrderType.TRAILING_STOP_LIMIT,
|
||||
}:
|
||||
return LiquiditySide.MAKER
|
||||
return LiquiditySide.NO_LIQUIDITY_SIDE
|
||||
|
||||
|
||||
def reference_price(order: Order, row: dict[str, Any] | None = None) -> tuple[Decimal | None, str]:
|
||||
row = row or {}
|
||||
for key in ("referencePrice", "referencePx", "expectedPrice", "expectedPx", "bestPrice"):
|
||||
value = _decimal(row.get(key))
|
||||
if value is not None and value > 0:
|
||||
return value, key
|
||||
if getattr(order, "has_price", False):
|
||||
price = _price_from_order(order)
|
||||
if price is not None and price > 0:
|
||||
return price, "order_price"
|
||||
if getattr(order, "has_trigger_price", False) and order.order_type in {
|
||||
OrderType.STOP_MARKET,
|
||||
OrderType.MARKET_IF_TOUCHED,
|
||||
OrderType.TRAILING_STOP_MARKET,
|
||||
}:
|
||||
trigger = getattr(order, "trigger_price", None)
|
||||
if trigger is not None:
|
||||
try:
|
||||
value = trigger.as_decimal()
|
||||
except Exception:
|
||||
value = _decimal(trigger)
|
||||
if value is not None and value > 0:
|
||||
return value, "trigger_price"
|
||||
return None, "unavailable"
|
||||
|
||||
|
||||
def _commission_quote(
|
||||
*,
|
||||
commission_amount: Decimal,
|
||||
commission_asset: str,
|
||||
quote_currency: str,
|
||||
base_currency: str,
|
||||
last_px: Decimal | None,
|
||||
estimate_quote: Decimal,
|
||||
) -> tuple[Decimal, str]:
|
||||
if commission_amount == 0:
|
||||
return estimate_quote, "estimated"
|
||||
if commission_asset == quote_currency:
|
||||
return commission_amount, "quote"
|
||||
if commission_asset == base_currency and last_px is not None:
|
||||
return commission_amount * last_px, "base_converted"
|
||||
return estimate_quote, "estimated"
|
||||
|
||||
|
||||
def estimate_friction(
|
||||
order: Order,
|
||||
row: dict[str, Any],
|
||||
*,
|
||||
last_qty: Quantity | None = None,
|
||||
last_px: Price | None = None,
|
||||
quote_currency: str,
|
||||
base_currency: str,
|
||||
maker_fee: Decimal,
|
||||
taker_fee: Decimal,
|
||||
feed_source: str = "unknown",
|
||||
) -> dict[str, Any]:
|
||||
qty = last_qty.as_decimal() if last_qty is not None else _decimal(row.get("lastFilledQty") or row.get("executedQty")) or Decimal("0")
|
||||
px = last_px.as_decimal() if last_px is not None else _decimal(row.get("lastFillPrice") or row.get("avgPrice") or row.get("price"))
|
||||
notional = qty * px if px is not None else Decimal("0")
|
||||
liquidity_side = infer_liquidity_side(order, row)
|
||||
fee_rate = maker_fee if liquidity_side == LiquiditySide.MAKER else taker_fee if liquidity_side == LiquiditySide.TAKER else taker_fee
|
||||
estimated_fee_quote = notional * fee_rate if notional > 0 else Decimal("0")
|
||||
commission_amount = _decimal(row.get("commission")) or Decimal("0")
|
||||
commission_asset = str(row.get("commissionAsset") or quote_currency)
|
||||
actual_fee_quote, commission_source = _commission_quote(
|
||||
commission_amount=commission_amount,
|
||||
commission_asset=commission_asset,
|
||||
quote_currency=quote_currency,
|
||||
base_currency=base_currency,
|
||||
last_px=px,
|
||||
estimate_quote=estimated_fee_quote,
|
||||
)
|
||||
reference_px, reference_source = reference_price(order, row)
|
||||
slippage_quote = None
|
||||
slippage_bps = None
|
||||
if reference_px is not None and px is not None and qty > 0:
|
||||
side = getattr(order.side, "name", str(order.side)).upper()
|
||||
delta = px - reference_px if side == "BUY" else reference_px - px
|
||||
slippage_quote = delta * qty
|
||||
if reference_px > 0:
|
||||
slippage_bps = (delta / reference_px) * Decimal("10000")
|
||||
net_friction_quote = actual_fee_quote + (slippage_quote or Decimal("0"))
|
||||
gross_friction_quote = abs(actual_fee_quote) + abs(slippage_quote or Decimal("0"))
|
||||
fee_bps = None
|
||||
if notional > 0:
|
||||
fee_bps = (actual_fee_quote / notional) * Decimal("10000")
|
||||
fill_quality_score, fill_quality_class = _fill_quality_score(
|
||||
slippage_bps=slippage_bps,
|
||||
fee_bps=fee_bps,
|
||||
liquidity_side=liquidity_side,
|
||||
)
|
||||
return {
|
||||
"liquidity_side": getattr(liquidity_side, "name", str(liquidity_side)),
|
||||
"feed_source": str(feed_source or "unknown"),
|
||||
"commission_asset": commission_asset,
|
||||
"commission_source": commission_source,
|
||||
"commission_quote": _decimal_text(actual_fee_quote),
|
||||
"estimated_fee_quote": _decimal_text(estimated_fee_quote),
|
||||
"fee_rate": _decimal_text(fee_rate),
|
||||
"fee_bps": _decimal_text(fee_bps),
|
||||
"reference_px": _decimal_text(reference_px),
|
||||
"reference_source": reference_source,
|
||||
"slippage_quote": _decimal_text(slippage_quote),
|
||||
"slippage_bps": _decimal_text(slippage_bps),
|
||||
"net_friction_quote": _decimal_text(net_friction_quote),
|
||||
"gross_friction_quote": _decimal_text(gross_friction_quote),
|
||||
"fill_quality_score": _decimal_text(fill_quality_score),
|
||||
"fill_quality_class": fill_quality_class,
|
||||
"notional_quote": _decimal_text(notional),
|
||||
"last_qty": _decimal_text(qty),
|
||||
"last_px": _decimal_text(px),
|
||||
}
|
||||
151
prod/bingx/health.py
Normal file
151
prod/bingx/health.py
Normal file
@@ -0,0 +1,151 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from .journal import load_latest_record
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BingxHealthSummary:
|
||||
score: float
|
||||
status: str
|
||||
event_type: str
|
||||
reason: str
|
||||
age_s: float
|
||||
transport: float
|
||||
freshness: float
|
||||
coherence: float
|
||||
rate_limit: float
|
||||
circuit: float
|
||||
ws_healthy: bool
|
||||
ledger_authority: str
|
||||
|
||||
|
||||
def load_latest_health_summary(
|
||||
*,
|
||||
strategy: str = "bingx",
|
||||
account_id: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> BingxHealthSummary | None:
|
||||
record = load_latest_record(strategy, account_id=account_id)
|
||||
if record is None:
|
||||
return None
|
||||
return score_health_record(record, now=now)
|
||||
|
||||
|
||||
def score_health_record(
|
||||
record: dict[str, Any],
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> BingxHealthSummary:
|
||||
payload = record.get("payload") if isinstance(record, dict) else {}
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
notes = record.get("notes") if isinstance(record, dict) else {}
|
||||
if not isinstance(notes, dict):
|
||||
notes = {}
|
||||
alarm = notes.get("alarm") if isinstance(notes, dict) else {}
|
||||
if not isinstance(alarm, dict):
|
||||
alarm = {}
|
||||
|
||||
event_type = str(record.get("event_type") or alarm.get("reason") or "")
|
||||
reason = str(alarm.get("reason") or event_type or "")
|
||||
ledger_authority = str(payload.get("ledger_authority") or notes.get("ledger_authority") or "exchange")
|
||||
ws_healthy = bool(payload.get("ws_healthy", True))
|
||||
|
||||
parsed_ts = _parse_ts(record.get("ts"))
|
||||
now_dt = now or datetime.now(timezone.utc)
|
||||
age_s = max(0.0, (now_dt - parsed_ts).total_seconds()) if parsed_ts is not None else 0.0
|
||||
|
||||
freshness = 1.0
|
||||
if age_s > 60.0:
|
||||
freshness = 0.0
|
||||
elif age_s > 20.0:
|
||||
freshness = 0.5
|
||||
|
||||
circuit = 1.0
|
||||
cb = payload.get("circuit_breaker") if isinstance(payload, dict) else {}
|
||||
if isinstance(cb, dict):
|
||||
open_until_ns = int(cb.get("open_until_ns") or 0)
|
||||
failure_count = int(cb.get("failure_count") or 0)
|
||||
last_delay_ms = int(cb.get("last_delay_ms") or 0)
|
||||
if open_until_ns > 0:
|
||||
circuit = 0.0
|
||||
elif failure_count > 0 or last_delay_ms > 0:
|
||||
circuit = 0.5
|
||||
|
||||
rate_limit = 1.0
|
||||
rl = payload.get("rate_limits") if isinstance(payload, dict) else {}
|
||||
if isinstance(rl, dict):
|
||||
remaining = rl.get("rest_remaining")
|
||||
reset_ms = int(rl.get("rest_reset_ms") or 0)
|
||||
if remaining is not None:
|
||||
remaining_int = int(remaining)
|
||||
if remaining_int <= 0:
|
||||
rate_limit = 0.0
|
||||
elif remaining_int <= 5:
|
||||
rate_limit = 0.25
|
||||
elif remaining_int <= 20:
|
||||
rate_limit = 0.6
|
||||
if reset_ms > 0 and rate_limit > 0.0:
|
||||
rate_limit = min(rate_limit, 0.8)
|
||||
|
||||
transport = 1.0 if ws_healthy else 0.3
|
||||
if event_type in {"BINGX_WS_DOWN", "BINGX_REST_FAIL"}:
|
||||
transport = 0.0
|
||||
|
||||
coherence = 1.0 if ledger_authority == "exchange" else 0.2
|
||||
if event_type == "BINGX_DRIFT":
|
||||
coherence = 0.0
|
||||
elif event_type in {"BINGX_ORDER_REJECTED", "BINGX_ORDER_CANCEL_REJECTED"}:
|
||||
coherence = min(coherence, 0.8)
|
||||
|
||||
if alarm:
|
||||
severity = float(alarm.get("severity") or 0.0)
|
||||
category = str(alarm.get("category") or "").lower()
|
||||
if severity >= 0.85:
|
||||
transport = min(transport, 0.0 if category in {"transport", "auth", "ws"} else transport)
|
||||
coherence = min(coherence, 0.0 if category in {"coherence", "drift"} else coherence)
|
||||
elif severity >= 0.5:
|
||||
transport = min(transport, 0.5)
|
||||
coherence = min(coherence, 0.5)
|
||||
|
||||
score = min(freshness, circuit, rate_limit, transport, coherence)
|
||||
if score >= 0.85:
|
||||
status = "GREEN"
|
||||
elif score >= 0.6:
|
||||
status = "DEGRADED"
|
||||
elif score >= 0.3:
|
||||
status = "CRITICAL"
|
||||
else:
|
||||
status = "DEAD"
|
||||
|
||||
return BingxHealthSummary(
|
||||
score=round(score, 3),
|
||||
status=status,
|
||||
event_type=event_type,
|
||||
reason=reason,
|
||||
age_s=round(age_s, 1),
|
||||
transport=round(transport, 3),
|
||||
freshness=round(freshness, 3),
|
||||
coherence=round(coherence, 3),
|
||||
rate_limit=round(rate_limit, 3),
|
||||
circuit=round(circuit, 3),
|
||||
ws_healthy=ws_healthy,
|
||||
ledger_authority=ledger_authority,
|
||||
)
|
||||
|
||||
|
||||
def _parse_ts(raw: Any) -> datetime | None:
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, datetime):
|
||||
return raw.replace(tzinfo=timezone.utc) if raw.tzinfo is None else raw.astimezone(timezone.utc)
|
||||
try:
|
||||
parsed = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
|
||||
return parsed.replace(tzinfo=timezone.utc) if parsed.tzinfo is None else parsed.astimezone(timezone.utc)
|
||||
except Exception:
|
||||
return None
|
||||
80
prod/bingx/instrument_provider.py
Normal file
80
prod/bingx/instrument_provider.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from nautilus_trader.common.providers import InstrumentProvider
|
||||
from nautilus_trader.model.identifiers import InstrumentId
|
||||
from nautilus_trader.model.identifiers import Symbol
|
||||
from nautilus_trader.model.instruments import CryptoPerpetual
|
||||
from nautilus_trader.model.objects import Currency
|
||||
from nautilus_trader.model.objects import Money
|
||||
from nautilus_trader.model.objects import Price
|
||||
from nautilus_trader.model.objects import Quantity
|
||||
|
||||
from .config import BingxInstrumentProviderConfig
|
||||
from .enums import BINGX_VENUE
|
||||
from .http import BingxHttpClient
|
||||
from .schemas import BingxContract
|
||||
|
||||
|
||||
class BingxInstrumentProvider(InstrumentProvider):
|
||||
def __init__(
|
||||
self,
|
||||
client: BingxHttpClient,
|
||||
config: BingxInstrumentProviderConfig | None = None,
|
||||
) -> None:
|
||||
super().__init__(config=config)
|
||||
self._client = client
|
||||
self._cfg = config or BingxInstrumentProviderConfig()
|
||||
|
||||
async def load_all_async(self, filters: dict | None = None) -> None:
|
||||
raw_contracts = await self._client.public_get("/openApi/swap/v2/quote/contracts")
|
||||
contracts = raw_contracts if isinstance(raw_contracts, list) else raw_contracts.get("contracts", [])
|
||||
requested = set(self._cfg.symbol_filters or ())
|
||||
for row in contracts:
|
||||
contract = BingxContract.from_http(row)
|
||||
if requested and contract.symbol not in requested and contract.venue_symbol not in requested:
|
||||
continue
|
||||
self.add(self._parse_contract(contract))
|
||||
|
||||
async def load_ids_async(
|
||||
self,
|
||||
instrument_ids: list[InstrumentId],
|
||||
filters: dict | None = None,
|
||||
) -> None:
|
||||
self._cfg = BingxInstrumentProviderConfig(
|
||||
load_all=True,
|
||||
symbol_filters=tuple(i.symbol.value for i in instrument_ids),
|
||||
default_maker_fee=self._cfg.default_maker_fee,
|
||||
default_taker_fee=self._cfg.default_taker_fee,
|
||||
)
|
||||
await self.load_all_async(filters)
|
||||
|
||||
def _parse_contract(self, contract: BingxContract) -> CryptoPerpetual:
|
||||
base_currency = Currency.from_str(contract.base_asset)
|
||||
quote_currency = Currency.from_str(contract.quote_asset)
|
||||
symbol = Symbol(contract.symbol)
|
||||
return CryptoPerpetual(
|
||||
instrument_id=InstrumentId(symbol=symbol, venue=BINGX_VENUE),
|
||||
raw_symbol=Symbol(contract.venue_symbol),
|
||||
base_currency=base_currency,
|
||||
quote_currency=quote_currency,
|
||||
settlement_currency=quote_currency,
|
||||
is_inverse=False,
|
||||
price_precision=contract.price_precision,
|
||||
price_increment=Price.from_str(str(contract.tick_size)),
|
||||
size_precision=contract.quantity_precision,
|
||||
size_increment=Quantity.from_str(str(contract.step_size)),
|
||||
max_quantity=Quantity.from_str("1000000000"),
|
||||
min_quantity=Quantity.from_str(str(contract.min_quantity)),
|
||||
max_notional=None,
|
||||
min_notional=Money(contract.min_notional, quote_currency),
|
||||
max_price=Price.from_str("1000000000"),
|
||||
min_price=Price.from_str(str(contract.tick_size)),
|
||||
margin_init=Decimal("0.11111111") if contract.max_leverage >= 9 else Decimal("1") / Decimal(contract.max_leverage or 1),
|
||||
margin_maint=Decimal("0.05"),
|
||||
maker_fee=contract.maker_fee or self._cfg.default_maker_fee,
|
||||
taker_fee=contract.taker_fee or self._cfg.default_taker_fee,
|
||||
ts_event=0,
|
||||
ts_init=0,
|
||||
)
|
||||
357
prod/bingx/journal.py
Normal file
357
prod/bingx/journal.py
Normal file
@@ -0,0 +1,357 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from hashlib import sha256
|
||||
from typing import Any
|
||||
|
||||
from prod.ch_writer import ch_put
|
||||
from prod.ch_writer import ch_put_green
|
||||
from prod.ch_writer import ch_put_prodgreen
|
||||
from prod.ch_writer import ch_put_pink
|
||||
|
||||
# ─── Account event rate control (§10.2) ──────────────────────────────────────
|
||||
import os
|
||||
import time as _time
|
||||
|
||||
_ACCOUNT_EVENT_RATE_CAP = int(os.environ.get("PINK_ACCOUNT_EVENT_RATE_CAP", "5"))
|
||||
|
||||
|
||||
class _AccountEventRateLimiter:
|
||||
"""Token-bucket rate limiter for account events (PINK data volume control)."""
|
||||
def __init__(self, max_per_sec: int = 5):
|
||||
self._max = max(max_per_sec, 1)
|
||||
self._tokens = float(self._max)
|
||||
self._last = _time.monotonic()
|
||||
|
||||
def allow(self) -> bool:
|
||||
now = _time.monotonic()
|
||||
self._tokens = min(self._max, self._tokens + (now - self._last) * self._max)
|
||||
self._last = now
|
||||
if self._tokens >= 1.0:
|
||||
self._tokens -= 1.0
|
||||
return True
|
||||
return False
|
||||
from prod.ch_writer import ts_us
|
||||
from prod.bingx.leverage import LEVERAGE_MAPPING_RULE
|
||||
|
||||
|
||||
CH_URL = "http://localhost:8123"
|
||||
CH_USER = "dolphin"
|
||||
CH_PASS = "dolphin_ch_2026"
|
||||
CH_DB = "dolphin"
|
||||
JOURNAL_EVENT_TYPE = "BINGX_SNAPSHOT"
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _json_safe(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {str(key): _json_safe(val) for key, val in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_json_safe(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return [_json_safe(item) for item in value]
|
||||
if hasattr(value, "isoformat"):
|
||||
try:
|
||||
return value.isoformat()
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(value, "as_decimal"):
|
||||
try:
|
||||
return str(value.as_decimal())
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(value, "__dict__"):
|
||||
return _json_safe(dict(vars(value)))
|
||||
return value
|
||||
|
||||
|
||||
def _capital_from_balances(balances: Any) -> float:
|
||||
if not isinstance(balances, list):
|
||||
LOGGER.warning("BingX journal account snapshot balances payload is not a list")
|
||||
return 0.0
|
||||
found = 0.0
|
||||
for row in balances:
|
||||
if not isinstance(row, dict):
|
||||
LOGGER.warning("BingX journal account snapshot skipped malformed balance row: %r", row)
|
||||
continue
|
||||
capital = 0.0
|
||||
for key in ("total", "balance", "equity", "availableMargin", "availableBalance", "walletBalance", "free"):
|
||||
try:
|
||||
capital = float(row.get(key, 0.0) or 0.0)
|
||||
except Exception:
|
||||
continue
|
||||
if capital > 0 and capital == capital:
|
||||
found = capital
|
||||
return capital
|
||||
if capital > 0 and capital == capital:
|
||||
found = capital
|
||||
return capital
|
||||
if balances:
|
||||
LOGGER.error("BingX journal account snapshot contained no usable balance rows")
|
||||
return found
|
||||
|
||||
|
||||
def _open_notional_from_positions(positions: Any) -> float:
|
||||
if not isinstance(positions, dict):
|
||||
LOGGER.warning("BingX journal positions payload is not a dict")
|
||||
return 0.0
|
||||
total = 0.0
|
||||
for row in positions.values():
|
||||
if not isinstance(row, dict):
|
||||
LOGGER.warning("BingX journal skipped malformed position row: %r", row)
|
||||
continue
|
||||
try:
|
||||
qty = abs(
|
||||
float(
|
||||
row.get("positionAmt")
|
||||
or row.get("positionQty")
|
||||
or row.get("positionSize")
|
||||
or row.get("quantity")
|
||||
or row.get("pa")
|
||||
or 0.0
|
||||
)
|
||||
)
|
||||
if qty <= 0.0:
|
||||
continue
|
||||
notional = row.get("positionValue") or row.get("notional") or row.get("openNotional")
|
||||
if notional is not None:
|
||||
total += abs(float(notional or 0.0))
|
||||
continue
|
||||
entry = (
|
||||
row.get("entryPrice")
|
||||
or row.get("avgPrice")
|
||||
or row.get("markPrice")
|
||||
or row.get("avgEntryPrice")
|
||||
or row.get("ep")
|
||||
or row.get("ap")
|
||||
or 0.0
|
||||
)
|
||||
total += qty * abs(float(entry or 0.0))
|
||||
except Exception:
|
||||
LOGGER.warning("BingX journal skipped unreadable position row: %r", row)
|
||||
continue
|
||||
return total
|
||||
|
||||
|
||||
def _filled_order_count_from_fills(fills: Any) -> int:
|
||||
if not isinstance(fills, list):
|
||||
return 0
|
||||
seen: set[str] = set()
|
||||
count = 0
|
||||
for snapshot in fills:
|
||||
if not isinstance(snapshot, dict):
|
||||
continue
|
||||
row = snapshot.get("row") if isinstance(snapshot.get("row"), dict) else snapshot
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
status = str(row.get("status") or "").upper()
|
||||
if status and status not in {"FILLED", "CLOSED"}:
|
||||
continue
|
||||
trade_key = str(snapshot.get("_trade_key") or "").strip()
|
||||
if trade_key:
|
||||
base_key = trade_key.split(":", 1)[0]
|
||||
else:
|
||||
base_key = str(
|
||||
row.get("orderId")
|
||||
or row.get("orderID")
|
||||
or row.get("clientOrderId")
|
||||
or row.get("clientOrderID")
|
||||
or ""
|
||||
).strip()
|
||||
if not base_key or base_key in seen:
|
||||
continue
|
||||
seen.add(base_key)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
_STRATEGY_DB_MAP: dict[str, str] = {
|
||||
"blue": "dolphin",
|
||||
"green": "dolphin_green",
|
||||
"prodgreen": "dolphin_prodgreen",
|
||||
"pink": "dolphin_pink",
|
||||
}
|
||||
|
||||
_STRATEGY_SINK_MAP: dict[str, Any] = {
|
||||
"blue": ch_put,
|
||||
"green": ch_put_green,
|
||||
"prodgreen": ch_put_prodgreen,
|
||||
"pink": ch_put_pink,
|
||||
}
|
||||
|
||||
_STRATEGY_SINK_NAME_MAP: dict[str, str] = {
|
||||
"blue": "ch_put",
|
||||
"green": "ch_put_green",
|
||||
"prodgreen": "ch_put_prodgreen",
|
||||
"pink": "ch_put_pink",
|
||||
}
|
||||
|
||||
|
||||
def _db_for_strategy(strategy: str) -> str:
|
||||
name = str(strategy or "").lower()
|
||||
return _STRATEGY_DB_MAP.get(name, "dolphin_prodgreen" if name.startswith("prod") else CH_DB)
|
||||
|
||||
|
||||
def _sink_for_strategy(strategy: str):
|
||||
strategy_lower = str(strategy or "").lower()
|
||||
sink = _STRATEGY_SINK_MAP.get(strategy_lower)
|
||||
if callable(sink):
|
||||
return sink
|
||||
sink_name = _STRATEGY_SINK_NAME_MAP.get(strategy_lower)
|
||||
if sink_name:
|
||||
sink = globals().get(sink_name)
|
||||
if callable(sink):
|
||||
return sink
|
||||
return ch_put_prodgreen if strategy_lower.startswith("prod") else ch_put
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BingxJournalSnapshot:
|
||||
ts: int
|
||||
strategy: str
|
||||
account_id: str
|
||||
ledger_authority: str
|
||||
payload: dict[str, Any]
|
||||
fingerprint: str
|
||||
reason: str = ""
|
||||
|
||||
|
||||
def build_snapshot(
|
||||
*,
|
||||
strategy: str,
|
||||
account_id: str,
|
||||
ledger_authority: str,
|
||||
payload: dict[str, Any],
|
||||
reason: str = "",
|
||||
) -> BingxJournalSnapshot:
|
||||
payload_json = json.dumps(_json_safe(payload), sort_keys=True, separators=(",", ":"))
|
||||
fingerprint = sha256(payload_json.encode("utf-8")).hexdigest()
|
||||
return BingxJournalSnapshot(
|
||||
ts=ts_us(),
|
||||
strategy=strategy,
|
||||
account_id=account_id,
|
||||
ledger_authority=ledger_authority,
|
||||
payload=payload,
|
||||
fingerprint=fingerprint,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
|
||||
def write_snapshot(snapshot: BingxJournalSnapshot) -> None:
|
||||
account = snapshot.payload.get("account", {})
|
||||
balances = account.get("balances", [])
|
||||
capital = _capital_from_balances(balances)
|
||||
peak_capital = capital
|
||||
drawdown_pct = 0.0
|
||||
if capital <= 0.0:
|
||||
LOGGER.error(
|
||||
"BingX journal snapshot has no usable capital for strategy=%s account_id=%s reason=%s",
|
||||
snapshot.strategy,
|
||||
snapshot.account_id,
|
||||
snapshot.reason or JOURNAL_EVENT_TYPE,
|
||||
)
|
||||
positions = snapshot.payload.get("positions", {})
|
||||
open_positions = len(positions) if isinstance(positions, dict) else 0
|
||||
current_open_notional = _open_notional_from_positions(positions)
|
||||
current_account_leverage = current_open_notional / capital if capital > 0 else 0.0
|
||||
configured = snapshot.payload.get("configured_leverage", {})
|
||||
exchange_leverage = 0
|
||||
if isinstance(configured, dict) and configured:
|
||||
try:
|
||||
exchange_leverage = max(int(v) for v in configured.values() if int(v) > 0)
|
||||
except Exception:
|
||||
exchange_leverage = 0
|
||||
fills = snapshot.payload.get("fills", [])
|
||||
fills_today = len(fills) if isinstance(fills, list) else 0
|
||||
trades_today = _filled_order_count_from_fills(fills)
|
||||
sink = _sink_for_strategy(snapshot.strategy)
|
||||
sink(
|
||||
"account_events",
|
||||
{
|
||||
"ts": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.%f"),
|
||||
"event_type": snapshot.reason or JOURNAL_EVENT_TYPE,
|
||||
"strategy": snapshot.strategy,
|
||||
"posture": "N/A",
|
||||
"capital": capital,
|
||||
"peak_capital": peak_capital,
|
||||
"drawdown_pct": drawdown_pct,
|
||||
"pnl_today": 0.0,
|
||||
"trades_today": trades_today,
|
||||
"open_positions": open_positions,
|
||||
"boost": 1.0,
|
||||
"beta": 1.0,
|
||||
"current_open_notional": current_open_notional,
|
||||
"current_account_leverage": current_account_leverage,
|
||||
"exchange_leverage": exchange_leverage,
|
||||
"exchange_leverage_mode": "mapped_conservative_integer",
|
||||
"leverage_mapping_rule": LEVERAGE_MAPPING_RULE,
|
||||
"notes": json.dumps(
|
||||
{
|
||||
"account_id": snapshot.account_id,
|
||||
"ledger_authority": snapshot.ledger_authority,
|
||||
"fingerprint": snapshot.fingerprint,
|
||||
"fills_today": fills_today,
|
||||
"filled_orders_today": trades_today,
|
||||
"payload": _json_safe(snapshot.payload),
|
||||
},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def load_latest_snapshot(strategy: str, account_id: str | None = None) -> dict[str, Any] | None:
|
||||
record = load_latest_record(strategy, account_id=account_id)
|
||||
if record is None:
|
||||
return None
|
||||
return record.get("payload")
|
||||
|
||||
|
||||
def load_latest_record(strategy: str, account_id: str | None = None) -> dict[str, Any] | None:
|
||||
clauses = [f"strategy = {json.dumps(strategy)}"]
|
||||
if account_id:
|
||||
clauses.append(f"JSONExtractString(notes, 'account_id') = {json.dumps(account_id)}")
|
||||
where = " AND ".join(clauses)
|
||||
sql = (
|
||||
"SELECT ts, event_type, strategy, notes "
|
||||
f"FROM account_events WHERE {where} ORDER BY ts DESC LIMIT 1 FORMAT JSONEachRow"
|
||||
)
|
||||
url = f"{CH_URL}/?database={_db_for_strategy(strategy)}&query={urllib.parse.quote(sql)}"
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("X-ClickHouse-User", CH_USER)
|
||||
req.add_header("X-ClickHouse-Key", CH_PASS)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
body = resp.read().decode("utf-8").strip()
|
||||
if not body:
|
||||
return None
|
||||
row = json.loads(body)
|
||||
notes = row.get("notes")
|
||||
if not notes:
|
||||
return None
|
||||
parsed = json.loads(notes)
|
||||
return {
|
||||
"ts": row.get("ts"),
|
||||
"event_type": row.get("event_type"),
|
||||
"strategy": row.get("strategy"),
|
||||
"notes": parsed,
|
||||
"payload": parsed.get("payload"),
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def resolve_account_event_rate_cap() -> int:
|
||||
"""Return the configured account event rate cap (rows/sec) per §10.2."""
|
||||
raw = os.environ.get("PINK_ACCOUNT_EVENT_RATE_CAP", "")
|
||||
try:
|
||||
val = int(raw)
|
||||
return max(val, 1)
|
||||
except (TypeError, ValueError):
|
||||
return _ACCOUNT_EVENT_RATE_CAP
|
||||
83
prod/bingx/leverage.py
Normal file
83
prod/bingx/leverage.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from decimal import ROUND_HALF_EVEN
|
||||
from typing import Any
|
||||
|
||||
|
||||
CONVICTION_MIN = 0.5
|
||||
CONVICTION_MAX = 9.0
|
||||
EXCHANGE_LEV_MIN = 1
|
||||
EXCHANGE_LEV_MAX = 3
|
||||
LEVERAGE_MAPPING_RULE = "round_half_even_linear_0.5_to_9.0_to_1_to_exchange_cap"
|
||||
|
||||
|
||||
def _clamp_exchange_bounds(exchange_min: Any, exchange_max: Any) -> tuple[int, int]:
|
||||
lower = int(Decimal(str(exchange_min)))
|
||||
upper = int(Decimal(str(exchange_max)))
|
||||
if lower < 1:
|
||||
lower = 1
|
||||
if upper < lower:
|
||||
upper = lower
|
||||
return lower, upper
|
||||
|
||||
|
||||
def normalize_bingx_leverage_value(
|
||||
leverage: Any,
|
||||
*,
|
||||
exchange_min: Any = EXCHANGE_LEV_MIN,
|
||||
exchange_max: Any = EXCHANGE_LEV_MAX,
|
||||
) -> int:
|
||||
"""
|
||||
BingX exchange leverage is integer-only and conservatively capped.
|
||||
"""
|
||||
lower, upper = _clamp_exchange_bounds(exchange_min, exchange_max)
|
||||
desired = int(
|
||||
Decimal(str(leverage)).quantize(Decimal("1"), rounding=ROUND_HALF_EVEN)
|
||||
)
|
||||
if desired < lower:
|
||||
return lower
|
||||
if desired > upper:
|
||||
return upper
|
||||
return desired
|
||||
|
||||
|
||||
def map_internal_conviction_to_exchange_leverage_target(
|
||||
leverage: Any,
|
||||
*,
|
||||
exchange_min: Any = EXCHANGE_LEV_MIN,
|
||||
exchange_max: Any = EXCHANGE_LEV_MAX,
|
||||
) -> float:
|
||||
"""
|
||||
Map engine conviction/sizing into BingX exchange leverage.
|
||||
|
||||
The engine retains the fractional conviction signal internally. The exchange
|
||||
receives a leverage target derived from that signal.
|
||||
"""
|
||||
lower, upper = _clamp_exchange_bounds(exchange_min, exchange_max)
|
||||
internal = float(Decimal(str(leverage)))
|
||||
internal = max(CONVICTION_MIN, min(CONVICTION_MAX, internal))
|
||||
return float(lower + (
|
||||
(internal - CONVICTION_MIN) / (CONVICTION_MAX - CONVICTION_MIN)
|
||||
) * (upper - lower))
|
||||
|
||||
|
||||
def map_internal_conviction_to_exchange_leverage(
|
||||
leverage: Any,
|
||||
*,
|
||||
exchange_min: Any = EXCHANGE_LEV_MIN,
|
||||
exchange_max: Any = EXCHANGE_LEV_MAX,
|
||||
) -> int:
|
||||
"""
|
||||
Backwards-compatible integer exchange leverage mapper.
|
||||
"""
|
||||
target = map_internal_conviction_to_exchange_leverage_target(
|
||||
leverage,
|
||||
exchange_min=exchange_min,
|
||||
exchange_max=exchange_max,
|
||||
)
|
||||
return normalize_bingx_leverage_value(
|
||||
target,
|
||||
exchange_min=exchange_min,
|
||||
exchange_max=exchange_max,
|
||||
)
|
||||
139
prod/bingx/market_stream.py
Normal file
139
prod/bingx/market_stream.py
Normal file
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import gzip
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Awaitable
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
|
||||
EventHandler = Callable[[dict[str, Any]], Awaitable[None]]
|
||||
HealthHandler = Callable[[bool], None]
|
||||
|
||||
|
||||
class BingxMarketStream:
|
||||
"""
|
||||
Public (unauthenticated) BingX swap-market WebSocket stream.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
ws_url: str,
|
||||
on_event: EventHandler,
|
||||
on_health: HealthHandler | None = None,
|
||||
reconnect_initial_ms: int = 500,
|
||||
reconnect_max_ms: int = 10_000,
|
||||
http_timeout_secs: int = 10,
|
||||
) -> None:
|
||||
self._ws_url = ws_url
|
||||
self._on_event = on_event
|
||||
self._on_health = on_health
|
||||
self._reconnect_initial_ms = int(reconnect_initial_ms)
|
||||
self._reconnect_max_ms = int(reconnect_max_ms)
|
||||
self._http_timeout_secs = int(http_timeout_secs)
|
||||
|
||||
self._closed = asyncio.Event()
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
|
||||
# dataType -> subscription id
|
||||
self._subscriptions: dict[str, str] = {}
|
||||
self._subscriptions_changed = asyncio.Event()
|
||||
|
||||
def subscribe(self, data_type: str) -> None:
|
||||
if data_type in self._subscriptions:
|
||||
return
|
||||
self._subscriptions[data_type] = str(uuid.uuid4())
|
||||
self._subscriptions_changed.set()
|
||||
|
||||
def unsubscribe(self, data_type: str) -> None:
|
||||
if data_type not in self._subscriptions:
|
||||
return
|
||||
self._subscriptions.pop(data_type, None)
|
||||
self._subscriptions_changed.set()
|
||||
|
||||
async def run_forever(self) -> None:
|
||||
delay_ms = self._reconnect_initial_ms
|
||||
while not self._closed.is_set():
|
||||
try:
|
||||
await self._consume()
|
||||
delay_ms = self._reconnect_initial_ms
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
if self._closed.is_set():
|
||||
break
|
||||
await asyncio.sleep(delay_ms / 1000.0)
|
||||
delay_ms = min(delay_ms * 2, self._reconnect_max_ms)
|
||||
finally:
|
||||
self._notify_health(False)
|
||||
await self.close()
|
||||
|
||||
async def close(self) -> None:
|
||||
self._closed.set()
|
||||
if self._session is not None and not self._session.closed:
|
||||
await self._session.close()
|
||||
|
||||
async def _consume(self) -> None:
|
||||
session = await self._get_session()
|
||||
async with session.ws_connect(
|
||||
self._ws_url,
|
||||
autoping=False,
|
||||
autoclose=False,
|
||||
heartbeat=None,
|
||||
compress=0,
|
||||
max_msg_size=0,
|
||||
) as ws:
|
||||
self._notify_health(True)
|
||||
await self._flush_subscriptions(ws)
|
||||
|
||||
async for msg in ws:
|
||||
if msg.type == aiohttp.WSMsgType.CLOSED:
|
||||
break
|
||||
if msg.type == aiohttp.WSMsgType.ERROR:
|
||||
raise ws.exception() or RuntimeError("BingX market socket error")
|
||||
|
||||
if self._subscriptions_changed.is_set():
|
||||
await self._flush_subscriptions(ws)
|
||||
|
||||
text = self._decode_message(msg)
|
||||
if text is None:
|
||||
continue
|
||||
if text == "Ping" or "ping" in text.lower():
|
||||
await ws.send_str("Pong")
|
||||
continue
|
||||
payload = json.loads(text)
|
||||
await self._on_event(payload)
|
||||
|
||||
async def _flush_subscriptions(self, ws: aiohttp.ClientWebSocketResponse) -> None:
|
||||
self._subscriptions_changed.clear()
|
||||
for data_type, sub_id in list(self._subscriptions.items()):
|
||||
await ws.send_json({"id": sub_id, "reqType": "sub", "dataType": data_type})
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
if self._session is None or self._session.closed:
|
||||
timeout = aiohttp.ClientTimeout(total=None, sock_connect=self._http_timeout_secs)
|
||||
connector = aiohttp.TCPConnector(limit=2, ttl_dns_cache=300)
|
||||
self._session = aiohttp.ClientSession(timeout=timeout, connector=connector)
|
||||
return self._session
|
||||
|
||||
def _notify_health(self, healthy: bool) -> None:
|
||||
if self._on_health is not None:
|
||||
self._on_health(healthy)
|
||||
|
||||
@staticmethod
|
||||
def _decode_message(msg: aiohttp.WSMessage) -> str | None:
|
||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||
return str(msg.data)
|
||||
if msg.type == aiohttp.WSMsgType.BINARY:
|
||||
data = bytes(msg.data)
|
||||
try:
|
||||
return gzip.decompress(data).decode("utf-8")
|
||||
except OSError:
|
||||
return data.decode("utf-8")
|
||||
return None
|
||||
|
||||
183
prod/bingx/observer.py
Normal file
183
prod/bingx/observer.py
Normal file
@@ -0,0 +1,183 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .config import BingxExecClientConfig
|
||||
from .http import BingxHttpClient
|
||||
from .websocket import BingxUserStream
|
||||
|
||||
|
||||
TERMINAL_ORDER_STATUSES = {"FILLED", "CANCELED", "CANCELLED", "REJECTED", "EXPIRED"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BingxObservedOrder:
|
||||
key: str
|
||||
row: dict[str, Any]
|
||||
terminal: bool
|
||||
|
||||
|
||||
class BingxOrderUpdateObserver:
|
||||
def __init__(
|
||||
self,
|
||||
client: BingxHttpClient,
|
||||
config: BingxExecClientConfig,
|
||||
*,
|
||||
on_health: Callable[[bool], None] | None = None,
|
||||
) -> None:
|
||||
self._client = client
|
||||
self._config = config
|
||||
self._stream = BingxUserStream(
|
||||
client=client,
|
||||
config=config,
|
||||
on_event=self._on_event,
|
||||
on_health=on_health,
|
||||
)
|
||||
self._task: asyncio.Task | None = None
|
||||
self._lock = asyncio.Lock()
|
||||
self._latest: dict[str, dict[str, Any]] = {}
|
||||
self._events: dict[str, asyncio.Event] = {}
|
||||
self._closed = False
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._task is None:
|
||||
self._task = asyncio.create_task(self._stream.run_forever())
|
||||
|
||||
async def close(self) -> None:
|
||||
self._closed = True
|
||||
await self._stream.close()
|
||||
if self._task is not None:
|
||||
self._task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._task
|
||||
self._task = None
|
||||
|
||||
async def stop(self) -> None:
|
||||
await self.close()
|
||||
|
||||
async def latest(self, key: str) -> dict[str, Any] | None:
|
||||
async with self._lock:
|
||||
row = self._latest.get(key)
|
||||
return dict(row) if isinstance(row, dict) else None
|
||||
|
||||
async def wait_for_terminal(self, key: str, *, timeout_s: float = 20.0) -> BingxObservedOrder | None:
|
||||
deadline = asyncio.get_running_loop().time() + timeout_s
|
||||
last_row: dict[str, Any] | None = None
|
||||
while not self._closed:
|
||||
async with self._lock:
|
||||
row = self._latest.get(key)
|
||||
if isinstance(row, dict):
|
||||
last_row = dict(row)
|
||||
status = str(last_row.get("status") or last_row.get("X") or "").upper()
|
||||
if status in TERMINAL_ORDER_STATUSES:
|
||||
return BingxObservedOrder(key=key, row=last_row, terminal=True)
|
||||
event = self._events.setdefault(key, asyncio.Event())
|
||||
remaining = deadline - asyncio.get_running_loop().time()
|
||||
if remaining <= 0:
|
||||
break
|
||||
try:
|
||||
await asyncio.wait_for(event.wait(), timeout=remaining)
|
||||
except TimeoutError:
|
||||
break
|
||||
finally:
|
||||
event.clear()
|
||||
if last_row is not None:
|
||||
return BingxObservedOrder(key=key, row=last_row, terminal=False)
|
||||
return None
|
||||
|
||||
async def wait_for_fill(self, key: str, *, timeout_s: float = 20.0) -> BingxObservedOrder | None:
|
||||
deadline = asyncio.get_running_loop().time() + timeout_s
|
||||
last_row: dict[str, Any] | None = None
|
||||
while not self._closed:
|
||||
async with self._lock:
|
||||
row = self._latest.get(key)
|
||||
if isinstance(row, dict):
|
||||
last_row = dict(row)
|
||||
last_fill_qty = str(
|
||||
last_row.get("lastFilledQty")
|
||||
or last_row.get("l")
|
||||
or "0",
|
||||
)
|
||||
if last_fill_qty not in {"0", "0.0", "0.00000000", ""}:
|
||||
return BingxObservedOrder(key=key, row=last_row, terminal=False)
|
||||
status = str(last_row.get("status") or last_row.get("X") or "").upper()
|
||||
if status in TERMINAL_ORDER_STATUSES:
|
||||
return BingxObservedOrder(key=key, row=last_row, terminal=True)
|
||||
event = self._events.setdefault(key, asyncio.Event())
|
||||
remaining = deadline - asyncio.get_running_loop().time()
|
||||
if remaining <= 0:
|
||||
break
|
||||
try:
|
||||
await asyncio.wait_for(event.wait(), timeout=remaining)
|
||||
except TimeoutError:
|
||||
break
|
||||
finally:
|
||||
event.clear()
|
||||
if last_row is not None:
|
||||
return BingxObservedOrder(key=key, row=last_row, terminal=False)
|
||||
return None
|
||||
|
||||
async def _on_event(self, payload: dict[str, Any]) -> None:
|
||||
data = payload.get("data") if isinstance(payload.get("data"), dict) else None
|
||||
event_type = str((data or payload).get("e") or "").upper()
|
||||
data_type = str(payload.get("dataType") or "").lower()
|
||||
if event_type not in {"ORDER_TRADE_UPDATE", "EXECUTIONREPORT"} and data_type != "spot.executionreport":
|
||||
return
|
||||
if event_type == "ORDER_TRADE_UPDATE":
|
||||
order_update = payload.get("o")
|
||||
if not isinstance(order_update, dict):
|
||||
return
|
||||
else:
|
||||
source = data or payload
|
||||
order_update = {
|
||||
"s": source.get("s"),
|
||||
"c": source.get("c") or source.get("clientOrderId") or source.get("clientOrderID"),
|
||||
"i": source.get("i") or source.get("orderId") or source.get("orderID"),
|
||||
"X": source.get("X"),
|
||||
"x": source.get("x"),
|
||||
"p": source.get("p") or source.get("price"),
|
||||
"ap": source.get("ap") or source.get("avgPrice"),
|
||||
"z": source.get("z") or source.get("executedQty") or source.get("cumFilledQty"),
|
||||
"l": source.get("l") or source.get("lastFilledQty") or source.get("lastExecutedQty"),
|
||||
"L": source.get("L") or source.get("lastFillPrice") or source.get("avgPrice"),
|
||||
"n": source.get("n") or source.get("commission"),
|
||||
"N": source.get("N") or source.get("commissionAsset"),
|
||||
"positionID": source.get("positionID") or source.get("positionId"),
|
||||
"triggerOrderId": source.get("triggerOrderId"),
|
||||
"mainOrderId": source.get("mainOrderId"),
|
||||
}
|
||||
client_order_id = str(order_update.get("c") or "")
|
||||
order_id = str(order_update.get("i") or "")
|
||||
status = str(order_update.get("X") or order_update.get("x") or "").upper()
|
||||
row = {
|
||||
"symbol": order_update.get("s"),
|
||||
"clientOrderId": client_order_id,
|
||||
"clientOrderID": client_order_id,
|
||||
"orderId": order_id,
|
||||
"orderID": order_id,
|
||||
"status": status,
|
||||
"price": order_update.get("p"),
|
||||
"avgPrice": order_update.get("ap") or order_update.get("L") or order_update.get("p"),
|
||||
"executedQty": order_update.get("z") or "0",
|
||||
"cumFilledQty": order_update.get("z") or "0",
|
||||
"lastFilledQty": order_update.get("l") or "0",
|
||||
"lastFillPrice": order_update.get("L") or order_update.get("ap") or order_update.get("p"),
|
||||
"commission": order_update.get("n") or "0",
|
||||
"commissionAsset": order_update.get("N"),
|
||||
"executionType": order_update.get("x"),
|
||||
"positionID": order_update.get("positionID") or order_update.get("positionId"),
|
||||
"triggerOrderId": order_update.get("triggerOrderId"),
|
||||
"mainOrderId": order_update.get("mainOrderId"),
|
||||
"raw": order_update,
|
||||
}
|
||||
async with self._lock:
|
||||
if client_order_id:
|
||||
self._latest[client_order_id] = row
|
||||
self._events.setdefault(client_order_id, asyncio.Event()).set()
|
||||
if order_id:
|
||||
self._latest[order_id] = row
|
||||
self._events.setdefault(order_id, asyncio.Event()).set()
|
||||
106
prod/bingx/rate_limits.py
Normal file
106
prod/bingx/rate_limits.py
Normal file
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from time import monotonic_ns
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BingxRateLimitSnapshot:
|
||||
rest_remaining: int | None = None
|
||||
rest_reset_ms: int | None = None
|
||||
ws_listenkey_ops: int = 0
|
||||
ws_listenkey_window_ns: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BingxCircuitBreakerSnapshot:
|
||||
failure_count: int = 0
|
||||
open_until_ns: int = 0
|
||||
last_delay_ms: int = 0
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
return monotonic_ns() < self.open_until_ns
|
||||
|
||||
|
||||
class BingxRateLimitTracker:
|
||||
def __init__(self) -> None:
|
||||
self._rest_remaining: int | None = None
|
||||
self._rest_reset_ms: int | None = None
|
||||
self._ws_listenkey_ops = 0
|
||||
self._ws_window_start_ns = monotonic_ns()
|
||||
|
||||
def update_rest_headers(self, headers: dict[str, str]) -> None:
|
||||
remain = headers.get("x-ratelimit-requests-remain")
|
||||
reset = headers.get("x-ratelimit-requests-expire")
|
||||
self._rest_remaining = int(remain) if remain is not None and str(remain).isdigit() else self._rest_remaining
|
||||
self._rest_reset_ms = int(reset) if reset is not None and str(reset).isdigit() else self._rest_reset_ms
|
||||
|
||||
def count_ws_listenkey_op(self) -> None:
|
||||
now = monotonic_ns()
|
||||
if now - self._ws_window_start_ns > 1_000_000_000:
|
||||
self._ws_window_start_ns = now
|
||||
self._ws_listenkey_ops = 0
|
||||
self._ws_listenkey_ops += 1
|
||||
|
||||
def snapshot(self) -> BingxRateLimitSnapshot:
|
||||
return BingxRateLimitSnapshot(
|
||||
rest_remaining=self._rest_remaining,
|
||||
rest_reset_ms=self._rest_reset_ms,
|
||||
ws_listenkey_ops=self._ws_listenkey_ops,
|
||||
ws_listenkey_window_ns=monotonic_ns() - self._ws_window_start_ns,
|
||||
)
|
||||
|
||||
|
||||
class BingxCircuitBreaker:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
failure_threshold: int = 3,
|
||||
base_backoff_ms: int = 250,
|
||||
max_backoff_ms: int = 2_000,
|
||||
) -> None:
|
||||
self._failure_threshold = max(1, int(failure_threshold))
|
||||
self._base_backoff_ms = max(1, int(base_backoff_ms))
|
||||
self._max_backoff_ms = max(self._base_backoff_ms, int(max_backoff_ms))
|
||||
self._failure_count = 0
|
||||
self._open_until_ns = 0
|
||||
self._last_delay_ms = 0
|
||||
|
||||
async def wait_if_open(self) -> None:
|
||||
remaining = self.open_remaining_secs()
|
||||
if remaining > 0:
|
||||
from asyncio import sleep
|
||||
|
||||
await sleep(remaining)
|
||||
|
||||
def open_remaining_secs(self) -> float:
|
||||
remaining_ns = self._open_until_ns - monotonic_ns()
|
||||
return max(0.0, remaining_ns / 1_000_000_000)
|
||||
|
||||
def snapshot(self) -> BingxCircuitBreakerSnapshot:
|
||||
return BingxCircuitBreakerSnapshot(
|
||||
failure_count=self._failure_count,
|
||||
open_until_ns=self._open_until_ns,
|
||||
last_delay_ms=self._last_delay_ms,
|
||||
)
|
||||
|
||||
def record_success(self) -> None:
|
||||
self._failure_count = 0
|
||||
self._open_until_ns = 0
|
||||
self._last_delay_ms = 0
|
||||
|
||||
def record_failure(self, *, rate_limited: bool = False, retry_after_ms: int | None = None) -> float:
|
||||
now_ns = monotonic_ns()
|
||||
if rate_limited and retry_after_ms is not None and retry_after_ms > 0:
|
||||
self._failure_count = self._failure_threshold
|
||||
self._last_delay_ms = retry_after_ms
|
||||
self._open_until_ns = max(self._open_until_ns, now_ns + retry_after_ms * 1_000_000)
|
||||
return retry_after_ms / 1000.0
|
||||
|
||||
self._failure_count += 1
|
||||
delay_ms = min(self._base_backoff_ms * (2 ** (self._failure_count - 1)), self._max_backoff_ms)
|
||||
self._last_delay_ms = delay_ms
|
||||
if self._failure_count >= self._failure_threshold:
|
||||
self._open_until_ns = max(self._open_until_ns, now_ns + delay_ms * 1_000_000)
|
||||
return delay_ms / 1000.0
|
||||
30
prod/bingx/reconciliation.py
Normal file
30
prod/bingx/reconciliation.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
|
||||
_TERMINAL_TRADE_HANDLERS: dict[str, Callable[[dict[str, Any]], Any]] = {}
|
||||
|
||||
|
||||
def register_terminal_trade_handler(account_id: str, handler: Callable[[dict[str, Any]], Any]) -> None:
|
||||
key = str(account_id or "").strip()
|
||||
if not key:
|
||||
return
|
||||
_TERMINAL_TRADE_HANDLERS[key] = handler
|
||||
|
||||
|
||||
def unregister_terminal_trade_handler(account_id: str, handler: Callable[[dict[str, Any]], Any] | None = None) -> None:
|
||||
key = str(account_id or "").strip()
|
||||
if not key:
|
||||
return
|
||||
current = _TERMINAL_TRADE_HANDLERS.get(key)
|
||||
if handler is None or current is handler:
|
||||
_TERMINAL_TRADE_HANDLERS.pop(key, None)
|
||||
|
||||
|
||||
def get_terminal_trade_handler(account_id: str) -> Callable[[dict[str, Any]], Any] | None:
|
||||
key = str(account_id or "").strip()
|
||||
if not key:
|
||||
return None
|
||||
return _TERMINAL_TRADE_HANDLERS.get(key)
|
||||
126
prod/bingx/sandbox_status.py
Normal file
126
prod/bingx/sandbox_status.py
Normal file
@@ -0,0 +1,126 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
DEFAULT_SANDBOX_STATUS_PATH = Path("/tmp/bingx_sandbox_status.json")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BingxSandboxStatus:
|
||||
"""Small sidecar snapshot for BingX demo/testnet state.
|
||||
|
||||
The snapshot is intentionally local-only so it can be used by tests and
|
||||
operators without writing into BLUE state, ClickHouse, or production logs.
|
||||
"""
|
||||
|
||||
ts: str
|
||||
environment: str
|
||||
balance: float
|
||||
equity: float
|
||||
available_margin: float
|
||||
unrealized_profit: float
|
||||
used_margin: float
|
||||
open_positions: int
|
||||
open_orders: int
|
||||
account_currency: str = "VST"
|
||||
clean: bool = False
|
||||
notes: dict[str, Any] | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"ts": self.ts,
|
||||
"environment": self.environment,
|
||||
"account_currency": self.account_currency,
|
||||
"balance": self.balance,
|
||||
"equity": self.equity,
|
||||
"available_margin": self.available_margin,
|
||||
"unrealized_profit": self.unrealized_profit,
|
||||
"used_margin": self.used_margin,
|
||||
"open_positions": self.open_positions,
|
||||
"open_orders": self.open_orders,
|
||||
"clean": self.clean,
|
||||
"notes": self.notes or {},
|
||||
}
|
||||
|
||||
|
||||
def _safe_float(value: Any, default: float = 0.0) -> float:
|
||||
try:
|
||||
out = float(value)
|
||||
except Exception:
|
||||
return default
|
||||
return out if out == out else default
|
||||
|
||||
|
||||
def _count_positions(positions: Any) -> int:
|
||||
if isinstance(positions, list):
|
||||
return sum(1 for item in positions if isinstance(item, dict))
|
||||
return 0
|
||||
|
||||
|
||||
def _count_orders(open_orders: Any) -> int:
|
||||
if isinstance(open_orders, dict):
|
||||
orders = open_orders.get("orders")
|
||||
if isinstance(orders, list):
|
||||
return sum(1 for item in orders if isinstance(item, dict))
|
||||
if isinstance(open_orders, list):
|
||||
return sum(1 for item in open_orders if isinstance(item, dict))
|
||||
return 0
|
||||
|
||||
|
||||
def build_sandbox_status(
|
||||
*,
|
||||
balance_payload: dict[str, Any],
|
||||
positions_payload: Any,
|
||||
open_orders_payload: Any,
|
||||
environment: str = "VST",
|
||||
account_currency: str = "VST",
|
||||
notes: dict[str, Any] | None = None,
|
||||
) -> BingxSandboxStatus:
|
||||
balance_row = balance_payload.get("balance", balance_payload) if isinstance(balance_payload, dict) else {}
|
||||
if not isinstance(balance_row, dict):
|
||||
balance_row = {}
|
||||
balance = _safe_float(balance_row.get("balance"), 0.0)
|
||||
equity = _safe_float(balance_row.get("equity"), balance)
|
||||
available_margin = _safe_float(balance_row.get("availableMargin"), 0.0)
|
||||
unrealized_profit = _safe_float(balance_row.get("unrealizedProfit"), 0.0)
|
||||
used_margin = _safe_float(balance_row.get("usedMargin"), 0.0)
|
||||
open_positions = _count_positions(positions_payload)
|
||||
open_orders = _count_orders(open_orders_payload)
|
||||
return BingxSandboxStatus(
|
||||
ts=datetime.now(timezone.utc).isoformat(),
|
||||
environment=str(environment),
|
||||
account_currency=str(account_currency),
|
||||
balance=balance,
|
||||
equity=equity,
|
||||
available_margin=available_margin,
|
||||
unrealized_profit=unrealized_profit,
|
||||
used_margin=used_margin,
|
||||
open_positions=open_positions,
|
||||
open_orders=open_orders,
|
||||
clean=(open_positions == 0 and open_orders == 0),
|
||||
notes=notes or {},
|
||||
)
|
||||
|
||||
|
||||
def snapshot_path(path: str | Path | None = None) -> Path:
|
||||
return Path(path) if path is not None else DEFAULT_SANDBOX_STATUS_PATH
|
||||
|
||||
|
||||
def write_sandbox_status(status: BingxSandboxStatus, path: str | Path | None = None) -> Path:
|
||||
target = snapshot_path(path)
|
||||
target.write_text(json.dumps(status.to_dict(), indent=2, sort_keys=True))
|
||||
return target
|
||||
|
||||
|
||||
def load_sandbox_status(path: str | Path | None = None) -> dict[str, Any] | None:
|
||||
target = snapshot_path(path)
|
||||
if not target.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(target.read_text())
|
||||
except Exception:
|
||||
return None
|
||||
80
prod/bingx/schemas.py
Normal file
80
prod/bingx/schemas.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
def _as_decimal(value: object, default: str = "0") -> Decimal:
|
||||
if value is None:
|
||||
return Decimal(default)
|
||||
return Decimal(str(value))
|
||||
|
||||
|
||||
def unwrap_order_payload(payload: dict[str, object]) -> dict[str, object]:
|
||||
row = payload.get("order") if isinstance(payload, dict) else None
|
||||
return row if isinstance(row, dict) else payload
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BingxContract:
|
||||
symbol: str
|
||||
venue_symbol: str
|
||||
quote_asset: str
|
||||
base_asset: str
|
||||
price_precision: int
|
||||
quantity_precision: int
|
||||
min_quantity: Decimal
|
||||
min_notional: Decimal
|
||||
tick_size: Decimal
|
||||
step_size: Decimal
|
||||
maker_fee: Decimal
|
||||
taker_fee: Decimal
|
||||
max_leverage: int
|
||||
|
||||
@classmethod
|
||||
def from_http(cls, payload: dict[str, object]) -> "BingxContract":
|
||||
venue_symbol = str(payload.get("symbol") or payload.get("ticker") or "")
|
||||
normalized = venue_symbol.replace("-", "")
|
||||
price_precision = int(payload.get("pricePrecision") or payload.get("price_scale") or 2)
|
||||
quantity_precision = int(
|
||||
payload.get("quantityPrecision") or payload.get("quantity_scale") or 3,
|
||||
)
|
||||
tick_size = _as_decimal(
|
||||
payload.get("tickSize") or payload.get("priceStep") or f"1e-{price_precision}",
|
||||
)
|
||||
step_size = _as_decimal(
|
||||
payload.get("stepSize") or payload.get("quantityStep") or f"1e-{quantity_precision}",
|
||||
)
|
||||
return cls(
|
||||
symbol=normalized,
|
||||
venue_symbol=venue_symbol,
|
||||
quote_asset=str(payload.get("currency") or payload.get("quoteAsset") or "USDT"),
|
||||
base_asset=str(payload.get("asset") or payload.get("baseAsset") or normalized[:-4]),
|
||||
price_precision=price_precision,
|
||||
quantity_precision=quantity_precision,
|
||||
min_quantity=_as_decimal(payload.get("minQty") or payload.get("minQuantity") or step_size),
|
||||
min_notional=_as_decimal(payload.get("minNotional") or payload.get("minQuoteAmount") or "2"),
|
||||
tick_size=tick_size,
|
||||
step_size=step_size,
|
||||
maker_fee=_as_decimal(payload.get("makerFeeRate") or payload.get("makerFee") or "0.0002"),
|
||||
taker_fee=_as_decimal(payload.get("takerFeeRate") or payload.get("takerFee") or "0.0005"),
|
||||
max_leverage=int(payload.get("maxLongLeverage") or payload.get("maxLeverage") or 1),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BingxOrderAck:
|
||||
order_id: str
|
||||
client_order_id: str
|
||||
symbol: str
|
||||
status: str | None
|
||||
|
||||
@classmethod
|
||||
def from_http(cls, payload: dict[str, object]) -> "BingxOrderAck":
|
||||
row = unwrap_order_payload(payload)
|
||||
return cls(
|
||||
order_id=str(row.get("orderId") or row.get("id") or ""),
|
||||
client_order_id=str(row.get("clientOrderID") or row.get("clientOrderId") or ""),
|
||||
symbol=str(row.get("symbol") or ""),
|
||||
status=str(row.get("status")) if row.get("status") is not None else None,
|
||||
)
|
||||
48
prod/bingx/signing.py
Normal file
48
prod/bingx/signing.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from urllib.parse import urlencode
|
||||
|
||||
|
||||
def utc_timestamp_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
def canonical_query(params: Mapping[str, object]) -> str:
|
||||
filtered = {
|
||||
key: value
|
||||
for key, value in params.items()
|
||||
if value is not None and value != ""
|
||||
}
|
||||
ordered = sorted(filtered.items(), key=lambda item: item[0])
|
||||
return urlencode(ordered, doseq=True)
|
||||
|
||||
|
||||
def sign_query(secret_key: str, query: str) -> str:
|
||||
return hmac.new(
|
||||
secret_key.encode("utf-8"),
|
||||
query.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def build_signed_params(
|
||||
params: Mapping[str, object],
|
||||
secret_key: str,
|
||||
*,
|
||||
timestamp_ms: int | None = None,
|
||||
recv_window_ms: int | None = 5_000,
|
||||
) -> dict[str, object]:
|
||||
signed = dict(params)
|
||||
signed["timestamp"] = utc_timestamp_ms() if timestamp_ms is None else int(timestamp_ms)
|
||||
try:
|
||||
parsed_recv_window = int(recv_window_ms) if recv_window_ms is not None else 5_000
|
||||
except Exception:
|
||||
parsed_recv_window = 5_000
|
||||
signed["recvWindow"] = parsed_recv_window if parsed_recv_window > 0 else 5_000
|
||||
query = canonical_query(signed)
|
||||
signed["signature"] = sign_query(secret_key, query)
|
||||
return signed
|
||||
71
prod/bingx/sizing_mode.py
Normal file
71
prod/bingx/sizing_mode.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import Literal
|
||||
from typing import Mapping
|
||||
|
||||
from prod.utils.trade_sizing_bridge import TradeSizingDefaults
|
||||
from prod.utils.trade_sizing_bridge import build_engine_ready_sizing
|
||||
from prod.utils.trade_sizing_bridge import load_trade_sizing_defaults_from_control_plane
|
||||
|
||||
SizingMode = Literal["engine", "testnet", "live_market"]
|
||||
|
||||
ENGINE_MODE: SizingMode = "engine"
|
||||
TESTNET_MODE: SizingMode = "testnet"
|
||||
LIVE_MARKET_MODE: SizingMode = "live_market"
|
||||
|
||||
|
||||
def normalize_sizing_mode(mode: Any) -> SizingMode:
|
||||
"""Normalize a caller-provided sizing mode."""
|
||||
if isinstance(mode, str):
|
||||
normalized = mode.strip().lower()
|
||||
if normalized == TESTNET_MODE:
|
||||
return TESTNET_MODE
|
||||
if normalized == LIVE_MARKET_MODE:
|
||||
return LIVE_MARKET_MODE
|
||||
return ENGINE_MODE
|
||||
|
||||
|
||||
def build_split_sizing_payload(
|
||||
*,
|
||||
sizing_mode: Any = ENGINE_MODE,
|
||||
sizing_lev: float,
|
||||
capital: float | None = None,
|
||||
mark_price: float | None = None,
|
||||
quantity_step: float | None = None,
|
||||
venue_notional_cap: float | None = None,
|
||||
exchange_leverage_cap: int | None = None,
|
||||
margin_budget_fraction_override: float | None = None,
|
||||
system_fraction_override: float | None = None,
|
||||
control_plane: Mapping[str, Any] | None = None,
|
||||
hz_client: Any | None = None,
|
||||
defaults: TradeSizingDefaults | None = None,
|
||||
notes: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return a BingX-ready sizing payload in testnet or live-market mode."""
|
||||
mode = normalize_sizing_mode(sizing_mode)
|
||||
if mode == ENGINE_MODE:
|
||||
return None
|
||||
|
||||
resolved_defaults = defaults or load_trade_sizing_defaults_from_control_plane(
|
||||
hz_client=hz_client,
|
||||
control_plane=control_plane,
|
||||
fallback=TradeSizingDefaults(),
|
||||
)
|
||||
result = build_engine_ready_sizing(
|
||||
sizing_lev=sizing_lev,
|
||||
capital=capital,
|
||||
mark_price=mark_price,
|
||||
quantity_step=quantity_step,
|
||||
venue_notional_cap=venue_notional_cap,
|
||||
exchange_leverage_cap=exchange_leverage_cap,
|
||||
margin_budget_fraction_override=margin_budget_fraction_override,
|
||||
system_fraction_override=system_fraction_override,
|
||||
control_plane=control_plane,
|
||||
hz_client=hz_client,
|
||||
defaults=resolved_defaults,
|
||||
notes=notes or {},
|
||||
)
|
||||
payload = result.to_engine_payload()
|
||||
payload["sizing_mode"] = mode
|
||||
return payload
|
||||
31
prod/bingx/urls.py
Normal file
31
prod/bingx/urls.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .enums import BingxEnvironment
|
||||
|
||||
|
||||
_REST_BASE_URLS: dict[BingxEnvironment, tuple[str, str]] = {
|
||||
BingxEnvironment.LIVE: ("https://open-api.bingx.com", "https://open-api.bingx.pro"),
|
||||
BingxEnvironment.VST: ("https://open-api-vst.bingx.com", "https://open-api-vst.bingx.pro"),
|
||||
}
|
||||
|
||||
_WS_PRIVATE_URLS: dict[BingxEnvironment, str | None] = {
|
||||
BingxEnvironment.LIVE: "wss://open-api-swap.bingx.com/swap-market",
|
||||
BingxEnvironment.VST: "wss://vst-open-api-ws.bingx.com/swap-market",
|
||||
}
|
||||
|
||||
_WS_PUBLIC_URLS: dict[BingxEnvironment, str] = {
|
||||
BingxEnvironment.LIVE: "wss://open-api-swap.bingx.com/swap-market",
|
||||
BingxEnvironment.VST: "wss://vst-open-api-ws.bingx.com/swap-market",
|
||||
}
|
||||
|
||||
|
||||
def get_rest_base_urls(environment: BingxEnvironment) -> tuple[str, str]:
|
||||
return _REST_BASE_URLS[environment]
|
||||
|
||||
|
||||
def get_private_ws_url(environment: BingxEnvironment) -> str | None:
|
||||
return _WS_PRIVATE_URLS[environment]
|
||||
|
||||
|
||||
def get_public_ws_url(environment: BingxEnvironment) -> str:
|
||||
return _WS_PUBLIC_URLS[environment]
|
||||
152
prod/bingx/websocket.py
Normal file
152
prod/bingx/websocket.py
Normal file
@@ -0,0 +1,152 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import gzip
|
||||
import json
|
||||
from collections.abc import Awaitable
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .config import BingxExecClientConfig
|
||||
from .http import BingxHttpClient
|
||||
from .rate_limits import BingxRateLimitTracker
|
||||
from .urls import get_private_ws_url
|
||||
|
||||
|
||||
EventHandler = Callable[[dict[str, Any]], Awaitable[None]]
|
||||
HealthHandler = Callable[[bool], None]
|
||||
|
||||
|
||||
class BingxUserStream:
|
||||
def __init__(
|
||||
self,
|
||||
client: BingxHttpClient,
|
||||
config: BingxExecClientConfig,
|
||||
on_event: EventHandler,
|
||||
on_health: HealthHandler | None = None,
|
||||
) -> None:
|
||||
self._client = client
|
||||
self._config = config
|
||||
self._on_event = on_event
|
||||
self._on_health = on_health
|
||||
self._rate_limits: BingxRateLimitTracker = client.rate_limits
|
||||
self._closed = asyncio.Event()
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
|
||||
async def run_forever(self) -> None:
|
||||
delay_ms = int(self._config.ws_reconnect_initial_ms)
|
||||
max_delay_ms = int(self._config.ws_reconnect_max_ms)
|
||||
while not self._closed.is_set():
|
||||
listen_key: str | None = None
|
||||
keepalive_task: asyncio.Task | None = None
|
||||
try:
|
||||
listen_key = await self._create_listen_key()
|
||||
keepalive_task = asyncio.create_task(self._keepalive_loop(listen_key))
|
||||
await self._consume(listen_key)
|
||||
delay_ms = int(self._config.ws_reconnect_initial_ms)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
if self._closed.is_set():
|
||||
break
|
||||
await asyncio.sleep(delay_ms / 1000.0)
|
||||
delay_ms = min(delay_ms * 2, max_delay_ms)
|
||||
finally:
|
||||
self._notify_health(False)
|
||||
if keepalive_task is not None:
|
||||
keepalive_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await keepalive_task
|
||||
if listen_key is not None:
|
||||
with contextlib.suppress(Exception, asyncio.CancelledError):
|
||||
await self._delete_listen_key(listen_key)
|
||||
await self.close()
|
||||
|
||||
async def close(self) -> None:
|
||||
self._closed.set()
|
||||
if self._session is not None and not self._session.closed:
|
||||
await self._session.close()
|
||||
|
||||
async def _consume(self, listen_key: str) -> None:
|
||||
ws_base = self._config.base_url_ws_private or get_private_ws_url(self._config.environment)
|
||||
if not ws_base:
|
||||
raise RuntimeError(f"No BingX private WS URL configured for {self._config.environment.value}")
|
||||
session = await self._get_session()
|
||||
ws_url = f"{ws_base}?listenKey={listen_key}"
|
||||
async with session.ws_connect(
|
||||
ws_url,
|
||||
autoping=False,
|
||||
autoclose=False,
|
||||
heartbeat=None,
|
||||
compress=0,
|
||||
max_msg_size=0,
|
||||
) as ws:
|
||||
self._notify_health(True)
|
||||
async for msg in ws:
|
||||
if msg.type == aiohttp.WSMsgType.CLOSED:
|
||||
break
|
||||
if msg.type == aiohttp.WSMsgType.ERROR:
|
||||
raise ws.exception() or RuntimeError("BingX user stream socket error")
|
||||
text = self._decode_message(msg)
|
||||
if text is None:
|
||||
continue
|
||||
if text == "Ping" or "ping" in text.lower():
|
||||
await ws.send_str("Pong")
|
||||
continue
|
||||
payload = json.loads(text)
|
||||
await self._on_event(payload)
|
||||
if payload.get("e") == "listenKeyExpired":
|
||||
raise RuntimeError("BingX listen key expired")
|
||||
|
||||
async def _create_listen_key(self) -> str:
|
||||
self._rate_limits.count_ws_listenkey_op()
|
||||
response = await self._client.signed_post_raw("/openApi/user/auth/userDataStream", {})
|
||||
listen_key = str(response.get("listenKey") or "")
|
||||
if not listen_key:
|
||||
raise RuntimeError("BingX listen key was empty")
|
||||
return listen_key
|
||||
|
||||
async def _keepalive_loop(self, listen_key: str) -> None:
|
||||
interval_secs = int(self._config.ws_listenkey_keepalive_interval_secs)
|
||||
while not self._closed.is_set():
|
||||
await asyncio.sleep(interval_secs)
|
||||
self._rate_limits.count_ws_listenkey_op()
|
||||
await self._client.signed_put_raw(
|
||||
"/openApi/user/auth/userDataStream",
|
||||
{"listenKey": listen_key},
|
||||
allow_empty=True,
|
||||
)
|
||||
|
||||
async def _delete_listen_key(self, listen_key: str) -> None:
|
||||
self._rate_limits.count_ws_listenkey_op()
|
||||
await self._client.signed_delete_raw(
|
||||
"/openApi/user/auth/userDataStream",
|
||||
{"listenKey": listen_key},
|
||||
allow_empty=True,
|
||||
)
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
if self._session is None or self._session.closed:
|
||||
timeout = aiohttp.ClientTimeout(total=None, sock_connect=self._config.http_timeout_secs)
|
||||
connector = aiohttp.TCPConnector(limit=4, ttl_dns_cache=300)
|
||||
self._session = aiohttp.ClientSession(timeout=timeout, connector=connector)
|
||||
return self._session
|
||||
|
||||
def _notify_health(self, healthy: bool) -> None:
|
||||
if self._on_health is not None:
|
||||
self._on_health(healthy)
|
||||
|
||||
@staticmethod
|
||||
def _decode_message(msg: aiohttp.WSMessage) -> str | None:
|
||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||
return str(msg.data)
|
||||
if msg.type == aiohttp.WSMsgType.BINARY:
|
||||
data = bytes(msg.data)
|
||||
try:
|
||||
return gzip.decompress(data).decode("utf-8")
|
||||
except OSError:
|
||||
return data.decode("utf-8")
|
||||
return None
|
||||
Reference in New Issue
Block a user