Files
siloqy/prod/clean_arch/dita_v2/test_bingx_bugs.py
Codex 7e83a5c5c5 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>
2026-06-06 20:53:41 +02:00

713 lines
29 KiB
Python

"""Regression tests for BingX adapter bugs found during 2026-06-05 review.
Covers:
- cancel_async: no thread-pool deadlock from async context
- process_intent_async CANCEL: uses cancel_async (not sync cancel)
- S2 task guard: no duplicate background refresh tasks per symbol
- _events_from_submit with None snapshots: correct fill emit
- cancel() with None snapshots: correct events
- _filled_size_from_snapshots(None, None): returns 0.0 safely
- _events_from_cancel ignores before/after completely
- submit() sync with None snapshots: no snapshot fallback regression
Run:
python -m pytest test_bingx_bugs.py -v
"""
from __future__ import annotations
import asyncio
import inspect
import itertools
import sys
import threading
from datetime import datetime, timezone
from typing import Any, List
from unittest.mock import AsyncMock, MagicMock, patch
sys.path.insert(0, "/mnt/dolphinng5_predict")
import pytest
from prod.clean_arch.dita_v2.bingx_venue import BingxVenueAdapter
from prod.clean_arch.dita_v2.contracts import (
KernelCommandType,
KernelEventKind,
KernelIntent,
TradeSide,
TradeStage,
VenueEvent,
VenueEventStatus,
VenueOrder,
VenueOrderStatus,
)
from prod.clean_arch.dita_v2.mock_venue import MockVenueAdapter, MockVenueScenario
from prod.clean_arch.dita_v2.rust_backend import ExecutionKernel
# ── helpers ───────────────────────────────────────────────────────────────────
def _make_venue(*, cancel_response=None, submit_response=None) -> BingxVenueAdapter:
backend = MagicMock()
backend.cancel = AsyncMock(return_value=cancel_response or {"status": "CANCELED", "orderId": "O1", "clientOrderId": "C1"})
backend.submit_intent = AsyncMock(return_value=_mock_receipt("FILLED"))
backend.refresh_state = AsyncMock(return_value=_flat_snap())
backend.connect = AsyncMock(return_value=True)
venue = BingxVenueAdapter.__new__(BingxVenueAdapter)
venue.backend = backend
venue._event_seq = itertools.count(1)
venue._snap_lock = threading.Lock()
venue._snapshot_ready = threading.Event()
venue._snapshot_ready.set()
venue._last_snapshot = None
return venue
def _flat_snap(**kw):
s = MagicMock()
s.open_orders = kw.get("open_orders", [])
s.all_orders = kw.get("all_orders", [])
s.all_fills = kw.get("all_fills", [])
s.open_positions = kw.get("open_positions", {})
s.capital = kw.get("capital", 25000.0)
return s
def _mock_receipt(status: str = "FILLED", executed_qty: float = 1.0, order_id: str = "O1") -> MagicMock:
r = MagicMock()
r.status = status
r.order_id = order_id
r.client_order_id = "C1"
r.price = 100.0
r.quantity = 1.0
r.timestamp = datetime.now(timezone.utc)
r.raw_ack = {
"status": status,
"orderId": order_id,
"clientOrderId": "C1",
"executedQty": str(executed_qty),
"avgPrice": "100.0",
}
return r
def _make_order(slot_id: int = 0, asset: str = "TRX-USDT") -> VenueOrder:
return VenueOrder(
internal_trade_id="t1",
venue_order_id="O1",
venue_client_id="C1",
side=TradeSide.SHORT,
intended_size=10.0,
filled_size=0.0,
average_fill_price=0.0,
status=VenueOrderStatus.NEW,
metadata={"slot_id": slot_id, "asset": asset},
)
def _make_intent(action=KernelCommandType.ENTER, trade_id="t1", slot_id=0,
asset="TRX-USDT", side=TradeSide.SHORT, size=10.0) -> KernelIntent:
return KernelIntent(
timestamp=datetime.now(timezone.utc),
intent_id=trade_id,
trade_id=trade_id,
slot_id=slot_id,
asset=asset,
action=action,
side=side,
reason="test",
target_size=size,
leverage=1.0,
reference_price=100.0,
exit_leg_ratios=(1.0,),
metadata={},
)
# ── Bug 1: cancel_async — must be awaitable, must not go through thread pool ──
class TestCancelAsync:
def test_cancel_async_is_coroutine(self):
venue = _make_venue()
order = _make_order()
result = venue.cancel_async(order)
assert inspect.iscoroutine(result), "cancel_async must return a coroutine"
asyncio.run(result)
def test_cancel_async_calls_backend_cancel_directly(self):
"""cancel_async must await backend.cancel — not go through _run/thread pool."""
venue = _make_venue()
order = _make_order()
async def _run():
events = await venue.cancel_async(order)
return events
events = asyncio.run(_run())
venue.backend.cancel.assert_awaited_once()
assert len(events) == 1
assert events[0].kind == KernelEventKind.CANCEL_ACK
def test_cancel_async_cancel_rejected_maps_correctly(self):
"""Backend returning REJECTED → CANCEL_REJECT event."""
venue = _make_venue(cancel_response={"status": "REJECTED", "msg": "no such order", "orderId": "O1", "clientOrderId": "C1"})
order = _make_order()
events = asyncio.run(venue.cancel_async(order))
assert events[0].kind == KernelEventKind.CANCEL_REJECT
def test_cancel_async_rate_limited_maps_correctly(self):
venue = _make_venue(cancel_response={"status": "RATE_LIMITED", "msg": "too fast", "orderId": "O1", "clientOrderId": "C1"})
order = _make_order()
events = asyncio.run(venue.cancel_async(order))
assert events[0].kind == KernelEventKind.RATE_LIMITED
def test_cancel_async_backend_none_cancel_returns_empty_response(self):
"""If backend has no cancel method, cancel_async returns CANCELED event with None response."""
venue = _make_venue()
del venue.backend.cancel # remove cancel attribute
venue.backend.cancel = None
order = _make_order()
events = asyncio.run(venue.cancel_async(order))
# None response → raw={} → status defaults to CANCELED → CANCEL_ACK
assert events[0].kind == KernelEventKind.CANCEL_ACK
def test_cancel_async_no_deadlock_from_running_loop(self):
"""cancel_async must not spin up a thread pool — safe inside running loop."""
venue = _make_venue()
order = _make_order()
async def _inner():
# If cancel_async used _run() with thread pool, this would deadlock
# because aiohttp is bound to this event loop.
return await venue.cancel_async(order)
events = asyncio.run(_inner())
assert len(events) >= 1 # got events without deadlock
# ── Bug 2: process_intent_async CANCEL path uses cancel_async ─────────────────
class TestCancelAsyncUsedByKernel:
def test_process_intent_cancel_uses_cancel_async_when_available(self):
"""process_intent_async CANCEL branch must await cancel_async (not sync cancel)
when the venue exposes cancel_async.
Strategy: inject a fake slot with an active_exit_order so the cancel
condition is met, then verify cancel_async was awaited.
"""
from prod.clean_arch.dita_v2.contracts import VenueOrderStatus
cancel_async_calls = []
fake_order = VenueOrder(
internal_trade_id="t1",
venue_order_id="O1",
venue_client_id="C1",
side=TradeSide.SHORT,
intended_size=10.0,
filled_size=0.0,
average_fill_price=0.0,
status=VenueOrderStatus.NEW,
metadata={"slot_id": 0, "asset": "TRX-USDT"},
)
async def _fake_cancel_async(order, *, reason=""):
cancel_async_calls.append(order)
return [VenueEvent(
timestamp=datetime.now(timezone.utc),
event_id="EV-001",
trade_id="t1",
slot_id=0,
kind=KernelEventKind.CANCEL_ACK,
status=VenueEventStatus.CANCELED,
venue_order_id="O1",
venue_client_id="C1",
side=TradeSide.SHORT,
asset="TRX-USDT",
price=0.0, size=0.0, filled_size=0.0, remaining_size=0.0,
reason=reason, raw_payload={}, metadata={},
)]
scenario = MockVenueScenario()
venue = MockVenueAdapter(scenario=scenario)
venue.cancel_async = _fake_cancel_async
kernel = ExecutionKernel(max_slots=1, venue=venue)
# Inject an active_exit_order via a mock slot view so the cancel condition is met.
# KernelSlotView.active_exit_order is Rust-backed — patch at the method level.
mock_slot = MagicMock()
mock_slot.active_exit_order = fake_order
mock_slot.active_entry_order = None
mock_slot.fsm_state = TradeStage.POSITION_OPEN
with patch.object(kernel, "slot", return_value=mock_slot):
async def _run():
cancel_intent = _make_intent(action=KernelCommandType.CANCEL)
return await kernel.process_intent_async(cancel_intent)
asyncio.run(_run())
assert len(cancel_async_calls) > 0, (
"process_intent_async CANCEL path must call cancel_async, not sync cancel"
)
def test_process_intent_cancel_falls_back_to_sync_for_mock(self):
"""If venue has no cancel_async, fall back to sync cancel (mock venue compat)."""
scenario = MockVenueScenario()
venue = MockVenueAdapter(scenario=scenario)
# Confirm MockVenueAdapter has no cancel_async
assert not hasattr(venue, "cancel_async"), "MockVenueAdapter should not have cancel_async for this test"
kernel = ExecutionKernel(max_slots=1, venue=venue)
async def _run():
enter = _make_intent(action=KernelCommandType.ENTER)
await kernel.process_intent_async(enter)
cancel = _make_intent(action=KernelCommandType.CANCEL)
return await kernel.process_intent_async(cancel)
# Must not raise even without cancel_async
outcome = asyncio.run(_run())
assert outcome is not None
# ── Bug 3: S2 task guard — no duplicate background tasks per symbol ───────────
class TestS2TaskGuard:
def test_s2_task_stored_on_creation(self):
"""Background refresh task must be stored in _s2_tasks for its duration."""
from prod.clean_arch.adapters.bingx_direct import BingxDirectExecutionAdapter
adapter = BingxDirectExecutionAdapter.__new__(BingxDirectExecutionAdapter)
adapter._s2_tasks = {}
adapter._state = None
adapter._state_refreshed_at = 0.0
refresh_started = asyncio.Event()
refresh_proceed = asyncio.Event()
async def _slow_refresh(symbol, include_history=False):
refresh_started.set()
await refresh_proceed.wait()
return MagicMock()
adapter._refresh_exchange_state = _slow_refresh
from unittest.mock import MagicMock as MM
adapter._instrument_venue_symbol = lambda a: a.upper().replace("-", "") + "USDT"
async def _run():
# Simulate create_task for background refresh
task = asyncio.create_task(
adapter._refresh_state_background("TRX-USDT"),
name="state_refresh_TRXUSDT",
)
adapter._s2_tasks["TRXUSDT"] = task
task.add_done_callback(lambda _t: adapter._s2_tasks.pop("TRXUSDT", None))
# While task is running, entry should exist
await refresh_started.wait()
assert "TRXUSDT" in adapter._s2_tasks, "Task must be tracked while pending"
assert not adapter._s2_tasks["TRXUSDT"].done()
# Let it complete
refresh_proceed.set()
await asyncio.sleep(0.01)
# After completion, entry removed by done callback
assert "TRXUSDT" not in adapter._s2_tasks, "Done task must be removed from _s2_tasks"
asyncio.run(_run())
def test_s2_no_duplicate_task_when_one_pending(self):
"""When a background refresh is already pending, a second submit must not
create a duplicate task — avoids concurrent REST writes to self._state."""
from prod.clean_arch.adapters.bingx_direct import BingxDirectExecutionAdapter
adapter = BingxDirectExecutionAdapter.__new__(BingxDirectExecutionAdapter)
adapter._s2_tasks = {}
adapter._state = None
adapter._state_refreshed_at = 0.0
call_count = 0
async def _counting_refresh(symbol, include_history=False):
nonlocal call_count
call_count += 1
await asyncio.sleep(0.05) # simulate slow refresh
return MagicMock()
adapter._refresh_exchange_state = _counting_refresh
async def _simulate_submit(symbol: str):
existing = adapter._s2_tasks.get(symbol)
if existing is None or existing.done():
task = asyncio.create_task(adapter._refresh_state_background(symbol))
adapter._s2_tasks[symbol] = task
task.add_done_callback(lambda _t: adapter._s2_tasks.pop(symbol, None))
async def _run():
sym = "TRXUSDT"
# Fire two rapid "submits" while first refresh is still running
await _simulate_submit(sym)
await _simulate_submit(sym) # should be skipped
await asyncio.sleep(0.1) # wait for all tasks
asyncio.run(_run())
assert call_count == 1, (
f"Expected 1 background refresh call, got {call_count}. "
"Duplicate tasks fire redundant REST calls and cause stale last-writer-wins."
)
def test_s2_new_task_created_after_previous_done(self):
"""After a background task completes, next submit should create a new one."""
from prod.clean_arch.adapters.bingx_direct import BingxDirectExecutionAdapter
adapter = BingxDirectExecutionAdapter.__new__(BingxDirectExecutionAdapter)
adapter._s2_tasks = {}
adapter._state = None
adapter._state_refreshed_at = 0.0
call_count = 0
async def _fast_refresh(symbol, include_history=False):
nonlocal call_count
call_count += 1
return MagicMock()
adapter._refresh_exchange_state = _fast_refresh
async def _simulate_submit(symbol: str):
existing = adapter._s2_tasks.get(symbol)
if existing is None or existing.done():
task = asyncio.create_task(adapter._refresh_state_background(symbol))
adapter._s2_tasks[symbol] = task
task.add_done_callback(lambda _t: adapter._s2_tasks.pop(symbol, None))
async def _run():
sym = "TRXUSDT"
await _simulate_submit(sym)
await asyncio.sleep(0.01) # first task completes
await _simulate_submit(sym) # second submit → new task OK
await asyncio.sleep(0.01)
asyncio.run(_run())
assert call_count == 2, f"Expected 2 refresh calls (one per submit after prior done), got {call_count}"
# ── _events_from_submit with None snapshots ───────────────────────────────────
class TestEventsFromSubmitNoneSnapshots:
def _venue(self):
return _make_venue()
def test_filled_market_order_emits_full_fill_no_snapshots(self):
"""MARKET FILLED with None snapshots → ORDER_ACK + FULL_FILL via executedQty."""
venue = self._venue()
intent = _make_intent()
receipt = _mock_receipt("FILLED", executed_qty=10.0)
events = venue._events_from_submit(intent, receipt, None, None)
kinds = [e.kind for e in events]
assert KernelEventKind.ORDER_ACK in kinds
assert KernelEventKind.FULL_FILL in kinds
fill = next(e for e in events if e.kind == KernelEventKind.FULL_FILL)
assert fill.filled_size == pytest.approx(10.0)
def test_filled_no_executed_qty_falls_back_to_target_size(self):
"""FILLED with no executedQty → fill size falls back to intent.target_size."""
venue = self._venue()
intent = _make_intent(size=10.0)
receipt = _mock_receipt("FILLED", executed_qty=0.0)
receipt.raw_ack["executedQty"] = "0"
events = venue._events_from_submit(intent, receipt, None, None)
fill = next((e for e in events if e.kind == KernelEventKind.FULL_FILL), None)
assert fill is not None
assert fill.filled_size == pytest.approx(10.0), "Should fall back to target_size when executedQty=0"
def test_acked_order_no_fill_event(self):
"""NEW/ACKED order with None snapshots → ORDER_ACK only, no FULL_FILL."""
venue = self._venue()
intent = _make_intent()
receipt = _mock_receipt("NEW", executed_qty=0.0)
receipt.raw_ack["executedQty"] = "0"
receipt.raw_ack["status"] = "NEW"
events = venue._events_from_submit(intent, receipt, None, None)
kinds = [e.kind for e in events]
assert KernelEventKind.ORDER_ACK in kinds
assert KernelEventKind.FULL_FILL not in kinds
assert KernelEventKind.PARTIAL_FILL not in kinds
def test_rejected_order_emits_order_reject(self):
"""REJECTED → ORDER_REJECT only."""
venue = self._venue()
intent = _make_intent()
receipt = _mock_receipt("REJECTED", executed_qty=0.0)
receipt.raw_ack["status"] = "REJECTED"
receipt.raw_ack["msg"] = "min qty not met"
events = venue._events_from_submit(intent, receipt, None, None)
assert len(events) == 1
assert events[0].kind == KernelEventKind.ORDER_REJECT
assert "min qty" in events[0].reason
def test_partial_fill_emits_partial_fill_event(self):
"""PARTIALLY_FILLED → ORDER_ACK + PARTIAL_FILL."""
venue = self._venue()
intent = _make_intent(size=10.0)
receipt = _mock_receipt("PARTIALLY_FILLED", executed_qty=5.0)
receipt.raw_ack["status"] = "PARTIALLY_FILLED"
receipt.raw_ack["executedQty"] = "5.0"
events = venue._events_from_submit(intent, receipt, None, None)
kinds = [e.kind for e in events]
assert KernelEventKind.PARTIAL_FILL in kinds
partial = next(e for e in events if e.kind == KernelEventKind.PARTIAL_FILL)
assert partial.filled_size == pytest.approx(5.0)
assert partial.remaining_size == pytest.approx(5.0)
def test_rate_limited_returns_rate_limited_event(self):
"""RATE_LIMITED → single RATE_LIMITED event, no fills."""
venue = self._venue()
intent = _make_intent()
receipt = _mock_receipt("RATE_LIMITED", executed_qty=0.0)
receipt.raw_ack = {"status": "RATE_LIMITED", "msg": "too many requests"}
events = venue._events_from_submit(intent, receipt, None, None)
assert len(events) == 1
assert events[0].kind == KernelEventKind.RATE_LIMITED
# ── _filled_size_from_snapshots with None inputs ──────────────────────────────
class TestFilledSizeFromNoneSnapshots:
def test_both_none_returns_zero(self):
result = BingxVenueAdapter._filled_size_from_snapshots(None, None, "TRX-USDT")
assert result == 0.0
def test_before_none_after_valid(self):
after = MagicMock()
after.open_positions = {"TRX-USDT": {"symbol": "TRX-USDT", "positionAmt": "10.0"}}
result = BingxVenueAdapter._filled_size_from_snapshots(None, after, "TRX-USDT")
assert result == pytest.approx(10.0)
def test_both_valid_returns_diff(self):
before = MagicMock()
before.open_positions = {"TRX-USDT": {"symbol": "TRX-USDT", "positionAmt": "0.0"}}
after = MagicMock()
after.open_positions = {"TRX-USDT": {"symbol": "TRX-USDT", "positionAmt": "10.0"}}
result = BingxVenueAdapter._filled_size_from_snapshots(before, after, "TRX-USDT")
assert result == pytest.approx(10.0)
# ── _events_from_cancel ignores before/after completely ──────────────────────
class TestEventsFromCancelIgnoresSnapshots:
def _venue(self):
return _make_venue()
def test_cancel_ack_with_none_snapshots(self):
venue = self._venue()
order = _make_order()
events = venue._events_from_cancel(order, {"status": "CANCELED", "orderId": "O1"}, None, None)
assert len(events) == 1
assert events[0].kind == KernelEventKind.CANCEL_ACK
def test_cancel_ack_with_garbage_snapshots_same_result(self):
"""Passing arbitrary objects as before/after must produce the same events
as passing None — confirming they're truly ignored."""
venue = self._venue()
order = _make_order()
events_null = venue._events_from_cancel(order, {"status": "CANCELED"}, None, None)
events_junk = venue._events_from_cancel(order, {"status": "CANCELED"}, "JUNK", 42)
assert events_null[0].kind == events_junk[0].kind
assert events_null[0].status == events_junk[0].status
def test_cancel_reject_with_none_snapshots(self):
venue = self._venue()
order = _make_order()
events = venue._events_from_cancel(order, {"status": "REJECTED", "msg": "already filled"}, None, None)
assert events[0].kind == KernelEventKind.CANCEL_REJECT
def test_cancel_rate_limited_with_none_snapshots(self):
venue = self._venue()
order = _make_order()
events = venue._events_from_cancel(order, {"status": "RATE_LIMITED", "msg": "slow down"}, None, None)
assert events[0].kind == KernelEventKind.RATE_LIMITED
# ── connect() no double refresh ───────────────────────────────────────────────
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).
connect() is async — must be awaited."""
import asyncio
backend = MagicMock()
backend.connect = AsyncMock(return_value=True)
backend.refresh_state = AsyncMock(return_value=_flat_snap())
venue = BingxVenueAdapter.__new__(BingxVenueAdapter)
venue.backend = backend
venue._event_seq = itertools.count(1)
venue._snap_lock = threading.Lock()
venue._snapshot_ready = threading.Event()
venue._snapshot_ready.set()
venue._last_snapshot = None
result = asyncio.run(venue.connect())
assert result is True
# backend.connect called once
backend.connect.assert_called_once()
# refresh_state must NOT have been called from connect() itself
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.
connect() is async — must be awaited."""
import asyncio
backend = MagicMock(spec=[]) # empty spec — no attributes
venue = BingxVenueAdapter.__new__(BingxVenueAdapter)
venue.backend = backend
venue._event_seq = itertools.count(1)
venue._snap_lock = threading.Lock()
venue._snapshot_ready = threading.Event()
venue._snapshot_ready.set()
venue._last_snapshot = None
result = asyncio.run(venue.connect())
assert result is True
# ── submit() sync path with None snapshots ────────────────────────────────────
class TestSubmitSyncNoneSnapshots:
def test_submit_sync_filled_emits_full_fill(self):
"""Sync submit() with None snapshots must still emit FULL_FILL for FILLED receipt."""
backend = MagicMock()
backend.submit_intent = MagicMock(return_value=_mock_receipt("FILLED", 10.0))
venue = BingxVenueAdapter.__new__(BingxVenueAdapter)
venue.backend = backend
venue._event_seq = itertools.count(1)
venue._snap_lock = threading.Lock()
venue._snapshot_ready = threading.Event()
venue._snapshot_ready.set()
venue._last_snapshot = None
intent = _make_intent(size=10.0)
# Patch _call_backend to return the receipt directly
from unittest.mock import patch as _patch
with _patch.object(venue, "_call_backend", return_value=_mock_receipt("FILLED", 10.0)):
with _patch.object(venue, "_legacy_intent", return_value=MagicMock()):
events = venue.submit(intent)
kinds = [e.kind for e in events]
assert KernelEventKind.FULL_FILL in kinds
# ── cancel() dead-branch audit ────────────────────────────────────────────────
class TestCancelBranchAudit:
def test_cancel_uses_cancel_not_cancel_order(self):
"""BingxDirectExecutionAdapter has cancel(), not cancel_order().
The cancel_order branch was dead code — confirm cancel() uses the right branch."""
backend = MagicMock()
# Has cancel but not cancel_order
backend.cancel = AsyncMock(return_value={"status": "CANCELED", "orderId": "O1", "clientOrderId": "C1"})
del backend.cancel_order # ensure cancel_order doesn't exist
venue = BingxVenueAdapter.__new__(BingxVenueAdapter)
venue.backend = backend
venue._event_seq = itertools.count(1)
venue._snap_lock = threading.Lock()
venue._snapshot_ready = threading.Event()
venue._snapshot_ready.set()
venue._last_snapshot = None
order = _make_order()
# Sync cancel goes through _call_backend → _run
# With asyncio not running, _run calls asyncio.run() directly
events = venue.cancel(order)
assert len(events) >= 1
assert events[0].kind == KernelEventKind.CANCEL_ACK
def test_cancel_no_cancel_attribute_falls_to_third_branch(self):
"""If backend has neither cancel_order nor cancel, raise RuntimeError for live backend."""
backend = MagicMock(spec=[]) # no methods
venue = BingxVenueAdapter.__new__(BingxVenueAdapter)
venue.backend = backend
venue._event_seq = itertools.count(1)
venue._snap_lock = threading.Lock()
venue._snapshot_ready = threading.Event()
venue._snapshot_ready.set()
venue._last_snapshot = None
order = _make_order()
with pytest.raises(RuntimeError, match="cancel surface"):
venue.cancel(order)
class TestHttpSigningBodyNoDuplicateSignature:
"""Regression: http.py was appending signature= twice in POST body.
build_signed_params injects 'signature' into the returned dict.
canonical_query(payload) then serialised it, then
f"{canonical}&signature={payload['signature']}" appended it again.
BingX received body with two signature= fields. Fix: exclude
'signature' from canonical before appending.
"""
def test_no_duplicate_signature_in_post_body(self):
from prod.bingx.signing import build_signed_params, canonical_query
import uuid
secret = "testsecret1234567890"
params = {
"symbol": "TRX-USDT",
"side": "SELL",
"positionSide": "BOTH",
"type": "MARKET",
"quantity": "10.0",
"clientOrderId": uuid.uuid4().hex,
}
signed = build_signed_params(params, secret, recv_window_ms=5000)
# Replicate fixed http.py body construction
canonical = canonical_query({k: v for k, v in signed.items() if k != "signature"})
body = f"{canonical}&signature={signed['signature']}"
assert body.count("signature") == 1, (
"POST body must contain exactly one 'signature=' field"
)
# signature must be the last field (appended, not embedded)
assert body.endswith(f"&signature={signed['signature']}"), (
"signature must be the final field in the POST body"
)
def test_canonical_without_signature_matches_hmac_input(self):
"""The canonical query we send must match exactly what HMAC was computed over."""
from prod.bingx.signing import build_signed_params, canonical_query, sign_query
import uuid
secret = "anothertestsecret99"
params = {
"symbol": "ETH-USDT",
"side": "BUY",
"type": "MARKET",
"quantity": "1.0",
"clientOrderId": uuid.uuid4().hex,
}
signed = build_signed_params(params, secret, recv_window_ms=5000)
# The string HMAC was computed over (inside build_signed_params)
signed_without_sig = {k: v for k, v in signed.items() if k != "signature"}
expected_hmac_input = canonical_query(signed_without_sig)
# Re-derive HMAC from the canonical we're about to send
recomputed_sig = sign_query(secret, expected_hmac_input)
assert recomputed_sig == signed["signature"], (
"canonical without signature must reproduce the HMAC"
)