2026-06-01 20:33:44 +02:00
|
|
|
"""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
|
|
|
|
|
|
|
|
|
|
|
dita_v2: unknown != flat (VenuePostAckError) + lossless telemetry lane
BUG CLASS (doc: BUGCLASS_INDETERMINATE_OUTCOME_20260713.md): an operation with an external
side effect has THREE outcomes — NOT_ATTEMPTED, ATTEMPTED_REFUSED, ATTEMPTED_INDETERMINATE
— and rollback is sound only for the first two. Collapsing the third into 'failed' is what
orphaned 6 live SHORTs: a post-ack TypeError reached rust_backend's 'except Exception ->
synthetic REJECTED -> FSM rollback', which asserted 'no order exists' about an order that
was already filled. Telemetry never had a veto; it hijacked the failure channel.
FIXES (no new seams, no re-architecture):
- venue.py: VenuePostAckError — typed channel meaning THE EFFECT EXISTS. Carries receipt.
- bingx_venue submit/submit_async: point-of-no-return fence. Post-ack bookkeeping failures
raise VenuePostAckError instead of a bare exception.
- rust_backend (BOTH submit paths): catch VenuePostAckError FIRST -> no synthetic REJECT,
no rollback. Slot stays working; E-feed FULL_FILL / reconcile settles the truth.
LOSSLESS TELEMETRY (HJ: 'DITAv2 exists precisely because seams dropped 40% of inputs'):
drop-oldest is data loss and is GONE. Exec path appends O(1) to an unbounded queue and
returns. A SEPARATE spiller thread (which never touches the plane, so a wedged plane cannot
starve it) parks the backlog above HWM into a durable append-only spool; the publisher
replays the spool when the plane recovers.
Proven: wedged-forever plane + 200k records -> 0 dropped, 195903 durable on disk, 4096 in
memory, 7.4 us/call on the exec path. Lossless AND memory-bounded. Healthy plane: 2000/2000.
STILL BROKEN, flagged to codex: the pre-ack branch rolls back on TIMEOUT — but a timeout is
the definition of INDETERMINATE (the order may have filled). Same bug class, older, live.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 10:31:39 +02:00
|
|
|
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 []
|
|
|
|
|
|
|
|
|
|
|
2026-06-01 20:33:44 +02:00
|
|
|
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
|