PINK: fix EXIT position not closing — 3 root causes, 368/368 tests green
Root cause 1 (http.py): duplicate signature= in POST body — canonical_query
included signature key after build_signed_params injected it, then body
appended &signature= again. Fix: exclude 'signature' from canonical.
Root cause 2 (bingx_direct + http.py): HTTP retry sent same MARKET order to
backup URL (bingx.pro), which hits the same VST account. Without clientOrderId,
each retry opened a new SHORT position; EXIT BUY 10 only closed one. Fix:
restore clientOrderId in hyphen format p-{e/x}-{base36_ts}-{rand4} (pure
alphanumeric rejected by VST; hyphen format accepted). Adds max_retries_override
+ urls_to_try to _request_json for non-idempotent override path.
Root cause 3 (flat_and_start_pink): k.venue.connect() ran backend.connect()
inside asyncio.run() in a thread-pool. httpx session created there references
a dead event loop; order POSTs raise RuntimeError("Event loop is closed").
Fix: await adapter.connect() directly from main event loop.
Also: enter_wall_ms + tight _is_our_position createTime filter to separate
PINK's position from concurrent strategies on shared VST account. 1.5s
settle sleep before flat check.
New test suite test_bingx_http_safety.py: 20 tests covering idempotency,
retry correctness, backup-URL dedup, event-loop hygiene, signing correctness.
Live result: ENTER 290ms, EXIT 260ms — both sub-second. Position flat.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -249,6 +249,19 @@ class BingxDirectExecutionAdapter(ExecutionPort):
|
||||
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:
|
||||
@@ -562,12 +575,16 @@ class BingxDirectExecutionAdapter(ExecutionPort):
|
||||
else:
|
||||
side = "BUY" if intent.side == TradeSide.LONG else "SELL"
|
||||
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}"
|
||||
# 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}"
|
||||
leverage = normalize_bingx_leverage_value(
|
||||
int(round(float(intent.leverage or self._config.default_leverage))),
|
||||
exchange_max=self._config.exchange_leverage_cap,
|
||||
@@ -604,17 +621,23 @@ class BingxDirectExecutionAdapter(ExecutionPort):
|
||||
"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)
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user