dita_v2+adapters: phantom-position root-cause fixes (UV audit §8)
bingx_direct.submit_intent: (1) empty/orderId-less ack (VST offline-symbol hole: code==0 + empty data) -> REJECTED, never fabricate fill from target_size; (2) honor upstream u- client id (intent.metadata promo_client_id/client_order_id) over self-minted p- id. rust_backend: EXIT asset-mismatch guard both sync+async paths — EXIT for asset X while slot holds Y -> NO_OPEN_POSITION/EXIT_ASSET_MISMATCH instead of asset-blind close (THETA 76bbf8ee cross-slot contamination). 9 regression tests, mutation-RED verified (guard flip).
This commit is contained in:
@@ -594,7 +594,13 @@ class BingxDirectExecutionAdapter(ExecutionPort):
|
||||
_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}"
|
||||
# UV-FIX 2026-07-11: honor an upstream-minted client id (the u- prefix
|
||||
# from the UV seam) 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
|
||||
@@ -653,6 +659,34 @@ class BingxDirectExecutionAdapter(ExecutionPort):
|
||||
)
|
||||
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 {}
|
||||
# UV-FIX 2026-07-11: 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"))
|
||||
@@ -665,7 +699,7 @@ class BingxDirectExecutionAdapter(ExecutionPort):
|
||||
if value > 0:
|
||||
fill_price = value
|
||||
break
|
||||
if fill_price <= 0 and self._state is not None:
|
||||
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()
|
||||
@@ -688,8 +722,14 @@ class BingxDirectExecutionAdapter(ExecutionPort):
|
||||
# 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)
|
||||
# UV-FIX 2026-07-11: 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"):
|
||||
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%
|
||||
|
||||
Reference in New Issue
Block a user