dita_v2(exec): INDETERMINATE submit is not REJECTED — unknown is never flat

A BingX read-timeout/reset/5xx after send means the answer was lost, not that
the order failed. Classify every submit failure by what it PROVES:
NOT_ATTEMPTED / REFUSED -> rollback sound; INDETERMINATE -> point-lookup our
own clientOrderId (read-only, bounded, never a reconcile); unresolved stays
UNKNOWN — no synthetic REJECT, no slot rollback, E-feed FILL settles truth.

- prod/bingx/http.py: BingxHttpError.effect + order_may_exist, 9 raise sites tagged
- adapters/bingx_direct.py: _lookup_own_order_by_client_id (never POSTs)
- dita_v2/venue.py: VenueIndeterminateError(VenuePostAckError) — existing fences catch it
- dita_v2/bingx_venue.py: both submit paths escalate INDETERMINATE receipts
- 14 tests incl. kernel no-rollback invariant + genuine-REFUSED contrast

Suite: 3416 passed, 19 skipped, 3 xfailed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Codex
2026-07-13 15:43:55 +02:00
parent d9b7e05531
commit bdc54fbeaa
5 changed files with 529 additions and 19 deletions

View File

@@ -578,6 +578,89 @@ class BingxDirectExecutionAdapter(ExecutionPort):
# (Bybit, OKX, Binance) will have the same REST/WS split.
# ─────────────────────────────────────────────────────────────────────────
async def _lookup_own_order_by_client_id(
self, symbol: str, client_order_id: str, *, attempts: int = 3
) -> Any:
"""Did THE ONE ORDER I JUST SENT land? A read-only point lookup. Nothing else.
*** THIS IS NOT A RECONCILE. DO NOT GROW IT INTO ONE. ***
PINK's reconcile was a periodic venue->kernel STATE SYNC: it pulled broad venue
state and wrote it back into slots, which produced ghost positions, reseed
loops and double entries. That pattern is forbidden here. Hard boundaries,
and any future edit that crosses one is a bug:
- Scope : exactly ONE order, identified by an idempotency key WE chose
before sending. Never "all orders", never "open positions".
- Side effects : NONE. It is a GET. It never cancels, never re-POSTs, never
writes a slot, never adopts unknown venue state.
- Re-submitting: NEVER. Re-POSTing an indeterminate order is exactly how you
get a double fill. We only ever ask.
- Bounded : `attempts` tries with backoff, then it gives up and SAYS SO.
It is allowed to answer "I don't know". It is never allowed
to guess.
Why it must exist: a read timeout / reset / 5xx means the request WAS SENT and
the answer was lost. The order may be live. The only alternative to asking is
guessing, and guessing "rejected" is what orphans a real position.
Returns:
dict -> the venue's row for OUR order. It exists. Adopt it as truth.
"ABSENT" -> the venue authoritatively has no such clientOrderId. Only now is
a rollback sound.
None -> truth not established. The caller MUST NOT assume flat.
"""
delay = 0.5
for attempt in range(1, attempts + 1):
try:
resp = await self._client.signed_get(
"/openApi/swap/v2/trade/order",
{"symbol": symbol, "clientOrderID": client_order_id},
)
row = dict(unwrap_order_payload(resp)) if isinstance(resp, dict) else {}
handle = row.get("orderId") or row.get("orderID") or row.get("order_id")
if handle:
LOGGER.critical(
"FIX(bingx_direct): INDETERMINATE submit RECONCILED — order IS LIVE "
"at venue (clientOrderId=%s orderId=%s status=%s). Adopting venue truth "
"instead of fabricating a REJECT.",
client_order_id, handle, row.get("status"),
)
return row
# Venue answered, and it has no such order.
LOGGER.warning(
"FIX(bingx_direct): INDETERMINATE submit reconciled — venue reports NO order "
"for clientOrderId=%s. Rollback is sound.", client_order_id,
)
return "ABSENT"
except BingxHttpError as exc:
# A query that the venue REFUSES (e.g. "order not exist") is an
# authoritative answer: the order is not there.
if getattr(exc, "effect", "") == BingxHttpError.REFUSED:
LOGGER.warning(
"FIX(bingx_direct): venue refused the lookup for clientOrderId=%s (%s) "
"— treating as ABSENT, rollback is sound.", client_order_id, exc,
)
return "ABSENT"
LOGGER.error(
"FIX(bingx_direct): reconcile attempt %d/%d for clientOrderId=%s failed: %s",
attempt, attempts, client_order_id, exc,
)
except Exception as exc: # transport died mid-lookup
LOGGER.error(
"FIX(bingx_direct): reconcile attempt %d/%d for clientOrderId=%s errored: %s",
attempt, attempts, client_order_id, exc,
)
if attempt < attempts:
await asyncio.sleep(delay)
delay *= 2
LOGGER.critical(
"FIX(bingx_direct): COULD NOT ESTABLISH ORDER TRUTH for clientOrderId=%s after %d "
"attempts. The order MAY BE LIVE. Refusing to claim it was rejected — the kernel "
"must NOT roll back to flat.", client_order_id, attempts,
)
return None
async def submit_intent(self, intent: Intent) -> ExecutionReceipt:
symbol = self._instrument_venue_symbol(intent.asset)
if intent.action == DecisionAction.EXIT:
@@ -707,16 +790,66 @@ class BingxDirectExecutionAdapter(ExecutionPort):
0.0,
)
except BingxHttpError as exc:
status = "RATE_LIMITED" if _is_rate_limited_error(exc) else "REJECTED"
ack_row = {
"status": status,
"msg": str(exc),
"symbol": symbol,
"clientOrderId": client_order_id,
}
fill_price = 0.0
ack = None
is_limit = False
fill_price = 0.0
effect = getattr(exc, "effect", BingxHttpError.INDETERMINATE)
if effect == BingxHttpError.INDETERMINATE and not _is_rate_limited_error(exc):
# THE ORDER MAY BE LIVE. Before this branch existed, a BingX read
# timeout / 5xx was reported to the kernel as a confident REJECTED
# with fill_qty=0 — the kernel rolled the slot back to flat while the
# venue happily kept the position. That is the orphan-maker.
#
# We do not guess. We ask the venue about OUR OWN clientOrderId.
LOGGER.critical(
"FIX(bingx_direct): submit outcome INDETERMINATE (%s) symbol=%s "
"clientOrderId=%s — order MAY be live. Looking it up; NOT assuming "
"rejection.", exc, symbol, client_order_id,
)
found = await self._lookup_own_order_by_client_id(symbol, client_order_id)
if isinstance(found, dict):
# It exists. The venue is the authority — adopt its row as the ack.
ack_row = dict(found)
status = str(ack_row.get("status") or "ACKED")
for key in ("avgPrice", "avgFilledPrice", "price", "lastFillPrice"):
try:
value = float(ack_row.get(key) or 0.0)
except Exception:
value = 0.0
if value > 0:
fill_price = value
break
elif found == "ABSENT":
# The venue authoritatively has no such order. Rollback is sound.
status = "REJECTED"
ack_row = {
"status": status,
"msg": f"venue confirms no such order after indeterminate submit: {exc}",
"symbol": symbol,
"clientOrderId": client_order_id,
}
else:
# Truth not established. We refuse to claim rejection. The venue
# layer turns this into a VenueIndeterminateError so the kernel
# does NOT roll back to flat; the E-feed FILL settles it if it filled.
status = "INDETERMINATE"
ack_row = {
"status": status,
"msg": f"order state UNKNOWN after indeterminate submit: {exc}",
"symbol": symbol,
"clientOrderId": client_order_id,
}
else:
# NOT_ATTEMPTED / REFUSED / rate-limited: the venue provably does not
# have this order. A rejection here is the truth, not a guess.
status = "RATE_LIMITED" if _is_rate_limited_error(exc) else "REJECTED"
ack_row = {
"status": status,
"msg": str(exc),
"symbol": symbol,
"clientOrderId": client_order_id,
}
# ── Gap 2: fee estimation (ESTIMATED_TAKER / ESTIMATED_MAKER) ────────
# BingX REST ACK does not include commission. WS FILL_SETTLED will deliver
@@ -725,7 +858,10 @@ class BingxDirectExecutionAdapter(ExecutionPort):
# FIX 2026-07-11 (bingx_direct): never fabricate a fill for a rejected order — the
# target_size fallback applies only to accepted MARKET acks whose fill
# arrives later via WS (BingX REST ack omits executedQty on those).
if status in ("REJECTED", "RATE_LIMITED"):
if status in ("REJECTED", "RATE_LIMITED", "INDETERMINATE"):
# INDETERMINATE: we do not know that it filled, so we must not invent a
# fill size from target_size. We also do not know that it DIDN'T — that is
# why the status is not REJECTED. The venue layer escalates from here.
fill_qty = 0.0
else:
fill_qty = float(ack_row.get("executedQty") or ack_row.get("filledQty") or