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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user