PINK Phase 2 (G3): ExchangeEvent seam + BingxUserStream + mode-parity

exchange_event.py: abstract ExchangeEvent/ExchangeEventKind seam
venue.py: VenueAdapter extended with subscribe()/account_snapshot()
bingx_user_stream.py: PINK-only WS client with listenKey lifecycle,
  gzip, ping/pong, 24h rotation sentinel, reconnect backoff, gap-backfill
mock_venue.py: subscribe()/account_snapshot() for offline tests

Gate G3 mode-parity: WS and poll paths produce identical k_capital,
fees, realized PnL, reconcile status for same logical event sequence.
89/89 total offline tests pass.
This commit is contained in:
Codex
2026-06-01 20:33:44 +02:00
parent 615d24386e
commit 8135a4ae17
5 changed files with 1289 additions and 0 deletions

View File

@@ -0,0 +1,262 @@
"""Deterministic mock venue for DITAv2 tests."""
from __future__ import annotations
import asyncio
import time
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, AsyncIterator, Dict, List, Optional
import itertools
from .contracts import (
KernelCommandType,
KernelEventKind,
KernelIntent,
TradeSide,
VenueEvent,
VenueEventStatus,
VenueOrder,
VenueOrderStatus,
)
from .venue import VenueAdapter
@dataclass(frozen=True)
class MockVenueScenario:
"""Failure knobs for the mock venue."""
reject_entries: bool = False
reject_exits: bool = False
partial_fill_ratio: float = 1.0
cancel_reject: bool = False
emit_ack_before_fill: bool = True
emit_fill_on_submit: bool = False
entry_partial_fill_ratio: float = 1.0
exit_partial_fill_ratio: float = 1.0
class MockVenueAdapter(VenueAdapter):
"""Scriptable mock venue with BingX-shaped response semantics."""
def __init__(self, scenario: Optional[MockVenueScenario] = None):
self.scenario = scenario or MockVenueScenario()
self._order_seq = itertools.count(1)
self._event_seq = itertools.count(1)
self._open_orders: Dict[str, VenueOrder] = {}
self._open_positions: Dict[str, Dict[str, Any]] = {}
def submit(self, intent: KernelIntent) -> List[VenueEvent]:
is_entry = intent.action == KernelCommandType.ENTER
should_reject = self.scenario.reject_entries if is_entry else self.scenario.reject_exits
order_id = f"V-{next(self._order_seq):08d}"
client_id = f"{intent.trade_id}:{intent.intent_id}"
order = VenueOrder(
internal_trade_id=intent.trade_id,
venue_order_id=order_id,
venue_client_id=client_id,
side=intent.side,
intended_size=float(intent.target_size),
status=VenueOrderStatus.NEW,
metadata={"intent_id": intent.intent_id, "action": intent.action.value, "slot_id": intent.slot_id, "asset": intent.asset},
)
if should_reject:
order = VenueOrder(
internal_trade_id=order.internal_trade_id,
venue_order_id=order.venue_order_id,
venue_client_id=order.venue_client_id,
side=order.side,
intended_size=order.intended_size,
filled_size=0.0,
average_fill_price=0.0,
status=VenueOrderStatus.REJECTED,
metadata=dict(order.metadata),
)
return [self._event_from_order(intent, order, KernelEventKind.ORDER_REJECT, VenueEventStatus.REJECTED, reason="MOCK_REJECT")]
self._open_orders[order_id] = order
events: List[VenueEvent] = []
if self.scenario.emit_ack_before_fill or not self.scenario.emit_fill_on_submit:
events.append(self._event_from_order(intent, order, KernelEventKind.ORDER_ACK, VenueEventStatus.ACKED))
if self.scenario.emit_fill_on_submit or self.scenario.partial_fill_ratio > 0:
if is_entry:
effective_ratio = self.scenario.entry_partial_fill_ratio if self.scenario.entry_partial_fill_ratio != 1.0 else self.scenario.partial_fill_ratio
else:
effective_ratio = self.scenario.exit_partial_fill_ratio if self.scenario.exit_partial_fill_ratio != 1.0 else self.scenario.partial_fill_ratio
fill_ratio = max(0.0, min(1.0, float(effective_ratio)))
fill_size = float(intent.target_size) * fill_ratio
event_kind = KernelEventKind.FULL_FILL if fill_ratio >= 1.0 else KernelEventKind.PARTIAL_FILL
event_status = VenueEventStatus.FILLED if fill_ratio >= 1.0 else VenueEventStatus.PARTIALLY_FILLED
fill_event = self._event_from_order(
intent,
order,
event_kind,
event_status,
price=float(intent.reference_price or 0.0),
fill_size=fill_size,
remaining_size=max(0.0, float(intent.target_size) - fill_size),
)
events.append(fill_event)
order = VenueOrder(
internal_trade_id=order.internal_trade_id,
venue_order_id=order.venue_order_id,
venue_client_id=order.venue_client_id,
side=order.side,
intended_size=order.intended_size,
filled_size=fill_size,
average_fill_price=float(intent.reference_price or 0.0),
status=VenueOrderStatus.FILLED if fill_ratio >= 1.0 else VenueOrderStatus.PARTIALLY_FILLED,
metadata=dict(order.metadata),
)
self._open_orders[order_id] = order
return events
def cancel(self, order: VenueOrder, *, reason: str = "") -> List[VenueEvent]:
if self.scenario.cancel_reject:
return [
self._event_from_order(
self._dummy_intent(order),
order,
KernelEventKind.CANCEL_REJECT,
VenueEventStatus.CANCELED_REJECTED,
reason=reason or "MOCK_CANCEL_REJECT",
)
]
existing = self._open_orders.get(order.venue_order_id, order)
canceled = VenueOrder(
internal_trade_id=existing.internal_trade_id,
venue_order_id=existing.venue_order_id,
venue_client_id=existing.venue_client_id,
side=existing.side,
intended_size=existing.intended_size,
filled_size=existing.filled_size,
average_fill_price=existing.average_fill_price,
status=VenueOrderStatus.CANCELED,
metadata=dict(existing.metadata),
)
self._open_orders.pop(order.venue_order_id, None)
return [
self._event_from_order(
self._dummy_intent(order),
canceled,
KernelEventKind.CANCEL_ACK,
VenueEventStatus.CANCELED,
reason=reason or "MOCK_CANCEL_ACK",
)
]
def open_orders(self) -> List[VenueOrder]:
return list(self._open_orders.values())
def open_positions(self) -> List[Dict[str, Any]]:
return list(self._open_positions.values())
def reconcile(self) -> List[VenueEvent]:
return []
def _dummy_intent(self, order: VenueOrder) -> KernelIntent:
return KernelIntent(
timestamp=datetime.now(timezone.utc),
intent_id=order.venue_client_id,
trade_id=order.internal_trade_id,
slot_id=int(order.metadata.get("slot_id", 0)),
asset=str(order.metadata.get("asset", "")),
side=order.side,
action=KernelCommandType.EXIT if order.metadata.get("action") == "EXIT" else KernelCommandType.ENTER,
reference_price=float(order.metadata.get("reference_price", 0.0)),
target_size=float(order.intended_size),
leverage=float(order.metadata.get("leverage", 1.0)),
reason=str(order.metadata.get("reason", "")),
metadata=dict(order.metadata),
)
def _event_from_order(
self,
intent: KernelIntent,
order: VenueOrder,
kind: KernelEventKind,
status: VenueEventStatus,
*,
price: Optional[float] = None,
fill_size: float = 0.0,
remaining_size: float = 0.0,
reason: str = "",
) -> VenueEvent:
event = VenueEvent(
timestamp=datetime.now(timezone.utc),
event_id=f"EV-{next(self._event_seq):08d}",
trade_id=intent.trade_id,
slot_id=intent.slot_id,
kind=kind,
status=status,
venue_order_id=order.venue_order_id,
venue_client_id=order.venue_client_id,
side=order.side,
asset=intent.asset,
price=float(price if price is not None else intent.reference_price or 0.0),
size=float(intent.target_size),
filled_size=float(fill_size),
remaining_size=float(remaining_size),
reason=reason,
raw_payload={
"status": status.value,
"orderId": order.venue_order_id,
"clientOrderId": order.venue_client_id,
"symbol": intent.asset,
"side": order.side.value,
"action": intent.action.value,
},
metadata={"intent_id": intent.intent_id, "action": intent.action.value},
)
return event
# ------------------------------------------------------------------
# Phase 2 stream seam — ExchangeEvent subscribe / account_snapshot
# ------------------------------------------------------------------
def queue_exchange_event(self, event: "ExchangeEvent") -> None: # type: ignore[name-defined]
"""Enqueue an ExchangeEvent to be yielded by subscribe()."""
self._exchange_event_queue.append(event)
async def subscribe(self) -> AsyncIterator["ExchangeEvent"]: # type: ignore[name-defined]
"""
Yield pre-programmed ExchangeEvent instances (offline/test mode).
Waits for events to be enqueued via queue_exchange_event(); yields
them in FIFO order. Sleeps 50 ms between polls.
"""
while True:
if self._exchange_event_queue:
yield self._exchange_event_queue.pop(0)
else:
await asyncio.sleep(0.05)
async def account_snapshot(self) -> "ExchangeEvent": # type: ignore[name-defined]
"""Return a synthetic ACCOUNT_UPDATE based on the mock's internal state."""
from .exchange_event import ExchangeEvent, ExchangeEventKind
return ExchangeEvent(
kind=ExchangeEventKind.ACCOUNT_UPDATE,
event_id=f"mock-snap-{uuid.uuid4().hex[:8]}",
exchange_ts=int(time.time() * 1000),
wallet_balance=self._mock_wallet_balance,
available_margin=self._mock_wallet_balance,
used_margin=0.0,
maint_margin=0.0,
source="poll",
)
@property
def _exchange_event_queue(self) -> list:
if not hasattr(self, "_exeq"):
object.__setattr__(self, "_exeq", []) # avoid dataclass collision
return self._exeq # type: ignore[return-value]
@property
def _mock_wallet_balance(self) -> float:
# Use the account projection's capital if available
proj = getattr(self, "_account_projection", None)
if proj is not None:
snap = getattr(proj, "snapshot", None)
if snap is not None:
return float(getattr(snap, "capital", 0.0))
return 10_000.0