Files
siloqy/prod/clean_arch/dita_v2/mock_venue.py

210 lines
8.4 KiB
Python
Raw Normal View History

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