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>
99 lines
3.6 KiB
Python
99 lines
3.6 KiB
Python
"""Venue adapter contracts for DITAv2."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from typing import Any, AsyncIterator, Dict, List, Optional, Protocol
|
|
|
|
from .contracts import (
|
|
KernelCommandType,
|
|
KernelIntent,
|
|
KernelEventKind,
|
|
TradeSide,
|
|
VenueEvent,
|
|
VenueEventStatus,
|
|
VenueOrder,
|
|
VenueOrderStatus,
|
|
)
|
|
from .exchange_event import ExchangeEvent
|
|
|
|
|
|
class VenuePostAckError(Exception):
|
|
"""Raised when submit fails AFTER the venue has already accepted the order.
|
|
|
|
THE POINT OF NO RETURN. A plain exception out of submit()/submit_async() is
|
|
ambiguous: it may mean "the venue never got the order" (safe to roll the FSM
|
|
back to IDLE) or "the venue filled it and a line below blew up" (rolling back
|
|
is catastrophic — the venue keeps the position while the kernel believes it is
|
|
flat). On 2026-07-13 a post-ack TypeError took the first interpretation and
|
|
orphaned 6 live SHORTs.
|
|
|
|
Post-ack failure is NOT failure. It is UNKNOWN — and unknown is never flat.
|
|
Callers MUST NOT synthesise a REJECTED event for this error: leave the slot in
|
|
its working state and let the E-feed FILL / reconcile settle the truth.
|
|
"""
|
|
|
|
def __init__(self, message: str, *, receipt: Any = None, events: Optional[List[VenueEvent]] = None):
|
|
super().__init__(message)
|
|
self.receipt = receipt
|
|
self.events = events or []
|
|
|
|
|
|
class VenueIndeterminateError(VenuePostAckError):
|
|
"""The submit outcome is UNKNOWN: the request was sent, the answer was lost.
|
|
|
|
A read timeout / connection reset / 5xx means BingX may have matched the order.
|
|
The adapter has already asked the venue about our clientOrderId and could not
|
|
establish the truth, so we are left with genuine uncertainty.
|
|
|
|
Deliberately a subclass of VenuePostAckError, because it demands the SAME
|
|
response: the effect may exist, therefore DO NOT roll the slot back to flat and
|
|
DO NOT synthesise a REJECT. Leave the slot working and let the E-feed FILL /
|
|
the account stream settle it. Callers that already fence VenuePostAckError get
|
|
this behaviour for free.
|
|
|
|
Unknown is not flat. It is not failed either. It is unknown.
|
|
"""
|
|
|
|
|
|
class VenueAdapter(Protocol):
|
|
"""Abstract venue adapter used by the kernel."""
|
|
|
|
def submit(self, intent: KernelIntent) -> List[VenueEvent]:
|
|
...
|
|
|
|
def cancel(self, order: VenueOrder, *, reason: str = "") -> List[VenueEvent]:
|
|
...
|
|
|
|
def open_orders(self) -> List[VenueOrder]:
|
|
...
|
|
|
|
def open_positions(self) -> List[Dict[str, Any]]:
|
|
...
|
|
|
|
def reconcile(self) -> List[VenueEvent]:
|
|
...
|
|
|
|
# ------------------------------------------------------------------
|
|
# Phase 2 — stream seam (spec G3)
|
|
# ------------------------------------------------------------------
|
|
|
|
async def subscribe(self) -> AsyncIterator[ExchangeEvent]:
|
|
"""
|
|
Yield ExchangeEvent instances in arrival order. Implementations
|
|
must handle reconnection, keepalive, and 24h rotation internally.
|
|
The iterator never terminates normally — callers cancel it on
|
|
shutdown. Both the WS and poll-failover paths implement this
|
|
interface so the kernel layer is source-agnostic.
|
|
"""
|
|
... # pragma: no cover
|
|
|
|
async def account_snapshot(self) -> ExchangeEvent:
|
|
"""
|
|
Return a single ACCOUNT_UPDATE + POSITION_UPDATE merged event
|
|
by calling the exchange REST API. Used for gap-backfill on
|
|
reconnect and as the poll-failover path.
|
|
"""
|
|
... # pragma: no cover
|