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