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:
Codex
2026-06-12 15:09:32 +02:00
parent c3a18f693a
commit 84e4a50e3f
67 changed files with 15090 additions and 0 deletions

19
prod/bingx/__init__.py Normal file
View 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",
]

View 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
View 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
View 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
View 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."
)

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

38
prod/bingx/factories.py Normal file
View 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
View 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
View 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

View 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
View 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
View 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
View 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
View 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
View 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

View 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)

View 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
View 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
View 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
View 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
View 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
View 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