Files
sentiment-engine/prod/clean_arch/dita_v2/venue.py

99 lines
3.6 KiB
Python
Raw Normal View History

"""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 []
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