2026-06-01 20:33:44 +02:00
|
|
|
"""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
|
|
|
|
|
|
2026-06-02 14:10:49 +02:00
|
|
|
# --- Order type / maker-taker ---
|
|
|
|
|
is_maker: bool = False # True when limit order rested and filled (maker)
|
|
|
|
|
|
2026-06-01 20:33:44 +02:00
|
|
|
# --- 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}
|