"""Abstract exchange-event seam for DITAv2 (spec G3). ExchangeEvent is the normalised, exchange-agnostic type that flows from the BingX adapter (or poll-failover synthesizer) into AccountProjectionV2 and the reconcile layer. No BingX field names, URLs, or listenKey semantics cross this boundary. Both the WebSocket path and the REST-poll path produce the same ExchangeEvent types so that the two sources are interchangeable — this is the VST↔LIVE symmetry guarantee (gate G3 mode-parity test). """ from __future__ import annotations from dataclasses import dataclass, field from enum import Enum from typing import Dict, Tuple class ExchangeEventKind(str, Enum): FULL_FILL = "FULL_FILL" PARTIAL_FILL = "PARTIAL_FILL" ORDER_ACK = "ORDER_ACK" ORDER_REJECT = "ORDER_REJECT" CANCEL_ACK = "CANCEL_ACK" CANCEL_REJECT = "CANCEL_REJECT" ACCOUNT_UPDATE = "ACCOUNT_UPDATE" # wallet balance / margin facts POSITION_UPDATE = "POSITION_UPDATE" # open-position facts FUNDING_FEE = "FUNDING_FEE" RECONNECTED = "RECONNECTED" # adapter internal — stream resumed UNKNOWN = "UNKNOWN" @dataclass(frozen=True) class ExchangePosition: """Single open position as normalised by the adapter.""" symbol: str = "" qty: float = 0.0 entry_price: float = 0.0 mark_price: float = 0.0 unrealized_pnl: float = 0.0 leverage: float = 1.0 side: str = "" # "LONG" | "SHORT" @dataclass(frozen=True) class ExchangeEvent: """ Normalised exchange event — the abstract seam between the adapter (BingX WS or REST poll) and the kernel/account layer. Immutable. All exchange-specific field names are resolved by the adapter before this type is constructed. Callers should check `kind` before reading kind-specific fields (fill_price/qty, wallet_balance, funding_amount, etc.) — unused fields default to zero/empty. """ kind: ExchangeEventKind event_id: str # dedup key (venue-assigned or synthetic uuid) exchange_ts: int # exchange-assigned timestamp ms # --- FILL / PARTIAL_FILL --- fill_price: float = 0.0 fill_qty: float = 0.0 # incremental fill quantity fee: float = 0.0 fee_asset: str = "" realized_pnl: float = 0.0 order_id: str = "" # venue order id client_order_id: str = "" symbol: str = "" # --- ACCOUNT_UPDATE --- wallet_balance: float = 0.0 available_margin: float = 0.0 used_margin: float = 0.0 maint_margin: float = 0.0 # --- POSITION_UPDATE --- positions: Tuple[ExchangePosition, ...] = () # --- FUNDING_FEE --- funding_amount: float = 0.0 # positive = received, negative = paid funding_ts: int = 0 # --- Order type / maker-taker --- is_maker: bool = False # True when limit order rested and filled (maker) # --- Source metadata --- source: str = "ws" # "ws" | "poll" raw: Dict = field(default_factory=dict) # original frame (debug only) def is_fill(self) -> bool: return self.kind in {ExchangeEventKind.FULL_FILL, ExchangeEventKind.PARTIAL_FILL}