PINK DITAv2: Hz writes + vol_ok gate + leverage logging + 8 new tests (94/94 green)

This commit is contained in:
Codex
2026-06-03 13:26:36 +02:00
parent 0f2d3f556d
commit 8d85d75ded
6 changed files with 1570 additions and 6 deletions

View File

@@ -0,0 +1,513 @@
"""Direct BingX execution adapter with no Nautilus Trader node dependency.
This adapter speaks BingX REST directly and keeps the exchange state
authoritative. It is intended for PINK live execution under the DITA boundary.
"""
from __future__ import annotations
import asyncio
import json
import logging
import math
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from decimal import Decimal, ROUND_DOWN
from typing import Any, Optional
from nautilus_trader.model.identifiers import InstrumentId
from prod.bingx.config import BingxExecClientConfig
from prod.bingx.config import BingxInstrumentProviderConfig
from prod.bingx.enums import BingxEnvironment
from prod.bingx.http import BingxHttpError
from prod.bingx.http import BingxHttpClient
from prod.bingx.instrument_provider import BingxInstrumentProvider
from prod.bingx.leverage import normalize_bingx_leverage_value
from prod.bingx.schemas import BingxOrderAck
from prod.bingx.schemas import unwrap_order_payload
from prod.clean_arch.dita import Intent, TradeSide, DecisionAction
from prod.clean_arch.ports.execution import ExchangeStateSnapshot
from prod.clean_arch.ports.execution import ExecutionReceipt
from prod.clean_arch.ports.execution import ExecutionPort
LOGGER = logging.getLogger(__name__)
def _rows_from_payload(payload: Any, *keys: str) -> list[dict[str, Any]]:
if isinstance(payload, list):
return [row for row in payload if isinstance(row, dict)]
if isinstance(payload, dict):
for key in keys:
rows = payload.get(key)
if isinstance(rows, list):
return [row for row in rows if isinstance(row, dict)]
return []
def _capital_from_balance_rows(rows: Any) -> float:
if not isinstance(rows, list):
return 0.0
for row in rows:
if not isinstance(row, dict):
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 math.isfinite(capital):
return capital
if capital > 0 and math.isfinite(capital):
return capital
return 0.0
def _position_notional_from_rows(rows: Any) -> float:
if not isinstance(rows, list):
return 0.0
total = 0.0
for row in rows:
if not isinstance(row, dict):
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:
continue
return total
def _normalize_symbol(symbol: str) -> str:
return str(symbol or "").replace("-", "").replace("_", "").replace("/","").upper()
def _venue_symbol_from_asset(asset: str) -> str:
text = _normalize_symbol(asset)
if text.endswith("USDT"):
return f"{text[:-4]}-USDT"
return text
def _decimal_text(value: Decimal) -> str:
text = format(value.normalize(), "f")
if "." in text:
text = text.rstrip("0").rstrip(".")
return text or "0"
def _is_rate_limited_error(exc: Exception) -> bool:
message = str(exc)
lowered = message.lower()
return "100410" in message or "frequency limit" in lowered or "rate limit" in lowered
@dataclass(frozen=True)
class BingxDirectExecutionConfig:
"""Execution-specific knobs for the direct adapter."""
environment: BingxEnvironment = BingxEnvironment.VST
allow_mainnet: bool = False
default_leverage: int = 1
exchange_leverage_cap: int = 3
recv_window_ms: int = 5_000
prefer_websocket: bool = False
use_reduce_only: bool = True
journal_strategy: str = "pink"
journal_db: str = "dolphin_pink"
instrument_provider: BingxInstrumentProviderConfig = BingxInstrumentProviderConfig(load_all=True)
class BingxDirectExecutionAdapter(ExecutionPort):
"""Direct BingX execution boundary with exchange-led state snapshots."""
def __init__(
self,
config: BingxExecClientConfig | BingxDirectExecutionConfig,
*,
client: BingxHttpClient | None = None,
provider: BingxInstrumentProvider | None = None,
) -> None:
if isinstance(config, BingxExecClientConfig):
self._config = BingxDirectExecutionConfig(
environment=config.environment,
allow_mainnet=config.allow_mainnet,
default_leverage=int(config.default_leverage),
exchange_leverage_cap=int(config.exchange_leverage_cap),
recv_window_ms=int(config.recv_window_ms),
prefer_websocket=bool(config.prefer_websocket),
use_reduce_only=bool(config.use_reduce_only),
journal_strategy=str(config.journal_strategy or "pink"),
journal_db=str(config.journal_db or "dolphin_pink"),
instrument_provider=config.instrument_provider,
)
http_config = config
else:
self._config = config
http_config = BingxExecClientConfig(
api_key="",
secret_key="",
environment=config.environment,
allow_mainnet=config.allow_mainnet,
prefer_websocket=config.prefer_websocket,
sizing_mode="testnet",
exchange_leverage_cap=config.exchange_leverage_cap,
use_reduce_only=config.use_reduce_only,
default_leverage=config.default_leverage,
recv_window_ms=config.recv_window_ms,
journal_strategy=config.journal_strategy,
journal_db=config.journal_db,
instrument_provider=config.instrument_provider,
)
self._client = client or BingxHttpClient(http_config)
self._provider = provider or BingxInstrumentProvider(client=self._client, config=self._config.instrument_provider)
self._log = LOGGER
self._client_order_run_id = uuid.uuid4().hex[:8]
self._entry_client_order_seq = 0
self._exit_client_order_seq = 0
self._state: ExchangeStateSnapshot | None = None
self._connected = False
@property
def state(self) -> ExchangeStateSnapshot | None:
return self._state
async def connect(self) -> bool:
await self._provider.initialize()
self._connected = True
self._state = await self.refresh_state()
return True
async def disconnect(self) -> None:
self._connected = False
await self._client.close()
def _resolve_instrument(self, asset: str):
normalized = _normalize_symbol(asset)
candidates = [
InstrumentId.from_str(f"{normalized}.BINGX"),
InstrumentId.from_str(f"{_venue_symbol_from_asset(asset)}.BINGX"),
]
for candidate in candidates:
instrument = self._provider.find(candidate)
if instrument is not None:
return instrument
for instrument in self._provider.list_all():
if _normalize_symbol(instrument.symbol.value) == normalized:
return instrument
if _normalize_symbol(instrument.raw_symbol.value) == normalized:
return instrument
return None
def _instrument_venue_symbol(self, asset: str) -> str:
instrument = self._resolve_instrument(asset)
if instrument is not None:
return str(instrument.raw_symbol.value)
return _venue_symbol_from_asset(asset)
def _instrument_step(self, asset: str) -> Decimal:
instrument = self._resolve_instrument(asset)
if instrument is not None:
try:
return Decimal(str(instrument.size_increment.as_decimal()))
except Exception:
pass
return Decimal("0.001")
def _format_quantity(self, asset: str, quantity: float) -> str:
step = self._instrument_step(asset)
if step <= 0:
return str(max(0.0, quantity))
value = Decimal(str(quantity))
quantized = (value / step).to_integral_value(rounding=ROUND_DOWN) * step
return _decimal_text(max(Decimal("0"), quantized))
def _instrument_tick(self, asset: str) -> Decimal:
instrument = self._resolve_instrument(asset)
if instrument is not None:
try:
tick = getattr(instrument, "price_increment", None)
if tick is not None:
return Decimal(str(tick.as_decimal()))
except Exception:
pass
return Decimal("0.01")
def _format_price(self, asset: str, price: float) -> str:
tick = self._instrument_tick(asset)
if tick <= 0:
return f"{price:.8f}".rstrip("0").rstrip(".")
value = Decimal(str(price))
quantized = (value / tick).to_integral_value(rounding=ROUND_DOWN) * tick
return _decimal_text(max(Decimal("0"), quantized))
async def _safe_get(self, endpoint: str, params: dict | None = None, *, fallback: Any = None) -> Any:
"""GET an endpoint, returning *fallback* on rate-limit errors."""
try:
return await self._client.signed_get(endpoint, params)
except BingxHttpError as exc:
message = str(exc)
if "100410" in message or "frequency limit" in message.lower():
LOGGER.debug("BingX %s rate-limited; continuing with empty snapshot", endpoint)
return fallback if fallback is not None else []
raise
async def _refresh_exchange_state(self, symbol: str | None = None, *, include_history: bool = False) -> ExchangeStateSnapshot:
"""Fetch exchange state with parallel HTTP calls.
The three primary calls (balance, positions, openOrders) are
independent and run concurrently via ``asyncio.gather``. Each has
its own rate-limit fallback so a single throttle does not block
the others. Historical calls (allOrders, allFillOrders) are gated
on ``include_history`` and also gathered.
"""
balance_task = self._safe_get("/openApi/swap/v2/user/balance")
positions_task = self._safe_get("/openApi/swap/v2/user/positions")
orders_task = self._safe_get("/openApi/swap/v2/trade/openOrders")
balance_payload, positions_payload, open_orders_payload = await asyncio.gather(
balance_task, positions_task, orders_task,
)
all_orders_payload: Any = []
all_fills_payload: Any = []
if include_history and symbol is not None:
venue_symbol = self._instrument_venue_symbol(symbol)
hist_tasks = asyncio.gather(
self._safe_get("/openApi/swap/v2/trade/allOrders", {"symbol": venue_symbol}),
self._safe_get("/openApi/swap/v2/trade/allFillOrders", {"symbol": venue_symbol}),
return_exceptions=True,
)
results = await hist_tasks
all_orders_payload = results[0] if not isinstance(results[0], Exception) else []
all_fills_payload = results[1] if not isinstance(results[1], Exception) else []
# Parse results (shared logic, same as before)
if isinstance(balance_payload, list):
balances = balance_payload
elif isinstance(balance_payload, dict):
rows_raw = balance_payload.get("balance") or balance_payload.get("balances") or balance_payload.get("data")
if isinstance(rows_raw, dict):
balances = [rows_raw]
elif isinstance(rows_raw, list):
balances = rows_raw
else:
balances = []
else:
balances = []
positions_rows = _rows_from_payload(positions_payload, "positions", "data")
positions: dict[str, dict[str, Any]] = {}
for row in positions_rows:
raw_symbol = str(row.get("symbol") or row.get("symbolName") or row.get("venueSymbol") or "")
key = _normalize_symbol(raw_symbol)
if not key:
continue
positions[key] = dict(row)
open_orders = _rows_from_payload(open_orders_payload, "orders", "data")
capital = _capital_from_balance_rows(balances)
open_notional = _position_notional_from_rows(positions_rows)
equity = capital
if open_notional > 0 and positions_rows:
equity = capital
snapshot = ExchangeStateSnapshot(
timestamp=datetime.now(timezone.utc),
capital=capital,
equity=equity,
open_positions=positions,
open_orders=[dict(row) for row in open_orders],
all_orders=[dict(row) for row in _rows_from_payload(all_orders_payload, "orders", "data")],
all_fills=[dict(row) for row in _rows_from_payload(all_fills_payload, "fills", "data")],
account={"balances": balances},
open_notional=open_notional,
source="bingx",
recovered=bool(include_history),
)
self._state = snapshot
return snapshot
async def refresh_state(self, symbol: str | None = None, *, include_history: bool = False) -> ExchangeStateSnapshot:
return await self._refresh_exchange_state(symbol, include_history=include_history)
async def submit_intent(self, intent: Intent) -> ExecutionReceipt:
symbol = self._instrument_venue_symbol(intent.asset)
if intent.action == DecisionAction.EXIT:
side = "SELL" if intent.side == TradeSide.LONG else "BUY"
else:
side = "BUY" if intent.side == TradeSide.LONG else "SELL"
# Entries must be free to open the slot; only exits are reduce-only.
reduce_only = bool(intent.action == DecisionAction.EXIT)
if reduce_only:
self._exit_client_order_seq += 1
client_order_id = f"pink:{self._client_order_run_id}:x{self._exit_client_order_seq:02d}"
else:
self._entry_client_order_seq += 1
client_order_id = f"pink:{self._client_order_run_id}:e{self._entry_client_order_seq:02d}"
leverage = normalize_bingx_leverage_value(
int(round(float(intent.leverage or self._config.default_leverage))),
exchange_max=self._config.exchange_leverage_cap,
)
try:
await self._client.signed_post(
"/openApi/swap/v2/trade/leverage",
{"symbol": symbol, "side": "BOTH", "leverage": leverage},
)
except Exception as _lev_exc:
# W: leverage POST failed — order will execute at whatever leverage the
# exchange currently has for this symbol. Log prominently; do NOT abort
# the submit because the order may still succeed at the right leverage.
import logging as _logging
_logging.getLogger(__name__).warning(
"BingX leverage set failed (symbol=%s lev=%s): %s — proceeding with submit",
symbol, leverage, _lev_exc,
)
try:
# Honor the order type forwarded by the venue adapter
# (bingx_venue._legacy_intent sets _order_type/_limit_price). MARKET
# is the default; a LIMIT carries a resting price + GTC and will not
# fill synchronously — the async-fill pump settles it later.
order_type = str((intent.metadata or {}).get("_order_type", "MARKET") or "MARKET").upper()
limit_price = float((intent.metadata or {}).get("_limit_price", 0.0) or 0.0)
is_limit = order_type == "LIMIT" and limit_price > 0.0
payload: dict[str, Any] = {
"symbol": symbol,
"side": side,
"positionSide": "BOTH",
"type": "LIMIT" if is_limit else "MARKET",
"quantity": self._format_quantity(intent.asset, intent.target_size),
"clientOrderId": client_order_id,
"recvWindow": str(int(self._config.recv_window_ms)),
}
if is_limit:
payload["price"] = self._format_price(intent.asset, limit_price)
payload["timeInForce"] = "GTC"
if reduce_only:
payload["reduceOnly"] = "true"
ack_payload = await self._client.signed_post("/openApi/swap/v2/trade/order", payload)
ack = BingxOrderAck.from_http(ack_payload if isinstance(ack_payload, dict) else {})
ack_row = dict(unwrap_order_payload(ack_payload)) if isinstance(ack_payload, dict) else {}
status = str(ack_row.get("status") or ack.status or "ACKED")
fill_price = 0.0
for key in ("avgPrice", "avgFilledPrice", "price", "lastFillPrice", "tradePrice"):
try:
value = float(ack_row.get(key) or 0.0)
except Exception:
value = 0.0
if value > 0:
fill_price = value
break
if fill_price <= 0 and self._state is not None:
# Use the last known exchange mark as a fallback for projected accounting.
fill_price = next((float(row.get("markPrice") or row.get("avgPrice") or 0.0) for row in self._state.open_positions.values() if float(row.get("markPrice") or row.get("avgPrice") or 0.0) > 0), 0.0)
except BingxHttpError as exc:
status = "RATE_LIMITED" if _is_rate_limited_error(exc) else "REJECTED"
ack_row = {
"status": status,
"msg": str(exc),
"symbol": symbol,
"clientOrderId": client_order_id,
}
fill_price = 0.0
ack = None
receipt = ExecutionReceipt(
timestamp=datetime.now(timezone.utc),
status=status,
symbol=symbol,
side=side,
action=intent.action.value,
quantity=float(intent.target_size or 0.0),
price=fill_price,
client_order_id=client_order_id,
order_id=str((ack.order_id if 'ack' in locals() and ack is not None else '') or ack_row.get("orderId") or ""),
raw_ack=ack_row,
raw_state=dict(self._state.account if self._state is not None else {}),
)
# Refresh from the venue so the direct runtime can use exchange-led state.
self._state = await self._refresh_exchange_state(intent.asset, include_history=True)
return receipt
async def cancel(self, order: Any, *, reason: str = "") -> dict[str, Any]:
"""Cancel a working order on the venue (resting LIMIT support).
Signs the DELETE with the same client used for order placement, keyed by
the venue orderId (propagated onto the slot order by the kernel on ACK)
with a clientOrderId fallback. Returns the raw BingX response for the
venue adapter to map into a CANCEL_ACK / CANCEL_REJECT event.
"""
asset = str((getattr(order, "metadata", None) or {}).get("asset") or "")
symbol = self._instrument_venue_symbol(asset) if asset else ""
params: dict[str, Any] = {
"symbol": symbol,
"recvWindow": str(int(self._config.recv_window_ms)),
}
venue_order_id = str(getattr(order, "venue_order_id", "") or "")
venue_client_id = str(getattr(order, "venue_client_id", "") or "")
if venue_order_id:
params["orderId"] = venue_order_id
elif venue_client_id:
params["clientOrderId"] = venue_client_id
else:
return {"status": "REJECTED", "msg": "no order id to cancel",
"orderId": venue_order_id, "clientOrderId": venue_client_id}
delete_resp: dict[str, Any] = {}
try:
resp = await self._client.signed_delete("/openApi/swap/v2/trade/order", params)
delete_resp = resp if isinstance(resp, dict) else {"status": "CANCELED"}
except BingxHttpError as exc:
delete_resp = {"status": "RATE_LIMITED" if _is_rate_limited_error(exc) else "ERROR", "msg": str(exc)}
# Truth-based confirmation: the cancel succeeded iff the order is no
# longer open on the venue. BingX can return transient errors (e.g.
# "order not exist", "same order number ... within 1 second" from an
# internal retry) even when the order was actually removed — so we trust
# exchange state, not the DELETE response.
still_open: bool | None = None
try:
oo = await self._client.signed_get("/openApi/swap/v2/trade/openOrders", {"symbol": symbol})
rows = oo if isinstance(oo, list) else (oo.get("data") or oo.get("orders") or [])
if isinstance(rows, dict):
rows = rows.get("orders") or []
ids = {str(r.get("orderId")) for r in rows if isinstance(r, dict)}
cids = {str(r.get("clientOrderId") or r.get("clientOrderID")) for r in rows if isinstance(r, dict)}
still_open = (venue_order_id in ids) if venue_order_id else (venue_client_id in cids)
except Exception:
still_open = None
if still_open is False:
return {"status": "CANCELED", "orderId": venue_order_id, "clientOrderId": venue_client_id}
if str(delete_resp.get("status", "")).upper() in {"CANCELED", "CANCELLED", "SUCCESS", "OK"}:
return {"status": "CANCELED", "orderId": venue_order_id, "clientOrderId": venue_client_id}
return {
"status": delete_resp.get("status", "REJECTED"),
"msg": delete_resp.get("msg", "cancel not confirmed"),
"orderId": venue_order_id, "clientOrderId": venue_client_id,
}
async def reconcile(self, symbol: str | None = None) -> ExchangeStateSnapshot:
# Recovery-only path: ask the venue for authoritative account/position/order state.
return await self._refresh_exchange_state(symbol, include_history=True)