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

@@ -341,10 +341,21 @@ class BingxVenueAdapter(VenueAdapter):
metadata=metadata,
)
def connect(self) -> bool:
result = getattr(self.backend, "connect", None)
if result is not None:
self._run(result())
async def connect(self) -> bool:
"""Async connect — awaits backend.connect() in the caller's event loop.
The old sync path called self._run(backend.connect()) which spawns a
thread-pool asyncio.run(), creating the httpx AsyncClient in a temporary
loop that immediately closes. Every subsequent request then raises
"Event loop is closed" or "bound to a different event loop".
Awaiting directly here fixes that: the client is always created in the
main running loop.
"""
conn_fn = getattr(self.backend, "connect", None)
if conn_fn is not None:
result = conn_fn()
if inspect.isawaitable(result):
await result
# backend.connect() already called refresh_state() — no second fetch needed
return True
@@ -402,10 +413,23 @@ class BingxVenueAdapter(VenueAdapter):
return self._events_from_cancel(order, response, None, None, reason=reason)
def open_orders(self) -> List[VenueOrder]:
# Use backend._state (populated by await backend.connect()) rather than
# _backend_snapshot() → _call_backend() → _run() → pool.submit(asyncio.run)
# which spawns a temporary event loop, creates the httpx AsyncClient inside
# it, then closes that loop — every subsequent HTTP call then raises
# "Event loop is closed" or "asyncio.locks.Event bound to a different loop".
backend_state = getattr(self.backend, "_state", None)
if backend_state is not None:
return [_venue_order_from_row(row) for row in (backend_state.open_orders or [])]
snapshot = self._backend_snapshot(include_history=False)
return [_venue_order_from_row(row) for row in (snapshot.open_orders or [])]
def open_positions(self) -> List[dict[str, Any]]:
# Same rationale as open_orders(): prefer cached backend._state to avoid
# the thread-pool asyncio.run() path that corrupts the httpx session.
backend_state = getattr(self.backend, "_state", None)
if backend_state is not None:
return [dict(row) for row in (backend_state.open_positions or {}).values()]
snapshot = self._backend_snapshot(include_history=False)
return [dict(row) for row in (snapshot.open_positions or {}).values()]