Files
sentiment-engine/prod/clean_arch/adapters/bingx_direct.py
Codex bdc54fbeaa dita_v2(exec): INDETERMINATE submit is not REJECTED — unknown is never flat
A BingX read-timeout/reset/5xx after send means the answer was lost, not that
the order failed. Classify every submit failure by what it PROVES:
NOT_ATTEMPTED / REFUSED -> rollback sound; INDETERMINATE -> point-lookup our
own clientOrderId (read-only, bounded, never a reconcile); unresolved stays
UNKNOWN — no synthetic REJECT, no slot rollback, E-feed FILL settles truth.

- prod/bingx/http.py: BingxHttpError.effect + order_may_exist, 9 raise sites tagged
- adapters/bingx_direct.py: _lookup_own_order_by_client_id (never POSTs)
- dita_v2/venue.py: VenueIndeterminateError(VenuePostAckError) — existing fences catch it
- dita_v2/bingx_venue.py: both submit paths escalate INDETERMINATE receipts
- 14 tests incl. kernel no-rollback invariant + genuine-REFUSED contrast

Suite: 3416 passed, 19 skipped, 3 xfailed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 15:43:55 +02:00

1004 lines
50 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 _lookup_own_order_by_client_id(
self, symbol: str, client_order_id: str, *, attempts: int = 3
) -> Any:
"""Did THE ONE ORDER I JUST SENT land? A read-only point lookup. Nothing else.
*** THIS IS NOT A RECONCILE. DO NOT GROW IT INTO ONE. ***
PINK's reconcile was a periodic venue->kernel STATE SYNC: it pulled broad venue
state and wrote it back into slots, which produced ghost positions, reseed
loops and double entries. That pattern is forbidden here. Hard boundaries,
and any future edit that crosses one is a bug:
- Scope : exactly ONE order, identified by an idempotency key WE chose
before sending. Never "all orders", never "open positions".
- Side effects : NONE. It is a GET. It never cancels, never re-POSTs, never
writes a slot, never adopts unknown venue state.
- Re-submitting: NEVER. Re-POSTing an indeterminate order is exactly how you
get a double fill. We only ever ask.
- Bounded : `attempts` tries with backoff, then it gives up and SAYS SO.
It is allowed to answer "I don't know". It is never allowed
to guess.
Why it must exist: a read timeout / reset / 5xx means the request WAS SENT and
the answer was lost. The order may be live. The only alternative to asking is
guessing, and guessing "rejected" is what orphans a real position.
Returns:
dict -> the venue's row for OUR order. It exists. Adopt it as truth.
"ABSENT" -> the venue authoritatively has no such clientOrderId. Only now is
a rollback sound.
None -> truth not established. The caller MUST NOT assume flat.
"""
delay = 0.5
for attempt in range(1, attempts + 1):
try:
resp = await self._client.signed_get(
"/openApi/swap/v2/trade/order",
{"symbol": symbol, "clientOrderID": client_order_id},
)
row = dict(unwrap_order_payload(resp)) if isinstance(resp, dict) else {}
handle = row.get("orderId") or row.get("orderID") or row.get("order_id")
if handle:
LOGGER.critical(
"FIX(bingx_direct): INDETERMINATE submit RECONCILED — order IS LIVE "
"at venue (clientOrderId=%s orderId=%s status=%s). Adopting venue truth "
"instead of fabricating a REJECT.",
client_order_id, handle, row.get("status"),
)
return row
# Venue answered, and it has no such order.
LOGGER.warning(
"FIX(bingx_direct): INDETERMINATE submit reconciled — venue reports NO order "
"for clientOrderId=%s. Rollback is sound.", client_order_id,
)
return "ABSENT"
except BingxHttpError as exc:
# A query that the venue REFUSES (e.g. "order not exist") is an
# authoritative answer: the order is not there.
if getattr(exc, "effect", "") == BingxHttpError.REFUSED:
LOGGER.warning(
"FIX(bingx_direct): venue refused the lookup for clientOrderId=%s (%s) "
"— treating as ABSENT, rollback is sound.", client_order_id, exc,
)
return "ABSENT"
LOGGER.error(
"FIX(bingx_direct): reconcile attempt %d/%d for clientOrderId=%s failed: %s",
attempt, attempts, client_order_id, exc,
)
except Exception as exc: # transport died mid-lookup
LOGGER.error(
"FIX(bingx_direct): reconcile attempt %d/%d for clientOrderId=%s errored: %s",
attempt, attempts, client_order_id, exc,
)
if attempt < attempts:
await asyncio.sleep(delay)
delay *= 2
LOGGER.critical(
"FIX(bingx_direct): COULD NOT ESTABLISH ORDER TRUTH for clientOrderId=%s after %d "
"attempts. The order MAY BE LIVE. Refusing to claim it was rejected — the kernel "
"must NOT roll back to flat.", client_order_id, attempts,
)
return None
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]
# FIX 2026-07-11 (bingx_direct): honor an upstream-minted client id (e.g. the caller's
# u- prefixed seam id) when the intent carries one — venue orders then join
# back to exec_journal rows by id, and the sentinel u- check holds.
# Same charset/length rules apply (metadata id is trimmed to 40).
_meta = intent.metadata or {}
_meta_cid = str(_meta.get("client_order_id") or _meta.get("promo_client_id") or "")
client_order_id = _meta_cid[:40] if _meta_cid else 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 {}
# FIX 2026-07-11 (bingx_direct): an accepted order ALWAYS carries the venue's
# orderId. VST answers an order on an offline/delisted symbol with
# code==0 + EMPTY data (observed BAND/CELR 2026-07-10/11) — the
# unwrapped ack is {} and, before this guard, fell through as
# "ACKED" while fill_qty below fabricated a full fill from
# intent.target_size. The kernel FSM then opened a position that
# never existed at the venue (root of the phantom-position pairs).
# (code!=0 envelopes raise BingxHttpError inside signed_post and
# take the except-branch below; this guard covers the code==0
# empty-ack hole, plus any raw envelope leak, belt-and-braces.)
_body_code = int(ack_payload.get("code") or 0) if isinstance(ack_payload, dict) else 0
_order_handle = (
ack_row.get("orderId") or ack_row.get("orderID") or ack_row.get("order_id")
if isinstance(ack_row, dict) else None
)
if _body_code != 0 or not _order_handle:
LOGGER.warning(
"order POST venue-dead ack symbol=%s code=%s orderId=%r msg=%.160s"
" — REJECTED (no fabricated fill)",
symbol, _body_code, _order_handle,
str((ack_payload or {}).get("msg") or "") if isinstance(ack_payload, dict) else "",
)
ack_row = {
"status": "REJECTED",
"msg": "venue returned no orderId (symbol offline/delisted?)",
"symbol": symbol,
"clientOrderId": client_order_id,
}
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 status != "REJECTED" 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:
ack = None
is_limit = False
fill_price = 0.0
effect = getattr(exc, "effect", BingxHttpError.INDETERMINATE)
if effect == BingxHttpError.INDETERMINATE and not _is_rate_limited_error(exc):
# THE ORDER MAY BE LIVE. Before this branch existed, a BingX read
# timeout / 5xx was reported to the kernel as a confident REJECTED
# with fill_qty=0 — the kernel rolled the slot back to flat while the
# venue happily kept the position. That is the orphan-maker.
#
# We do not guess. We ask the venue about OUR OWN clientOrderId.
LOGGER.critical(
"FIX(bingx_direct): submit outcome INDETERMINATE (%s) symbol=%s "
"clientOrderId=%s — order MAY be live. Looking it up; NOT assuming "
"rejection.", exc, symbol, client_order_id,
)
found = await self._lookup_own_order_by_client_id(symbol, client_order_id)
if isinstance(found, dict):
# It exists. The venue is the authority — adopt its row as the ack.
ack_row = dict(found)
status = str(ack_row.get("status") or "ACKED")
for key in ("avgPrice", "avgFilledPrice", "price", "lastFillPrice"):
try:
value = float(ack_row.get(key) or 0.0)
except Exception:
value = 0.0
if value > 0:
fill_price = value
break
elif found == "ABSENT":
# The venue authoritatively has no such order. Rollback is sound.
status = "REJECTED"
ack_row = {
"status": status,
"msg": f"venue confirms no such order after indeterminate submit: {exc}",
"symbol": symbol,
"clientOrderId": client_order_id,
}
else:
# Truth not established. We refuse to claim rejection. The venue
# layer turns this into a VenueIndeterminateError so the kernel
# does NOT roll back to flat; the E-feed FILL settles it if it filled.
status = "INDETERMINATE"
ack_row = {
"status": status,
"msg": f"order state UNKNOWN after indeterminate submit: {exc}",
"symbol": symbol,
"clientOrderId": client_order_id,
}
else:
# NOT_ATTEMPTED / REFUSED / rate-limited: the venue provably does not
# have this order. A rejection here is the truth, not a guess.
status = "RATE_LIMITED" if _is_rate_limited_error(exc) else "REJECTED"
ack_row = {
"status": status,
"msg": str(exc),
"symbol": symbol,
"clientOrderId": client_order_id,
}
# ── 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.
# FIX 2026-07-11 (bingx_direct): never fabricate a fill for a rejected order — the
# target_size fallback applies only to accepted MARKET acks whose fill
# arrives later via WS (BingX REST ack omits executedQty on those).
if status in ("REJECTED", "RATE_LIMITED", "INDETERMINATE"):
# INDETERMINATE: we do not know that it filled, so we must not invent a
# fill size from target_size. We also do not know that it DIDN'T — that is
# why the status is not REJECTED. The venue layer escalates from here.
fill_qty = 0.0
else:
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)