PINK: fix event-loop corruption in open_positions/open_orders + 23-test orphan suite

Root cause: open_positions()/open_orders() called _backend_snapshot() ->
_call_backend() -> _run() -> pool.submit(asyncio.run, coro) which spawned a
temporary event loop in a worker thread. httpx AsyncClient created inside that
temp loop, loop closed immediately. All subsequent HTTP calls raised Event loop
is closed or asyncio.locks.Event bound to different loop. Crash triggered WS
stream reconnects; each reconnect re-ran reconcile with N>1 BingX positions and
orphaned all but the largest.

Fix: open_positions()/open_orders() now read backend._state (populated by
await backend.connect() in the main loop). Fallback to _backend_snapshot()
for callers without a connected backend.

Fixes test_bingx_bugs::TestConnectNoDoubleRefresh: connect() is now async.

New test_orphan_prevention.py: 23 tests covering all 5 orphan mechanisms:
  A. open_positions/open_orders use backend._state, never hit thread pool
  B. connect() awaitable, backend.connect() runs in main event loop
  C. Reconcile guard: >1 position logs ERROR and takes only largest
  D. clientOrderId p-action-base36-rand4 on every order
  E. EXIT sizing capped to kernel slot_size

391 passed, 2 skipped, 0 failed across all 14 test files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Codex
2026-06-06 20:53:41 +02:00
parent a3169b762d
commit 7e83a5c5c5
3 changed files with 520 additions and 8 deletions

View File

@@ -544,7 +544,9 @@ class TestEventsFromCancelIgnoresSnapshots:
class TestConnectNoDoubleRefresh:
def test_connect_calls_backend_connect_once_no_extra_snapshot(self):
"""connect() must call backend.connect() exactly once and not call
refresh_state() a second time (the redundant _backend_snapshot was removed)."""
refresh_state() a second time (the redundant _backend_snapshot was removed).
connect() is async — must be awaited."""
import asyncio
backend = MagicMock()
backend.connect = AsyncMock(return_value=True)
backend.refresh_state = AsyncMock(return_value=_flat_snap())
@@ -556,7 +558,7 @@ class TestConnectNoDoubleRefresh:
venue._snapshot_ready.set()
venue._last_snapshot = None
result = venue.connect()
result = asyncio.run(venue.connect())
assert result is True
# backend.connect called once
backend.connect.assert_called_once()
@@ -564,7 +566,9 @@ class TestConnectNoDoubleRefresh:
backend.refresh_state.assert_not_called()
def test_connect_without_backend_connect_is_noop(self):
"""If backend has no connect(), connect() returns True without crashing."""
"""If backend has no connect(), connect() returns True without crashing.
connect() is async — must be awaited."""
import asyncio
backend = MagicMock(spec=[]) # empty spec — no attributes
venue = BingxVenueAdapter.__new__(BingxVenueAdapter)
venue.backend = backend
@@ -574,7 +578,7 @@ class TestConnectNoDoubleRefresh:
venue._snapshot_ready.set()
venue._last_snapshot = None
result = venue.connect()
result = asyncio.run(venue.connect())
assert result is True