"""Deterministic mock venue for DITAv2 tests.""" from __future__ import annotations from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Any, 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