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:
Codex
2026-06-06 01:39:35 +02:00
parent 535eea855d
commit 33d8e855c8
6 changed files with 1806 additions and 19 deletions

View File

@@ -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: