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>
Bug fixes:
1. bingx_venue.py: add cancel_async() — async cancel that awaits backend.cancel()
directly in the main event loop. The sync cancel() path goes through _run()
→ thread-pool → asyncio.run() in a new thread, but aiohttp is bound to the
main loop → deadlock. Identical root cause as the old sync submit() → fixed
via submit_async. Remove dead cancel_order branch (BingxDirectExecutionAdapter
has cancel, not cancel_order).
2. rust_backend.py: process_intent_async CANCEL path now uses cancel_async when
available (matching the submit_async pattern for ENTER/EXIT). Sync cancel()
fallback kept for MockVenueAdapter compat.
3. bingx_direct.py: guard S2 background refresh task per symbol. Old code discarded
the task reference; rapid submits piled up concurrent _refresh_state_background
calls all writing self._state in arbitrary completion order (stale last-writer-
wins). Now: skip creating a new task if one is already pending for the symbol;
store reference and clear via done-callback.
Test additions (test_bingx_bugs.py, 29 tests):
- cancel_async: awaitable, calls backend.cancel directly, maps all statuses
- process_intent_async CANCEL: dispatches cancel_async / falls back to sync
- S2 guard: task stored, no duplicates while pending, new task after done
- _events_from_submit with None snapshots: FILLED/NEW/REJECTED/PARTIAL/RATE_LIMITED
- _filled_size_from_snapshots(None, None): safe 0.0 return
- _events_from_cancel: before/after completely ignored
- connect(): no double refresh_state, no-op if backend has no connect
- submit() sync with None snapshots: FULL_FILL still emitted
- cancel() branch audit: uses cancel not cancel_order, raises for no-cancel backend
Fix: test_exchange_event_seam_parity.py TestMockSubscribe — replace deprecated
asyncio.get_event_loop().run_until_complete() with asyncio.run() (Python 3.12
raises RuntimeError when event loop is closed by earlier suite tests).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>