Files
sentiment-engine/prod/clean_arch/dita_v2/venue.py
Codex bdc54fbeaa 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>
2026-07-13 15:43:55 +02:00

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