Defects fix (FET -$5,990 replay, 2026-06-11): - realized_pnl() and mark_price(): PnL = qty × Δprice, side-signed; no ×leverage inflation (was 3× every leg). - BingX MARKET fill events carry true fill price (avgPrice/lastFillPrice), never the order's nominal price (protective bound ±20-25% from mark, poisoned PnL to -$5,990 on a +$164 round-trip). - Fill routing by ORDER IDENTITY first, FSM state second — late entry-remainder fills during EXIT_WORKING no longer misclassify as exits. - Entry basis = VWAP across entry fills, not last fill price. - reconcile_from_slots / restore_state: re-anchor _last_settled_pnl / _slot_was_closed to adopted slot state (cross-restart double-book of carried PnL). - ACCOUNT_UPDATE with wallet_balance=0 dropped (margin-only frames no longer zero e_available_margin). - Foreign-fill skip on shared VST account (PRODGREEN collision filter). - exec_router TTL: entry-requote venue-truth gate (recent own fill + live exchange position probes prevent double-entry). - bingx_direct: openOrders fetched BEFORE positions (sequential ordering prevents dangerous tear → double-entries). - Dual-leverage translation via map_internal_conviction_to_exchange_leverage() (strategy conviction → integer at-exchange leverage, bankers rounding). - BLUE-parity alpha components wired: asset picker (IRP universe ranking) + alpha sizer (cubic-convex dynamic leverage, 0.5-8.0 range). - ch_writer: date_time_input_format=best_effort on insert URLs; flush error logging at WARNING with counter. - blue_parity.price_of(): hyphen-tolerant fallback (FET-USDT → FETUSDT). - Fill test updated to incremental filled_size semantics (BingX WS lastFilledQty). - Env-override base URLs, supervisord autorestart, per-asset DC histories, single-slot invariant, fill-attribution filter. Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
828 lines
40 KiB
Python
828 lines
40 KiB
Python
"""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 time
|
||
import uuid
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime, timezone
|
||
from decimal import Decimal, ROUND_DOWN
|
||
from pathlib import Path
|
||
from typing import Any, Dict, Optional, Tuple
|
||
|
||
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 map_internal_conviction_to_exchange_leverage
|
||
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
|
||
|
||
# ── S1: Leverage cache ────────────────────────────────────────────────
|
||
# Maps symbol → last successfully set leverage (int).
|
||
# Avoids a ~350ms leverage POST before every order when leverage is unchanged.
|
||
#
|
||
# Cache key is symbol only (not runner_id) because leverage is an
|
||
# ACCOUNT-LEVEL setting on BingX — one value per symbol per account.
|
||
# IMPORTANT CONTRACT: if multiple runners share this account and request
|
||
# DIFFERENT leverages for the same symbol concurrently, the last writer
|
||
# wins at the exchange and the other runner's order executes at wrong
|
||
# leverage. Callers MUST ensure leverage is uniform across runners for
|
||
# any given symbol when sharing an account, or use separate accounts.
|
||
# This cache only eliminates the redundant round-trip; it cannot resolve
|
||
# the underlying multi-runner semantic conflict.
|
||
#
|
||
# Per-symbol asyncio.Lock: prevents concurrent submit_intent calls for the
|
||
# same symbol from interleaving leverage POST + cache update, which would
|
||
# create a window where the cache says "leverage set" but the POST hasn't
|
||
# completed yet. Lock scope is deliberately tight (only the POST + cache
|
||
# write), not the entire submit_intent, to avoid head-of-line blocking.
|
||
self._leverage_cache: Dict[str, int] = {}
|
||
self._leverage_locks: Dict[str, asyncio.Lock] = {}
|
||
# Persist path: survives process restarts within a session, not across
|
||
# reboots (leverage should be re-verified from exchange on cold start).
|
||
env_tag = "live" if getattr(self._config, "environment", None) and \
|
||
str(getattr(self._config, "environment", "")).upper() == "LIVE" else "vst"
|
||
self._leverage_cache_path = Path(f"/tmp/.bingx_leverage_cache_{env_tag}.json")
|
||
self._load_leverage_cache()
|
||
|
||
# ── S2: Background state refresh tracking ─────────────────────────────
|
||
self._state_refreshed_at: float = 0.0 # monotonic seconds
|
||
# Per-symbol pending background-refresh task. Prevents concurrent REST
|
||
# calls piling up (rapid submits) — if a task is already running for a
|
||
# symbol we skip creating a new one; the running task captures state after
|
||
# the most recent fill anyway. Done callback removes the entry on completion.
|
||
self._s2_tasks: Dict[str, "asyncio.Task[None]"] = {}
|
||
|
||
@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()
|
||
# S4/S1: on reconnect, verify cached leverage matches exchange truth.
|
||
# Drift happens when another process/runner changed leverage on the same account.
|
||
await self._verify_leverage_drift()
|
||
return True
|
||
|
||
async def disconnect(self) -> None:
|
||
self._connected = False
|
||
await self._client.close()
|
||
|
||
# ── clientOrderId helpers ─────────────────────────────────────────────────
|
||
|
||
@staticmethod
|
||
def _base36(n: int) -> str:
|
||
"""Encode a non-negative integer as base-36 (0-9a-z), lowercase."""
|
||
if n == 0:
|
||
return "0"
|
||
chars = []
|
||
while n:
|
||
chars.append("0123456789abcdefghijklmnopqrstuvwxyz"[n % 36])
|
||
n //= 36
|
||
return "".join(reversed(chars))
|
||
|
||
# ── S1: Leverage cache helpers ────────────────────────────────────────────
|
||
|
||
def _load_leverage_cache(self) -> None:
|
||
"""Load persisted leverage cache from JSON sidecar. Ignores errors."""
|
||
try:
|
||
raw = json.loads(self._leverage_cache_path.read_text())
|
||
self._leverage_cache = {
|
||
str(k): int(v)
|
||
for k, v in raw.items()
|
||
if isinstance(v, (int, float)) and math.isfinite(float(v)) and float(v) >= 1
|
||
}
|
||
LOGGER.debug("leverage cache loaded: %s", self._leverage_cache)
|
||
except Exception:
|
||
self._leverage_cache = {}
|
||
|
||
def _persist_leverage_cache(self) -> None:
|
||
"""Flush leverage cache to JSON sidecar. Non-fatal on failure."""
|
||
try:
|
||
self._leverage_cache_path.write_text(
|
||
json.dumps(self._leverage_cache, indent=2)
|
||
)
|
||
except Exception as exc:
|
||
LOGGER.warning("leverage cache persist failed: %s", exc)
|
||
|
||
async def _ensure_leverage(self, symbol: str, leverage: int) -> bool:
|
||
"""Set leverage for symbol only if the cached value differs.
|
||
|
||
Returns True if a POST was made (leverage was changed), False if skipped.
|
||
The asyncio.Lock per symbol ensures concurrent submit_intent calls for the
|
||
same symbol never interleave leverage POST and cache update — preventing
|
||
the heisenbug where two orders both think they set leverage but one runs
|
||
at the wrong value because the other's POST arrived last.
|
||
|
||
Cache is updated ONLY on successful POST. A failed POST leaves the cache
|
||
stale, so the next submit retries — correct conservative behaviour.
|
||
"""
|
||
lock = self._leverage_locks.setdefault(symbol, asyncio.Lock())
|
||
async with lock:
|
||
cached = self._leverage_cache.get(symbol)
|
||
if cached == leverage:
|
||
return False # exchange already at requested value — skip POST
|
||
|
||
try:
|
||
await self._client.signed_post(
|
||
"/openApi/swap/v2/trade/leverage",
|
||
{"symbol": symbol, "side": "BOTH", "leverage": leverage},
|
||
)
|
||
prev = self._leverage_cache.get(symbol)
|
||
self._leverage_cache[symbol] = leverage
|
||
self._persist_leverage_cache()
|
||
LOGGER.info(
|
||
"leverage SET symbol=%s %s→%d",
|
||
symbol, f"{prev}→" if prev is not None else "", leverage,
|
||
)
|
||
return True
|
||
except Exception as exc:
|
||
LOGGER.warning(
|
||
"leverage POST failed symbol=%s lev=%d: %s — "
|
||
"cache NOT updated, will retry on next submit",
|
||
symbol, leverage, exc,
|
||
)
|
||
return False # do NOT cache — retry guarantees correctness
|
||
|
||
async def _verify_leverage_drift(self) -> None:
|
||
"""On connect/reconnect, compare cached leverage with exchange reality.
|
||
|
||
Drift occurs when another process or runner changed leverage on the same
|
||
account while this adapter was offline. Log it loudly; update cache to
|
||
exchange truth so next submit does not re-set to the wrong value.
|
||
"""
|
||
for symbol, cached_lev in list(self._leverage_cache.items()):
|
||
try:
|
||
resp = await self._client.signed_get(
|
||
"/openApi/swap/v2/trade/leverage", {"symbol": symbol}
|
||
)
|
||
exchange_lev = int(
|
||
float(resp.get("longLeverage") or resp.get("leverage") or 0)
|
||
)
|
||
if exchange_lev > 0 and exchange_lev != cached_lev:
|
||
LOGGER.warning(
|
||
"LEVERAGE DRIFT symbol=%s cached=%d exchange=%d — "
|
||
"another process may have changed it; cache updated to exchange truth",
|
||
symbol, cached_lev, exchange_lev,
|
||
)
|
||
self._leverage_cache[symbol] = exchange_lev
|
||
except Exception as exc:
|
||
LOGGER.debug("leverage drift check failed symbol=%s: %s", symbol, exc)
|
||
self._persist_leverage_cache()
|
||
|
||
# ── S2: Background state refresh ─────────────────────────────────────────
|
||
|
||
async def _refresh_state_background(self, symbol: str) -> None:
|
||
"""Background task: refresh internal state after a synchronous MARKET fill.
|
||
|
||
MARKET fills deliver fill_price / executedQty in the ACK; capital and
|
||
position truth arrive via WS FILL_SETTLED + ACCOUNT_UPDATE. This REST
|
||
poll is belt-and-suspenders — catches edge cases such as unexpected
|
||
concurrent fills or exchange position ledger lag. It does not block the
|
||
submit path.
|
||
"""
|
||
try:
|
||
self._state = await self._refresh_exchange_state(symbol, include_history=False)
|
||
self._state_refreshed_at = time.monotonic()
|
||
LOGGER.debug("background state refresh complete symbol=%s", symbol)
|
||
except Exception as exc:
|
||
LOGGER.warning("background state refresh failed symbol=%s: %s", symbol, exc)
|
||
|
||
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.
|
||
"""
|
||
# SNAPSHOT CONSISTENCY ORDER (2026-06-10, operator-mandated atomicity):
|
||
# openOrders MUST complete BEFORE positions is fetched. The snapshot
|
||
# is assembled from separate REST calls and cannot be truly atomic, but
|
||
# this ordering makes the dangerous tear unrepresentable: if an order
|
||
# fills between the two fetches, it still APPEARS in open_orders
|
||
# (conservative: treated as working) while the resulting position is
|
||
# ALSO visible in positions. The deadly combination — absent from
|
||
# open_orders AND absent from positions for a filled order — cannot
|
||
# occur. Consumers may therefore reason "order gone ⇒ its outcome is
|
||
# visible in positions/fills" ONLY because of this ordering.
|
||
# (Previously all three were gathered concurrently → torn snapshots →
|
||
# double-entry incidents 15:20 and 17:24 UTC.)
|
||
open_orders_payload = await self._safe_get("/openApi/swap/v2/trade/openOrders")
|
||
balance_payload, positions_payload = await asyncio.gather(
|
||
self._safe_get("/openApi/swap/v2/user/balance"),
|
||
self._safe_get("/openApi/swap/v2/user/positions"),
|
||
)
|
||
|
||
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)
|
||
|
||
# ── S3 — Maker order placement (CRITICAL TODO) ────────────────────────────
|
||
# Currently all orders are MARKET (taker, 0.04% fee). For exits especially,
|
||
# a LIMIT order at bid+1tick (SHORT close) or ask-1tick (LONG close) with
|
||
# reduceOnly=true can fill as maker (0.02% fee) — 50% fee saving per trade.
|
||
#
|
||
# Design requirements before this is safe to enable:
|
||
#
|
||
# 1. SHORT/LONG awareness (CRITICAL):
|
||
# SHORT close → BUY side → LIMIT at bid+1tick (slightly above bid to rest on book)
|
||
# LONG close → SELL side → LIMIT at ask-1tick
|
||
# SHORT enter → SELL side → LIMIT at ask-1tick (only if signal allows waiting)
|
||
# LONG enter → BUY side → LIMIT at bid+1tick
|
||
#
|
||
# 2. OBF integration: the OBF subsystem tracks order book depth and emits
|
||
# spread guidance. Use OBF.spread_bps and OBF.bid_depth / ask_depth to
|
||
# decide whether a maker order is viable (tight spread = fast fill,
|
||
# thin book = price impact of own order too large).
|
||
#
|
||
# 3. TODO_ADD_PARAMSET_VIBRISS: The "calm market" threshold (currently
|
||
# hardcoded as spread_bps < 5) MUST be VIBRISS-calibrated. VIBRISS
|
||
# governs our own assessment of market microstructure quality. Some
|
||
# invariants may remain hardcoded (e.g. "never use maker if spread > 50bps")
|
||
# but the normal operating range should be a VIBRISS metaparameter.
|
||
# See: prod/vibriss/ for the param-set schema.
|
||
#
|
||
# 4. Timeout calibration: timeout_s cannot be a fixed constant. It must
|
||
# reflect: (a) the strategy's max adverse excursion budget for the position,
|
||
# (b) the expected fill velocity from OBF order-flow data, (c) the remaining
|
||
# time before the signal's max_hold threshold. A resting limit that misses
|
||
# its cancel window creates an orphaned order — a hard risk control failure.
|
||
#
|
||
# 5. Price impact awareness (future, not yet operational):
|
||
# For large notionals, our own LIMIT order changes the book. At some notional
|
||
# threshold, the maker price improvement is eaten by the impact of our own
|
||
# resting order. The OBF subsystem must estimate this before we switch from
|
||
# MARKET to LIMIT. This is the "price impact of our own order" problem.
|
||
#
|
||
# CRITICAL TODO: Abstraction. The entire order placement logic (MARKET/LIMIT/
|
||
# TWAP/VWAP/Iceberg, maker/taker selection, price impact estimation, timeout/
|
||
# fallback, cancel-replace) MUST eventually be a dedicated "Smart Order Router"
|
||
# (SOR) subsystem. submit_intent() should be a thin dispatcher into the SOR,
|
||
# not a monolith. This is a pre-condition for multi-venue support, co-location
|
||
# optimisation, and regulatory reporting.
|
||
#
|
||
# GAP in characterisation: why does BingX separate REST (order placement) from
|
||
# WS (fill events)? Almost certainly historical: REST came first for audit-
|
||
# trail reasons (HMAC-signed requests → deterministic replay), WS push came
|
||
# later for low-latency fills. The FIX protocol unifies these under one
|
||
# connection — that is the right long-term model and the reason venue-agnostic
|
||
# abstraction via the VenueAdapter protocol is critical. Any future venue
|
||
# (Bybit, OKX, Binance) will have the same REST/WS split.
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
|
||
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"
|
||
reduce_only = bool(intent.action == DecisionAction.EXIT)
|
||
# clientOrderId: BingX allows letters, digits, hyphens, underscores (1-40
|
||
# chars; must not be all-letters). Pure alphanumeric triggered a false
|
||
# "unique check failed" on VST (2026-06-05). Using hyphen-separated format
|
||
# avoids that VST quirk AND lets retries be safe (BingX returns the original
|
||
# order result for duplicate clientOrderId within ~24h — idempotent retries).
|
||
# Format: "p-{action}-{base36_ts_ms}-{rand4}" e.g. "p-e-1q3k7m-ab4c" (17 chars).
|
||
_action_char = "e" if intent.action == DecisionAction.ENTER else "x"
|
||
_ts36 = self._base36(int(time.time() * 1000))
|
||
_rand4 = uuid.uuid4().hex[:4]
|
||
client_order_id = f"p-{_action_char}-{_ts36}-{_rand4}"
|
||
# DUAL-LEVERAGE TRANSLATION (prod/bingx/leverage.py, SYSTEM BIBLE §6):
|
||
# intent.leverage is the STRATEGY conviction (fractional, 0.5–9.0) and
|
||
# already sized the quantity. At-exchange leverage is derived from it
|
||
# via the linear conviction map → integer [1, cap], bankers rounding.
|
||
# Plain round(conviction) here previously pinned every trade at the cap.
|
||
leverage = map_internal_conviction_to_exchange_leverage(
|
||
float(intent.leverage or self._config.default_leverage),
|
||
exchange_max=self._config.exchange_leverage_cap,
|
||
)
|
||
|
||
# S1: leverage cache — skip POST when exchange already has the right value.
|
||
await self._ensure_leverage(symbol, leverage)
|
||
|
||
# Capture mark price BEFORE the order POST for slippage measurement (Gap 3).
|
||
# This is the most honest reference: the market state at decision time.
|
||
mark_at_submit = 0.0
|
||
submit_ts_ms = int(time.time() * 1000)
|
||
if self._state is not None:
|
||
for _sym_key, _pos in self._state.open_positions.items():
|
||
_mark = float(_pos.get("markPrice") or _pos.get("avgPrice") or 0.0)
|
||
if _mark > 0:
|
||
mark_at_submit = _mark
|
||
break
|
||
if mark_at_submit <= 0:
|
||
# No open position for mark — use the last known mark from any symbol
|
||
# as a rough reference (e.g. for fresh ENTER with no position yet)
|
||
pass # remains 0.0; slippage will be 0
|
||
|
||
try:
|
||
# Honor the order type forwarded by the venue adapter
|
||
# (bingx_venue._legacy_intent encodes _order_type/_limit_price).
|
||
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,
|
||
}
|
||
if is_limit:
|
||
payload["price"] = self._format_price(intent.asset, limit_price)
|
||
# Exec-router maker quotes send PostOnly so a crossing quote is
|
||
# rejected by the venue instead of paying taker silently.
|
||
_tif = str((intent.metadata or {}).get("_time_in_force", "GTC") or "GTC")
|
||
payload["timeInForce"] = _tif if _tif in ("GTC", "IOC", "FOK", "PostOnly") else "GTC"
|
||
if reduce_only:
|
||
payload["reduceOnly"] = "true"
|
||
LOGGER.debug("order POST: action=%s side=%s symbol=%s qty=%s reduceOnly=%s",
|
||
intent.action.value, side, symbol,
|
||
payload.get("quantity"), payload.get("reduceOnly", False))
|
||
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")
|
||
LOGGER.debug("order ACK: status=%s orderId=%s executedQty=%s side=%s",
|
||
status, ack_row.get("orderId"), ack_row.get("executedQty"), ack_row.get("side"))
|
||
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:
|
||
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
|
||
is_limit = False
|
||
|
||
# ── Gap 2: fee estimation (ESTIMATED_TAKER / ESTIMATED_MAKER) ────────
|
||
# BingX REST ACK does not include commission. WS FILL_SETTLED will deliver
|
||
# the actual fee later and update the fee_source to "WS_SETTLED".
|
||
# Until then, log an estimate so CH rows are never blank on this field.
|
||
fill_qty = float(ack_row.get("executedQty") or ack_row.get("filledQty") or
|
||
getattr(intent, "target_size", 0.0) or 0.0)
|
||
if is_limit:
|
||
# LIMIT orders *may* rest and fill as maker — optimistic estimate.
|
||
fee_rate = 0.0002 # BingX perpetuals maker fee 0.02%
|
||
fee_source = "ESTIMATED_MAKER"
|
||
is_maker_est = True
|
||
else:
|
||
fee_rate = 0.0004 # BingX perpetuals taker fee 0.04%
|
||
fee_source = "ESTIMATED_TAKER"
|
||
is_maker_est = False
|
||
estimated_fee = fill_price * fill_qty * fee_rate if fill_price > 0 and fill_qty > 0 else 0.0
|
||
|
||
# ── Gap 3: slippage (signed, vs mark_at_submit) ───────────────────────
|
||
# Positive = worse than mark (taker overpays), negative = better (maker/price improvement).
|
||
# Measured for BOTH taker and maker fills so post-trade analytics can compare.
|
||
slippage_bps = 0.0
|
||
if mark_at_submit > 0 and fill_price > 0:
|
||
raw_diff = (fill_price - mark_at_submit) / mark_at_submit * 10_000
|
||
# Sign convention: adverse = positive regardless of direction.
|
||
# For BUY (LONG enter / SHORT close): higher fill price = worse → positive
|
||
# For SELL (SHORT enter / LONG close): lower fill price = worse → positive
|
||
if side == "BUY":
|
||
slippage_bps = raw_diff # positive if fill > mark (paid up)
|
||
else:
|
||
slippage_bps = -raw_diff # positive if fill < mark (sold down)
|
||
|
||
# Exchange-assigned fill time from ACK (field "updateTime" or "time"); 0 if absent.
|
||
exchange_ts = int(ack_row.get("updateTime") or ack_row.get("time") or
|
||
ack_row.get("transactTime") or 0)
|
||
|
||
# Annotate ack_row with computed friction so _events_from_submit can read them.
|
||
ack_row["_fee_estimated"] = estimated_fee
|
||
ack_row["_fee_source"] = fee_source
|
||
ack_row["_is_maker_est"] = is_maker_est
|
||
ack_row["_mark_at_submit"] = mark_at_submit
|
||
ack_row["_slippage_bps"] = slippage_bps
|
||
ack_row["_submit_ts_ms"] = submit_ts_ms
|
||
ack_row["_exchange_ts"] = exchange_ts
|
||
|
||
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 {}),
|
||
)
|
||
|
||
# S2: background state refresh — do not block the submit path for MARKET fills.
|
||
# For MARKET orders that returned FILLED in the ACK, all fill data is already
|
||
# in the receipt. The WS stream (FILL_SETTLED + ACCOUNT_UPDATE) delivers
|
||
# capital truth. The background REST poll is belt-and-suspenders.
|
||
# For LIMIT / non-FILLED orders: must refresh synchronously to detect resting order.
|
||
market_filled = (status == "FILLED" and not is_limit)
|
||
if market_filled:
|
||
existing = self._s2_tasks.get(symbol)
|
||
if existing is None or existing.done():
|
||
task = asyncio.create_task(
|
||
self._refresh_state_background(intent.asset),
|
||
name=f"state_refresh_{symbol}",
|
||
)
|
||
self._s2_tasks[symbol] = task
|
||
task.add_done_callback(lambda _t, _s=symbol: self._s2_tasks.pop(_s, None))
|
||
else:
|
||
self._state = await self._refresh_exchange_state(intent.asset, include_history=False)
|
||
|
||
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)
|