"""INDETERMINATE submit outcomes: "not done" != "tried and failed" != "unknown". A BingX read timeout / reset / 5xx means the request WAS SENT and the answer was lost — the order may be live. Before this class of fix, `bingx_direct` reported such a failure to the kernel as a confident REJECTED with fill_qty=0, and the kernel rolled the slot back to flat while the venue kept the position. That is an orphan, and it is the same disease as the 2026-07-13 post-ack skew, one layer deeper. The rule these tests protect: Rollback is sound ONLY when the effect is provably absent. NOT_ATTEMPTED (never sent) -> rollback OK REFUSED (venue said no) -> rollback OK INDETERMINATE (answer lost) -> NEVER roll back. Ask the venue about our own clientOrderId; if truth cannot be established, stay UNKNOWN and let the E-feed FILL settle it. The contrast tests matter as much as the recovery tests: a fix that refuses to roll back on a *genuine* rejection would strand every slot forever. """ from __future__ import annotations import asyncio import itertools import threading from datetime import datetime, timezone from types import SimpleNamespace import httpx import pytest from prod.bingx.http import ( BingxHttpError, _effect_of_httpx_error, _effect_of_status, ) from prod.clean_arch.adapters.bingx_direct import BingxDirectExecutionAdapter from prod.clean_arch.dita_v2.bingx_venue import BingxVenueAdapter from prod.clean_arch.dita_v2.contracts import ( KernelCommandType, KernelEventKind, KernelIntent, TradeSide, TradeStage, ) from prod.clean_arch.dita_v2.rust_backend import ExecutionKernel from prod.clean_arch.dita_v2.venue import VenueIndeterminateError, VenuePostAckError # ── 1. Classification: what does the failure actually PROVE? ───────────────── def test_read_timeout_is_indeterminate_not_rejection(): """The killer case: the request was sent, the answer was lost.""" assert _effect_of_httpx_error(httpx.ReadTimeout("timed out")) == BingxHttpError.INDETERMINATE def test_connect_failures_are_not_attempted(): """Never left the box -> provably no order -> rollback is sound.""" assert _effect_of_httpx_error(httpx.ConnectError("refused")) == BingxHttpError.NOT_ATTEMPTED assert _effect_of_httpx_error(httpx.ConnectTimeout("no route")) == BingxHttpError.NOT_ATTEMPTED def test_5xx_is_indeterminate_and_4xx_is_refused(): assert _effect_of_status(500) == BingxHttpError.INDETERMINATE assert _effect_of_status(503) == BingxHttpError.INDETERMINATE assert _effect_of_status(400) == BingxHttpError.REFUSED assert _effect_of_status(429) == BingxHttpError.REFUSED def test_unknown_failure_defaults_to_indeterminate(): """When we do not know, we must NOT claim absence.""" assert BingxHttpError("mystery").effect == BingxHttpError.INDETERMINATE assert BingxHttpError("mystery").order_may_exist is True assert BingxHttpError("nope", effect=BingxHttpError.REFUSED).order_may_exist is False # ── 2. The point lookup: one order, by our own key, read-only ──────────────── class _LookupClient: """Records every call so we can prove the lookup NEVER re-POSTs an order.""" def __init__(self, get_results): self._get_results = list(get_results) self.gets: list[tuple[str, dict]] = [] self.posts: list[tuple[str, dict]] = [] async def signed_get(self, path, params=None): self.gets.append((path, dict(params or {}))) result = self._get_results.pop(0) if isinstance(result, Exception): raise result return result async def signed_post(self, path, params=None, **_kw): # pragma: no cover - must never run self.posts.append((path, dict(params or {}))) raise AssertionError("lookup must NEVER submit an order") def _adapter(client) -> BingxDirectExecutionAdapter: adapter = BingxDirectExecutionAdapter.__new__(BingxDirectExecutionAdapter) adapter._client = client return adapter def _order_row(**over): """Shape as signed_get actually delivers it: the envelope is already unwrapped (BingxHttpClient._unwrap_response returns payload["data"]), so a query order lookup yields {"order": {...}}.""" row = {"orderId": "venue-1", "clientOrderId": "cid-1", "status": "FILLED", "avgPrice": "100.0", "executedQty": "10.0"} row.update(over) return {"order": row} def test_lookup_finds_the_order_and_never_posts(): client = _LookupClient([_order_row()]) row = asyncio.run(_adapter(client)._lookup_own_order_by_client_id("TRX-USDT", "cid-1")) assert isinstance(row, dict) and row["orderId"] == "venue-1" assert client.posts == [], "lookup must never re-submit — that is how you double-fill" assert client.gets[0][1]["clientOrderID"] == "cid-1", "must query by OUR idempotency key" def test_lookup_reports_absent_when_venue_has_no_such_order(): """Venue answered authoritatively: no order. ONLY now is rollback sound.""" client = _LookupClient([{"code": 0, "data": {}}]) assert asyncio.run( _adapter(client)._lookup_own_order_by_client_id("TRX-USDT", "cid-1") ) == "ABSENT" def test_lookup_treats_venue_refusal_as_absent(): client = _LookupClient([BingxHttpError("order not exist", effect=BingxHttpError.REFUSED)]) assert asyncio.run( _adapter(client)._lookup_own_order_by_client_id("TRX-USDT", "cid-1") ) == "ABSENT" def test_lookup_returns_none_when_truth_cannot_be_established(): """It is allowed to say "I don't know". It is never allowed to guess.""" client = _LookupClient([httpx.ReadTimeout("t")] * 3) result = asyncio.run( _adapter(client)._lookup_own_order_by_client_id("TRX-USDT", "cid-1", attempts=3) ) assert result is None, "unresolved must be None — never 'ABSENT', never a fabricated row" assert len(client.gets) == 3, "bounded: exactly `attempts` tries, then give up" assert client.posts == [] # ── 3. The venue layer escalates UNKNOWN instead of claiming rejection ─────── def _intent(trade_id="ind-1") -> KernelIntent: return KernelIntent( timestamp=datetime.now(timezone.utc), intent_id=f"intent-{trade_id}", trade_id=trade_id, slot_id=0, asset="TRX-USDT", action=KernelCommandType.ENTER, side=TradeSide.SHORT, reason="indeterminate-regression", target_size=10.0, leverage=1.0, reference_price=100.0, exit_leg_ratios=(1.0,), metadata={}, ) def _receipt(status): return SimpleNamespace( status=status, order_id="", client_order_id="cid-1", price=0.0, quantity=0.0, timestamp=datetime.now(timezone.utc), raw_ack={"status": status, "clientOrderId": "cid-1"}, ) class _Backend: def __init__(self, status, is_async=False): self._status = status self._is_async = is_async self.submit_count = 0 def submit_intent(self, _legacy): self.submit_count += 1 return _receipt(self._status) async def submit_intent_async(self, _legacy): # pragma: no cover - shim return self.submit_intent(_legacy) class _AsyncBackend(_Backend): async def submit_intent(self, _legacy): # type: ignore[override] self.submit_count += 1 return _receipt(self._status) def _venue(backend) -> BingxVenueAdapter: 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 venue._telemetry_plane = None return venue def test_sync_venue_escalates_indeterminate_receipt(monkeypatch): venue = _venue(_Backend("INDETERMINATE")) monkeypatch.setattr(venue, "_publish_telemetry", lambda **_f: None) with pytest.raises(VenueIndeterminateError): venue.submit(_intent()) def test_async_venue_escalates_indeterminate_receipt(monkeypatch): venue = _venue(_AsyncBackend("INDETERMINATE")) monkeypatch.setattr(venue, "_publish_telemetry", lambda **_f: None) with pytest.raises(VenueIndeterminateError): asyncio.run(venue.submit_async(_intent())) def test_indeterminate_is_a_post_ack_error_so_existing_fences_catch_it(): """Subclassing is load-bearing: the kernel's no-rollback fences key on the base.""" assert issubclass(VenueIndeterminateError, VenuePostAckError) # ── 4. THE INVARIANT: the kernel must never conclude "flat" on UNKNOWN ─────── def test_kernel_does_not_roll_back_slot_on_indeterminate_submit(monkeypatch): """kernel_believes_flat => venue_is_flat. An UNKNOWN order may be live, so the slot must NOT return to IDLE and NO synthetic REJECT may be emitted.""" venue = _venue(_Backend("INDETERMINATE")) monkeypatch.setattr(venue, "_publish_telemetry", lambda **_f: None) with ExecutionKernel(max_slots=1, venue=venue) as kernel: outcome = kernel.process_intent(_intent(trade_id="ind-sync")) slot = kernel._get_slot(0) kinds = [e.kind for e in outcome.emitted_events] assert KernelEventKind.ORDER_REJECT not in kinds, ( "a synthetic REJECT on an UNKNOWN order is the orphan-maker" ) assert slot.fsm_state is not TradeStage.IDLE, ( "slot rolled back to IDLE while the order may be LIVE at the venue" ) def test_kernel_does_not_roll_back_slot_on_indeterminate_submit_async(monkeypatch): venue = _venue(_AsyncBackend("INDETERMINATE")) monkeypatch.setattr(venue, "_publish_telemetry", lambda **_f: None) async def exercise(): with ExecutionKernel(max_slots=1, venue=venue) as kernel: outcome = await kernel.process_intent_async(_intent(trade_id="ind-async")) return outcome, kernel._get_slot(0) outcome, slot = asyncio.run(exercise()) kinds = [e.kind for e in outcome.emitted_events] assert KernelEventKind.ORDER_REJECT not in kinds assert slot.fsm_state is not TradeStage.IDLE # ── 5. CONTRAST: a genuine rejection MUST still roll back ──────────────────── # Over-correcting here would strand every slot in ORDER_REQUESTED forever, which is # its own outage. Proving the safe path still works is part of the fix. def test_genuine_rejection_still_rolls_the_slot_back(monkeypatch): class _RejectingBackend: def submit_intent(self, _legacy): raise BingxHttpError("insufficient margin", effect=BingxHttpError.REFUSED) venue = _venue(_RejectingBackend()) monkeypatch.setattr(venue, "_publish_telemetry", lambda **_f: None) with ExecutionKernel(max_slots=1, venue=venue) as kernel: outcome = kernel.process_intent(_intent(trade_id="refused")) slot = kernel._get_slot(0) kinds = [e.kind for e in outcome.emitted_events] assert KernelEventKind.ORDER_REJECT in kinds, ( "a PROVEN refusal must still produce a REJECT — otherwise the slot strands" ) assert slot.fsm_state is TradeStage.IDLE, "provably-no-order must free the slot"