First commit of the previously-untracked PINK-on-DITAv2 migration system (execution moves to the Rust kernel; policy stays on legacy DITA, so Alpha Engine algorithmic integrity is preserved). BLUE is untouched. Sprint 0 (safety snapshot + flaw-fix verification, MARKET single-leg scope): - Verified Rust FSM fixes (flaws 2,4,10,11,13) by source read of lib.rs. - Hardened 5 vacuous/guarded assertions in test_flaws.py so each flaw test genuinely exercises its fix. Most important: Flaw 5 now asserts capital moves by EXACTLY realized PnL (was entering/exiting at the same price). - Offline suites: 533 passed, 0 failed (35 flaws + 402 kernel/accounting/ bridge + 96 runtime/persistence/multi-exit/restart/seams). - GATE PASS: MARKET-path-critical flaws 1,2,5 confirmed fixed + green. - Added SPRINT0_FLAW_VERIFICATION.md report and _rust_kernel/.gitignore (excludes Rust target/ build artifacts). LIMIT/partial-fill remain explicitly out of scope (MARKET-only bring-up). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
908 lines
38 KiB
Python
908 lines
38 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
import random
|
|
from typing import Any, Callable, Iterable, Optional, Sequence
|
|
|
|
import pytest
|
|
|
|
from prod.clean_arch.dita_v2 import (
|
|
BingxVenueAdapter,
|
|
BackendMode,
|
|
ControlUpdate,
|
|
ExecutionKernel,
|
|
InMemoryControlPlane,
|
|
InMemoryZincPlane,
|
|
KernelCommandType,
|
|
KernelControlSnapshot,
|
|
KernelDiagnosticCode,
|
|
KernelEventKind,
|
|
KernelIntent,
|
|
KernelMode,
|
|
KernelVerbosity,
|
|
TradeSide,
|
|
TradeStage,
|
|
VenueEvent,
|
|
VenueEventStatus,
|
|
VenueOrder,
|
|
VenueOrderStatus,
|
|
)
|
|
from prod.clean_arch.ports.execution import ExchangeStateSnapshot, ExecutionReceipt
|
|
|
|
|
|
def _norm_symbol(symbol: str) -> str:
|
|
return str(symbol or "").replace("-", "").replace("_", "").upper()
|
|
|
|
|
|
def _snapshot(
|
|
*,
|
|
capital: float = 25_000.0,
|
|
positions: list[dict[str, Any]] | None = None,
|
|
open_orders: list[dict[str, Any]] | None = None,
|
|
all_orders: list[dict[str, Any]] | None = None,
|
|
all_fills: list[dict[str, Any]] | None = None,
|
|
source: str = "bingx",
|
|
recovered: bool = False,
|
|
) -> ExchangeStateSnapshot:
|
|
position_map = {
|
|
_norm_symbol(str(row.get("symbol", ""))): dict(row)
|
|
for row in (positions or [])
|
|
if _norm_symbol(str(row.get("symbol", "")))
|
|
}
|
|
return ExchangeStateSnapshot(
|
|
timestamp=datetime.now(timezone.utc),
|
|
capital=capital,
|
|
equity=capital,
|
|
open_positions=position_map,
|
|
open_orders=[dict(row) for row in (open_orders or [])],
|
|
all_orders=[dict(row) for row in (all_orders or [])],
|
|
all_fills=[dict(row) for row in (all_fills or [])],
|
|
account={"balances": [{"asset": "USDT", "total": capital}]},
|
|
open_notional=0.0,
|
|
source=source,
|
|
recovered=recovered,
|
|
)
|
|
|
|
|
|
def _sign(side: TradeSide) -> int:
|
|
return -1 if side == TradeSide.SHORT else 1
|
|
|
|
|
|
def _position_row(asset: str, side: TradeSide, qty: float, price: float) -> dict[str, Any]:
|
|
signed_qty = _sign(side) * abs(float(qty))
|
|
return {
|
|
"symbol": asset,
|
|
"positionSide": side.value,
|
|
"positionAmt": f"{signed_qty}",
|
|
"avgPrice": f"{price}",
|
|
"markPrice": f"{price}",
|
|
"leverage": "2",
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class VenueScriptStep:
|
|
name: str
|
|
submit_kind: str
|
|
fill_ratio: float = 0.0
|
|
cancel_kind: str = "cancel_ack"
|
|
submit_advances: bool = True
|
|
cancel_advances: bool = True
|
|
reject_reason: str = "MOCK_REJECT"
|
|
cancel_reason: str = "MOCK_CANCEL"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SignalAction:
|
|
kind: str
|
|
price: float
|
|
target_size: float = 0.0
|
|
fill_ratio: float = 1.0
|
|
reason: str = ""
|
|
require_close: bool = False
|
|
|
|
|
|
class ScriptedVenueAdapter:
|
|
"""Deterministic venue adapter that plays scripted submit/cancel outcomes."""
|
|
|
|
def __init__(self, steps: Sequence[VenueScriptStep]) -> None:
|
|
self.steps = list(steps)
|
|
self._step_index = 0
|
|
self._active_step_index = 0
|
|
self._order_seq = 1
|
|
self._event_seq = 1
|
|
self._open_orders: dict[str, VenueOrder] = {}
|
|
self._open_positions: dict[str, dict[str, Any]] = {}
|
|
self.calls: list[tuple[str, Any]] = []
|
|
|
|
def _next_step(self) -> VenueScriptStep:
|
|
if self._step_index < len(self.steps):
|
|
step = self.steps[self._step_index]
|
|
self._step_index += 1
|
|
return step
|
|
return VenueScriptStep(name="default", submit_kind="ack_only")
|
|
|
|
def submit(self, intent: KernelIntent) -> list[VenueEvent]:
|
|
self.calls.append(("submit", intent.action.value, intent.trade_id, intent.slot_id))
|
|
step = self._next_step()
|
|
self._active_step_index = max(0, self._step_index - 1)
|
|
order_id = f"MOCK-{self._order_seq:08d}"
|
|
self._order_seq += 1
|
|
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),
|
|
filled_size=0.0,
|
|
average_fill_price=float(intent.reference_price or 0.0),
|
|
status=VenueOrderStatus.NEW,
|
|
metadata={"slot_id": intent.slot_id, "asset": intent.asset, "action": intent.action.value},
|
|
)
|
|
if step.submit_kind == "entry_reject":
|
|
return [
|
|
self._event(
|
|
intent=intent,
|
|
order=order,
|
|
kind=KernelEventKind.ORDER_REJECT,
|
|
status=VenueEventStatus.REJECTED,
|
|
reason=step.reject_reason,
|
|
)
|
|
]
|
|
ack = self._event(
|
|
intent=intent,
|
|
order=order,
|
|
kind=KernelEventKind.ORDER_ACK,
|
|
status=VenueEventStatus.ACKED,
|
|
)
|
|
self._open_orders[order_id] = order
|
|
events = [ack]
|
|
if step.submit_kind in {"entry_partial", "exit_partial", "entry_full", "exit_full"}:
|
|
fill_ratio = max(0.0, min(1.0, float(step.fill_ratio or 0.0)))
|
|
if fill_ratio <= 0.0:
|
|
fill_ratio = 1.0 if step.submit_kind.endswith("full") else 0.5
|
|
fill_size = float(intent.target_size) * fill_ratio
|
|
fill_kind = KernelEventKind.FULL_FILL if fill_ratio >= 1.0 else KernelEventKind.PARTIAL_FILL
|
|
fill_status = VenueEventStatus.FILLED if fill_kind == KernelEventKind.FULL_FILL else VenueEventStatus.PARTIALLY_FILLED
|
|
events.append(
|
|
self._event(
|
|
intent=intent,
|
|
order=order,
|
|
kind=fill_kind,
|
|
status=fill_status,
|
|
price=float(intent.reference_price or 0.0),
|
|
filled_size=fill_size,
|
|
remaining_size=max(0.0, float(intent.target_size) - fill_size),
|
|
)
|
|
)
|
|
self._apply_fill(intent, fill_size, fill_kind == KernelEventKind.FULL_FILL)
|
|
if fill_kind == KernelEventKind.FULL_FILL:
|
|
self._open_orders.pop(order_id, None)
|
|
return events
|
|
|
|
def cancel(self, order: VenueOrder, *, reason: str = "") -> list[VenueEvent]:
|
|
self.calls.append(("cancel", order.venue_order_id, reason))
|
|
step = self.steps[min(self._active_step_index, len(self.steps) - 1)] if self.steps else VenueScriptStep(name="default", submit_kind="ack_only")
|
|
if step.cancel_kind == "cancel_reject":
|
|
return [
|
|
self._event(
|
|
intent=self._intent_from_order(order),
|
|
order=order,
|
|
kind=KernelEventKind.CANCEL_REJECT,
|
|
status=VenueEventStatus.CANCELED_REJECTED,
|
|
reason=step.cancel_reason,
|
|
)
|
|
]
|
|
self._open_orders.pop(order.venue_order_id, None)
|
|
if step.cancel_advances:
|
|
self._step_index = max(self._step_index, self._active_step_index + 1)
|
|
return [
|
|
self._event(
|
|
intent=self._intent_from_order(order),
|
|
order=order,
|
|
kind=KernelEventKind.CANCEL_ACK,
|
|
status=VenueEventStatus.CANCELED,
|
|
reason=reason or step.cancel_reason,
|
|
)
|
|
]
|
|
|
|
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]:
|
|
events: list[VenueEvent] = []
|
|
for order in self._open_orders.values():
|
|
events.append(
|
|
self._event(
|
|
intent=self._intent_from_order(order),
|
|
order=order,
|
|
kind=KernelEventKind.ORDER_ACK,
|
|
status=VenueEventStatus.ACKED,
|
|
reason="RECONCILE",
|
|
)
|
|
)
|
|
for row in self._open_positions.values():
|
|
events.append(
|
|
VenueEvent(
|
|
timestamp=datetime.now(timezone.utc),
|
|
event_id=f"EV-{self._event_seq:08d}",
|
|
trade_id=str(row.get("trade_id", "")),
|
|
slot_id=int(row.get("slot_id", 0)),
|
|
kind=KernelEventKind.RECONCILE,
|
|
status=VenueEventStatus.ACKED,
|
|
venue_order_id=str(row.get("venue_order_id", "")),
|
|
venue_client_id=str(row.get("venue_client_id", "")),
|
|
side=TradeSide(str(row.get("side", TradeSide.FLAT.value))),
|
|
asset=str(row.get("symbol", "")),
|
|
price=float(row.get("avgPrice", 0.0)),
|
|
size=abs(float(row.get("positionAmt", 0.0))),
|
|
filled_size=abs(float(row.get("positionAmt", 0.0))),
|
|
remaining_size=0.0,
|
|
reason="RECONCILE",
|
|
raw_payload=dict(row),
|
|
metadata={"source": "mock"},
|
|
)
|
|
)
|
|
self._event_seq += 1
|
|
return events
|
|
|
|
def _event(
|
|
self,
|
|
*,
|
|
intent: KernelIntent,
|
|
order: VenueOrder,
|
|
kind: KernelEventKind,
|
|
status: VenueEventStatus,
|
|
price: float | None = None,
|
|
filled_size: float = 0.0,
|
|
remaining_size: float = 0.0,
|
|
reason: str = "",
|
|
) -> VenueEvent:
|
|
event = VenueEvent(
|
|
timestamp=datetime.now(timezone.utc),
|
|
event_id=f"EV-{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 or 0.0),
|
|
filled_size=float(filled_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},
|
|
)
|
|
self._event_seq += 1
|
|
return event
|
|
|
|
def _intent_from_order(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.average_fill_price or 0.0),
|
|
target_size=float(order.intended_size or 0.0),
|
|
leverage=2.0,
|
|
reason=str(order.metadata.get("action", "")),
|
|
)
|
|
|
|
def _apply_fill(self, intent: KernelIntent, filled_size: float, full: bool) -> None:
|
|
signed = _sign(intent.side) * abs(float(filled_size))
|
|
row = self._open_positions.get(intent.asset)
|
|
if intent.action == KernelCommandType.ENTER:
|
|
self._open_positions[intent.asset] = {
|
|
"symbol": intent.asset,
|
|
"trade_id": intent.trade_id,
|
|
"slot_id": intent.slot_id,
|
|
"side": intent.side.value,
|
|
"positionSide": intent.side.value,
|
|
"positionAmt": f"{signed}",
|
|
"avgPrice": f"{intent.reference_price}",
|
|
"markPrice": f"{intent.reference_price}",
|
|
"venue_order_id": f"MOCK-{self._order_seq - 1:08d}",
|
|
"venue_client_id": f"{intent.trade_id}:{intent.intent_id}",
|
|
}
|
|
return
|
|
if row is None:
|
|
return
|
|
current = abs(float(row.get("positionAmt", 0.0)))
|
|
new_qty = max(0.0, current - abs(float(filled_size)))
|
|
if new_qty <= 1e-12 or full:
|
|
self._open_positions.pop(intent.asset, None)
|
|
return
|
|
row["positionAmt"] = f"{_sign(intent.side) * new_qty}"
|
|
self._open_positions[intent.asset] = row
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BingxE2EStep:
|
|
name: str
|
|
submit_kind: str
|
|
submit_fill_ratio: float
|
|
before_snapshot: ExchangeStateSnapshot
|
|
after_snapshot: ExchangeStateSnapshot
|
|
receipt: ExecutionReceipt
|
|
submit_advances: bool = True
|
|
cancel_kind: str = "cancel_ack"
|
|
cancel_advances: bool = True
|
|
cancel_before_snapshot: ExchangeStateSnapshot | None = None
|
|
cancel_after_snapshot: ExchangeStateSnapshot | None = None
|
|
|
|
|
|
class BingxE2EBackend:
|
|
"""Stateful fake backend that drives the real BingxVenueAdapter."""
|
|
|
|
def __init__(self, steps: Sequence[BingxE2EStep]) -> None:
|
|
self.steps = list(steps)
|
|
self.index = 0
|
|
self.calls: list[tuple[str, Any]] = []
|
|
self.connected = False
|
|
self._operation: str | None = None
|
|
self._active_index = 0
|
|
|
|
async def connect(self) -> bool:
|
|
self.connected = True
|
|
self.calls.append(("connect", None))
|
|
return True
|
|
|
|
async def disconnect(self) -> None:
|
|
self.connected = False
|
|
self.calls.append(("disconnect", None))
|
|
|
|
async def refresh_state(self, symbol: str | None = None, *, include_history: bool = False) -> ExchangeStateSnapshot:
|
|
self.calls.append(("refresh_state", symbol, include_history, self.index, self._operation))
|
|
step = self.steps[min(self._active_index, len(self.steps) - 1)]
|
|
if self._operation == "submit":
|
|
snapshot = step.after_snapshot
|
|
if step.submit_advances:
|
|
self.index = min(self.index + 1, len(self.steps) - 1)
|
|
self._operation = None
|
|
return snapshot
|
|
if self._operation == "cancel":
|
|
snapshot = step.cancel_after_snapshot or step.after_snapshot
|
|
if step.cancel_advances:
|
|
self.index = min(self.index + 1, len(self.steps) - 1)
|
|
self._operation = None
|
|
return snapshot
|
|
return step.before_snapshot
|
|
|
|
async def submit_intent(self, legacy_intent: Any) -> ExecutionReceipt:
|
|
self.calls.append(("submit_intent", legacy_intent.trade_id, legacy_intent.action.value))
|
|
self._active_index = min(self.index, len(self.steps) - 1)
|
|
step = self.steps[self._active_index]
|
|
self._operation = "submit"
|
|
if step.submit_kind == "reject":
|
|
return ExecutionReceipt(
|
|
timestamp=datetime.now(timezone.utc),
|
|
status="REJECTED",
|
|
symbol=legacy_intent.asset,
|
|
side=legacy_intent.side.value,
|
|
action=legacy_intent.action.value,
|
|
quantity=float(legacy_intent.target_size),
|
|
price=float(legacy_intent.reference_price),
|
|
client_order_id=step.receipt.client_order_id,
|
|
order_id=step.receipt.order_id,
|
|
raw_ack={"status": "REJECTED", "msg": "E2E_REJECT"},
|
|
raw_state={},
|
|
)
|
|
return step.receipt
|
|
|
|
async def cancel_order(self, order: VenueOrder, *, reason: str = "") -> dict[str, Any]:
|
|
self.calls.append(("cancel_order", order.venue_order_id, reason))
|
|
self._operation = "cancel"
|
|
step = self.steps[min(self._active_index, len(self.steps) - 1)]
|
|
if step.cancel_kind == "cancel_reject":
|
|
return {"status": "CANCEL_REJECTED", "msg": reason or "E2E_CANCEL_REJECT"}
|
|
return {"status": "CANCELED", "msg": reason or "E2E_CANCEL_ACK"}
|
|
|
|
|
|
def _kernel(venue: Any, *, zinc: Any | None = None) -> ExecutionKernel:
|
|
return ExecutionKernel(
|
|
max_slots=1,
|
|
control_plane=InMemoryControlPlane(
|
|
KernelControlSnapshot(
|
|
mode=KernelMode.DEBUG,
|
|
verbosity=KernelVerbosity.TRACE,
|
|
backend_mode=BackendMode.MOCK if not isinstance(venue, BingxVenueAdapter) else BackendMode.BINGX,
|
|
trace_transitions=True,
|
|
debug_clickhouse_enabled=True,
|
|
mirror_to_hazelcast=True,
|
|
)
|
|
),
|
|
venue=venue,
|
|
zinc_plane=zinc or InMemoryZincPlane(),
|
|
)
|
|
|
|
|
|
def _intent(
|
|
*,
|
|
action: KernelCommandType,
|
|
trade_id: str,
|
|
side: TradeSide,
|
|
slot_id: int = 0,
|
|
target_size: float = 1.0,
|
|
price: float = 100.0,
|
|
exit_leg_ratios: Sequence[float] = (1.0,),
|
|
reason: str = "E2E",
|
|
) -> KernelIntent:
|
|
return KernelIntent(
|
|
timestamp=datetime.now(timezone.utc),
|
|
intent_id=f"{trade_id}:{action.value}:{slot_id}:{reason}",
|
|
trade_id=trade_id,
|
|
slot_id=slot_id,
|
|
asset="BTCUSDT",
|
|
side=side,
|
|
action=action,
|
|
reference_price=price,
|
|
target_size=target_size,
|
|
leverage=2.0,
|
|
exit_leg_ratios=tuple(exit_leg_ratios),
|
|
reason=reason,
|
|
)
|
|
|
|
|
|
def _entry_event(trade_id: str, slot_id: int, side: TradeSide, target_size: float, price: float, *, partial: bool = False, ratio: float = 1.0) -> list[VenueEvent]:
|
|
order = VenueOrder(
|
|
internal_trade_id=trade_id,
|
|
venue_order_id=f"{trade_id}-entry-oid",
|
|
venue_client_id=f"{trade_id}:entry",
|
|
side=side,
|
|
intended_size=target_size,
|
|
filled_size=0.0,
|
|
average_fill_price=price,
|
|
status=VenueOrderStatus.NEW,
|
|
metadata={"slot_id": slot_id, "asset": "BTCUSDT", "action": "ENTER"},
|
|
)
|
|
events = [
|
|
VenueEvent(
|
|
timestamp=datetime.now(timezone.utc),
|
|
event_id=f"{trade_id}-ack",
|
|
trade_id=trade_id,
|
|
slot_id=slot_id,
|
|
kind=KernelEventKind.ORDER_ACK,
|
|
status=VenueEventStatus.ACKED,
|
|
venue_order_id=order.venue_order_id,
|
|
venue_client_id=order.venue_client_id,
|
|
side=side,
|
|
asset="BTCUSDT",
|
|
price=price,
|
|
size=target_size,
|
|
filled_size=0.0,
|
|
remaining_size=target_size,
|
|
)
|
|
]
|
|
if partial:
|
|
fill_size = target_size * ratio
|
|
events.append(
|
|
VenueEvent(
|
|
timestamp=datetime.now(timezone.utc),
|
|
event_id=f"{trade_id}-fill",
|
|
trade_id=trade_id,
|
|
slot_id=slot_id,
|
|
kind=KernelEventKind.PARTIAL_FILL,
|
|
status=VenueEventStatus.PARTIALLY_FILLED,
|
|
venue_order_id=order.venue_order_id,
|
|
venue_client_id=order.venue_client_id,
|
|
side=side,
|
|
asset="BTCUSDT",
|
|
price=price,
|
|
size=target_size,
|
|
filled_size=fill_size,
|
|
remaining_size=max(0.0, target_size - fill_size),
|
|
)
|
|
)
|
|
else:
|
|
events.append(
|
|
VenueEvent(
|
|
timestamp=datetime.now(timezone.utc),
|
|
event_id=f"{trade_id}-fill",
|
|
trade_id=trade_id,
|
|
slot_id=slot_id,
|
|
kind=KernelEventKind.FULL_FILL,
|
|
status=VenueEventStatus.FILLED,
|
|
venue_order_id=order.venue_order_id,
|
|
venue_client_id=order.venue_client_id,
|
|
side=side,
|
|
asset="BTCUSDT",
|
|
price=price,
|
|
size=target_size,
|
|
filled_size=target_size,
|
|
remaining_size=0.0,
|
|
)
|
|
)
|
|
return events
|
|
|
|
|
|
def _close_and_mark(kernel: ExecutionKernel, *, trade_id: str, side: TradeSide, exit_size: float, price: float, reason: str) -> None:
|
|
kernel.process_intent(_intent(action=KernelCommandType.EXIT, trade_id=trade_id, side=side, target_size=exit_size, price=price, exit_leg_ratios=(0.5, 0.5), reason=reason))
|
|
|
|
|
|
def _assert_full_cycle(kernel: ExecutionKernel, *, side: TradeSide, trade_id: str, expect_closed: bool = True) -> None:
|
|
slot = kernel.slot(0)
|
|
assert slot.trade_id == trade_id
|
|
if expect_closed:
|
|
assert slot.closed is True
|
|
assert slot.fsm_state in {TradeStage.CLOSED, TradeStage.IDLE}
|
|
assert kernel.account.snapshot.open_positions in {0, 1}
|
|
|
|
|
|
def _bingx_steps_for_cycle(side: TradeSide, *, hung_exit: bool = False, cancel_reject: bool = False) -> list[BingxE2EStep]:
|
|
entry_receipt = ExecutionReceipt(
|
|
timestamp=datetime.now(timezone.utc),
|
|
status="NEW",
|
|
symbol="BTC-USDT",
|
|
side=side.value,
|
|
action="ENTER",
|
|
quantity=1.0,
|
|
price=75_000.0,
|
|
client_order_id="cid-entry",
|
|
order_id="oid-entry",
|
|
raw_ack={
|
|
"orderId": "oid-entry",
|
|
"clientOrderId": "cid-entry",
|
|
"status": "NEW",
|
|
"symbol": "BTC-USDT",
|
|
"executedQty": "0",
|
|
},
|
|
raw_state={},
|
|
)
|
|
exit_ack_status = "NEW" if hung_exit or cancel_reject else "FILLED"
|
|
exit_filled_qty = 0.0 if hung_exit or cancel_reject else 0.5
|
|
exit_receipt = ExecutionReceipt(
|
|
timestamp=datetime.now(timezone.utc),
|
|
status=exit_ack_status,
|
|
symbol="BTC-USDT",
|
|
side=("SELL" if side == TradeSide.SHORT else "BUY"),
|
|
action="EXIT",
|
|
quantity=0.5,
|
|
price=74_900.0 if side == TradeSide.SHORT else 75_100.0,
|
|
client_order_id="cid-exit-1",
|
|
order_id="oid-exit-1",
|
|
raw_ack={
|
|
"orderId": "oid-exit-1",
|
|
"clientOrderId": "cid-exit-1",
|
|
"status": exit_ack_status,
|
|
"symbol": "BTC-USDT",
|
|
"executedQty": f"{exit_filled_qty}",
|
|
"cumFilledQty": f"{exit_filled_qty}",
|
|
"avgPrice": "74900" if side == TradeSide.SHORT else "75100",
|
|
},
|
|
raw_state={},
|
|
)
|
|
final_receipt = ExecutionReceipt(
|
|
timestamp=datetime.now(timezone.utc),
|
|
status="FILLED",
|
|
symbol="BTC-USDT",
|
|
side=("SELL" if side == TradeSide.SHORT else "BUY"),
|
|
action="EXIT",
|
|
quantity=0.5,
|
|
price=74_850.0 if side == TradeSide.SHORT else 75_150.0,
|
|
client_order_id="cid-exit-2",
|
|
order_id="oid-exit-2",
|
|
raw_ack={
|
|
"orderId": "oid-exit-2",
|
|
"clientOrderId": "cid-exit-2",
|
|
"status": "FILLED",
|
|
"symbol": "BTC-USDT",
|
|
"executedQty": "0.5",
|
|
"cumFilledQty": "0.5",
|
|
"avgPrice": "74850" if side == TradeSide.SHORT else "75150",
|
|
},
|
|
raw_state={},
|
|
)
|
|
entry_before = _snapshot()
|
|
entry_after = _snapshot(
|
|
positions=[_position_row("BTC-USDT", side, 1.0, 75_000.0)],
|
|
open_orders=[
|
|
{
|
|
"symbol": "BTC-USDT",
|
|
"clientOrderId": "cid-entry",
|
|
"clientOrderID": "cid-entry",
|
|
"orderId": "oid-entry",
|
|
"status": "FILLED",
|
|
"origQty": "1",
|
|
"executedQty": "1",
|
|
"avgPrice": "75000",
|
|
}
|
|
],
|
|
all_orders=[{"symbol": "BTC-USDT", "clientOrderId": "cid-entry", "clientOrderID": "cid-entry", "orderId": "oid-entry", "status": "FILLED"}],
|
|
all_fills=[{"symbol": "BTC-USDT", "clientOrderId": "cid-entry", "clientOrderID": "cid-entry", "orderId": "oid-entry", "status": "FILLED", "executedQty": "1", "lastFilledQty": "1", "lastFillPrice": "75000"}],
|
|
)
|
|
exit_before = entry_after
|
|
cancel_open_positions = [_position_row("BTC-USDT", side, 1.0, 75_000.0)] if (hung_exit or cancel_reject) else []
|
|
cancel_open_orders = [
|
|
{
|
|
"symbol": "BTC-USDT",
|
|
"clientOrderId": "cid-exit-1",
|
|
"clientOrderID": "cid-exit-1",
|
|
"orderId": "oid-exit-1",
|
|
"status": exit_ack_status,
|
|
"origQty": "0.5",
|
|
"executedQty": "0",
|
|
"avgPrice": "74900",
|
|
}
|
|
] if (hung_exit or cancel_reject) else []
|
|
exit_after = _snapshot(
|
|
positions=cancel_open_positions,
|
|
open_orders=cancel_open_orders,
|
|
all_orders=[{"symbol": "BTC-USDT", "clientOrderId": "cid-exit-1", "clientOrderID": "cid-exit-1", "orderId": "oid-exit-1", "status": exit_ack_status}],
|
|
all_fills=[{"symbol": "BTC-USDT", "clientOrderId": "cid-exit-1", "clientOrderID": "cid-exit-1", "orderId": "oid-exit-1", "status": exit_ack_status, "executedQty": f"{exit_filled_qty}", "lastFilledQty": f"{exit_filled_qty}", "lastFillPrice": "74900"}] if exit_filled_qty > 0 else [],
|
|
)
|
|
cancel_after = _snapshot(
|
|
positions=cancel_open_positions,
|
|
open_orders=[],
|
|
all_orders=[{"symbol": "BTC-USDT", "clientOrderId": "cid-exit-1", "clientOrderID": "cid-exit-1", "orderId": "oid-exit-1", "status": "CANCELED"}],
|
|
all_fills=[],
|
|
)
|
|
cancel_before = exit_after
|
|
cancel_kind = "cancel_reject" if cancel_reject else "cancel_ack"
|
|
final_before = cancel_after if (hung_exit or cancel_reject) else exit_after
|
|
final_after = _snapshot(
|
|
positions=[_position_row("BTC-USDT", side, 0.5, 74_900.0 if side == TradeSide.SHORT else 75_100.0)] if (hung_exit or cancel_reject) else [],
|
|
open_orders=[],
|
|
all_orders=[{"symbol": "BTC-USDT", "clientOrderId": "cid-exit-2", "clientOrderID": "cid-exit-2", "orderId": "oid-exit-2", "status": "FILLED"}],
|
|
all_fills=[{"symbol": "BTC-USDT", "clientOrderId": "cid-exit-2", "clientOrderID": "cid-exit-2", "orderId": "oid-exit-2", "status": "FILLED", "executedQty": "0.5", "lastFilledQty": "0.5", "lastFillPrice": "74850" if side == TradeSide.SHORT else "75150"}] if (hung_exit or cancel_reject) else [],
|
|
)
|
|
return [
|
|
BingxE2EStep("entry", "fill", 1.0, entry_before, entry_after, entry_receipt),
|
|
BingxE2EStep(
|
|
"exit_hang" if hung_exit else "exit_1",
|
|
"fill" if not hung_exit else "ack_only",
|
|
0.5 if not hung_exit else 0.0,
|
|
exit_before,
|
|
exit_after,
|
|
exit_receipt,
|
|
submit_advances=not (hung_exit or cancel_reject),
|
|
cancel_kind=cancel_kind,
|
|
cancel_before_snapshot=cancel_before,
|
|
cancel_after_snapshot=cancel_after,
|
|
cancel_advances=True,
|
|
),
|
|
BingxE2EStep("exit_2", "fill", 1.0, final_before, final_after, final_receipt),
|
|
]
|
|
|
|
|
|
def _run_signal_plan(kernel: ExecutionKernel, side: TradeSide, plan: Sequence[SignalAction]) -> ExecutionKernel:
|
|
trade_id = f"signal-{side.value.lower()}"
|
|
for step in plan:
|
|
if step.kind == "entry":
|
|
kernel.process_intent(_intent(action=KernelCommandType.ENTER, trade_id=trade_id, side=side, target_size=1.0, price=75_000.0, reason=step.reason or "ENTRY"))
|
|
elif step.kind == "mark":
|
|
kernel.process_intent(_intent(action=KernelCommandType.MARK_PRICE, trade_id=trade_id, side=side, target_size=1.0, price=step.price, reason=step.reason or "MARK"))
|
|
elif step.kind == "exit":
|
|
kernel.process_intent(_intent(action=KernelCommandType.EXIT, trade_id=trade_id, side=side, target_size=step.target_size, price=step.price, exit_leg_ratios=(0.5, 0.5), reason=step.reason or "EXIT"))
|
|
elif step.kind == "cancel":
|
|
slot = kernel.slot(0)
|
|
if step.require_close:
|
|
active_order = slot.active_exit_order
|
|
if active_order is None:
|
|
fallback_client_id = f"{trade_id}:{step.reason or 'CANCEL'}:{slot.slot_id}"
|
|
active_order = VenueOrder(
|
|
internal_trade_id=slot.trade_id or trade_id,
|
|
venue_order_id=str(slot.active_entry_order.venue_order_id if slot.active_entry_order else fallback_client_id),
|
|
venue_client_id=str(slot.active_entry_order.venue_client_id if slot.active_entry_order else fallback_client_id),
|
|
side=slot.side,
|
|
intended_size=float(slot.active_exit_order.intended_size if slot.active_exit_order else max(slot.size, step.target_size or slot.size or 0.0)),
|
|
filled_size=0.0,
|
|
average_fill_price=float(step.price),
|
|
status=VenueOrderStatus.NEW,
|
|
metadata={"slot_id": slot.slot_id, "asset": slot.asset, "action": "EXIT"},
|
|
)
|
|
emitted = kernel.venue.cancel(active_order, reason=step.reason or "CANCEL")
|
|
for event in emitted:
|
|
kernel.on_venue_event(event)
|
|
elif step.kind == "reconcile":
|
|
kernel.process_intent(_intent(action=KernelCommandType.RECONCILE, trade_id=trade_id, side=side, target_size=1.0, price=step.price, reason=step.reason or "RECONCILE"))
|
|
else:
|
|
raise AssertionError(step.kind)
|
|
return kernel
|
|
|
|
|
|
MOCK_SIGNAL_CASES = [
|
|
(
|
|
"short_full_gamut",
|
|
TradeSide.SHORT,
|
|
[
|
|
VenueScriptStep("entry", "entry_full"),
|
|
VenueScriptStep("exit_tp1", "exit_partial", fill_ratio=0.5),
|
|
VenueScriptStep("exit_tp2", "exit_full", fill_ratio=1.0),
|
|
],
|
|
[
|
|
SignalAction("entry", 75_000.0, reason="ENTRY"),
|
|
SignalAction("mark", 74_200.0, reason="PUMP_BREAK"),
|
|
SignalAction("exit", 74_900.0, target_size=0.5, reason="TP1"),
|
|
SignalAction("mark", 74_100.0, reason="TRAIL"),
|
|
SignalAction("exit", 74_800.0, target_size=0.5, reason="TP2"),
|
|
],
|
|
),
|
|
(
|
|
"long_full_gamut",
|
|
TradeSide.LONG,
|
|
[
|
|
VenueScriptStep("entry", "entry_full"),
|
|
VenueScriptStep("exit_tp1", "exit_partial", fill_ratio=0.5),
|
|
VenueScriptStep("exit_tp2", "exit_full", fill_ratio=1.0),
|
|
],
|
|
[
|
|
SignalAction("entry", 75_000.0, reason="ENTRY"),
|
|
SignalAction("mark", 75_800.0, reason="RALLY"),
|
|
SignalAction("exit", 75_100.0, target_size=0.5, reason="TP1"),
|
|
SignalAction("mark", 75_900.0, reason="TRAIL"),
|
|
SignalAction("exit", 75_200.0, target_size=0.5, reason="TP2"),
|
|
],
|
|
),
|
|
(
|
|
"hung_exit_then_cancel",
|
|
TradeSide.SHORT,
|
|
[
|
|
VenueScriptStep("entry", "entry_full"),
|
|
VenueScriptStep("hung_exit", "ack_only", submit_advances=False, cancel_kind="cancel_ack", cancel_advances=True),
|
|
VenueScriptStep("exit_after_cancel", "exit_full", fill_ratio=1.0),
|
|
],
|
|
[
|
|
SignalAction("entry", 75_000.0, reason="ENTRY"),
|
|
SignalAction("mark", 74_300.0, reason="HANG"),
|
|
SignalAction("exit", 74_950.0, target_size=0.5, reason="HUNG_TP"),
|
|
SignalAction("cancel", 74_950.0, reason="CANCEL_HUNG", require_close=True),
|
|
SignalAction("exit", 74_700.0, target_size=0.5, reason="RESUME_TP"),
|
|
],
|
|
),
|
|
(
|
|
"cancel_reject_then_fill",
|
|
TradeSide.SHORT,
|
|
[
|
|
VenueScriptStep("entry", "entry_full"),
|
|
VenueScriptStep("hung_exit", "ack_only", submit_advances=False, cancel_kind="cancel_reject", cancel_advances=False),
|
|
VenueScriptStep("exit_after_reject", "exit_full", fill_ratio=1.0),
|
|
],
|
|
[
|
|
SignalAction("entry", 75_000.0, reason="ENTRY"),
|
|
SignalAction("mark", 74_250.0, reason="HANG"),
|
|
SignalAction("exit", 74_950.0, target_size=0.5, reason="HUNG_TP"),
|
|
SignalAction("cancel", 74_950.0, reason="CANCEL_REJECT", require_close=True),
|
|
SignalAction("exit", 74_650.0, target_size=0.5, reason="FINAL_TP"),
|
|
],
|
|
),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("name,side,steps,plan", MOCK_SIGNAL_CASES, ids=[case[0] for case in MOCK_SIGNAL_CASES])
|
|
def test_mock_signal_gamut_e2e_matrix(name: str, side: TradeSide, steps: Sequence[VenueScriptStep], plan: Sequence[SignalAction]) -> None:
|
|
venue = ScriptedVenueAdapter(steps)
|
|
kernel = _kernel(venue)
|
|
kernel.update_control(ControlUpdate(mode=KernelMode.DEBUG, verbosity=KernelVerbosity.TRACE))
|
|
_run_signal_plan(kernel, side, plan)
|
|
slot = kernel.slot(0)
|
|
assert slot.trade_id == f"signal-{side.value.lower()}"
|
|
assert venue.calls[0][0] == "submit"
|
|
expected_cancel = any(step.kind == "cancel" and step.require_close for step in plan)
|
|
assert any(call[0] == "cancel" for call in venue.calls) == expected_cancel
|
|
assert kernel.snapshot()["control"]["mode"] == KernelMode.DEBUG.value
|
|
if name in {"short_full_gamut", "long_full_gamut", "hung_exit_then_cancel", "cancel_reject_then_fill"}:
|
|
assert slot.fsm_state in {TradeStage.CLOSED, TradeStage.POSITION_OPEN, TradeStage.EXIT_WORKING}
|
|
if name == "hung_exit_then_cancel":
|
|
assert any(call[0] == "cancel" for call in venue.calls)
|
|
assert slot.closed is True or slot.fsm_state == TradeStage.POSITION_OPEN
|
|
|
|
|
|
def _bingx_backend_for_plan(side: TradeSide, *, hung_exit: bool = False, cancel_reject: bool = False) -> BingxE2EBackend:
|
|
return BingxE2EBackend(_bingx_steps_for_cycle(side, hung_exit=hung_exit, cancel_reject=cancel_reject))
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"side,hung_exit,cancel_reject",
|
|
[
|
|
(TradeSide.SHORT, False, False),
|
|
(TradeSide.LONG, False, False),
|
|
(TradeSide.SHORT, True, False),
|
|
(TradeSide.SHORT, True, True),
|
|
],
|
|
ids=["short_full", "long_full", "short_hung", "short_cancel_reject"],
|
|
)
|
|
def test_bingx_basic_e2e_matrix(side: TradeSide, hung_exit: bool, cancel_reject: bool) -> None:
|
|
backend = _bingx_backend_for_plan(side, hung_exit=hung_exit, cancel_reject=cancel_reject)
|
|
venue = BingxVenueAdapter(backend=backend)
|
|
kernel = _kernel(venue)
|
|
kernel.update_control(ControlUpdate(mode=KernelMode.DEBUG, verbosity=KernelVerbosity.TRACE, backend_mode=BackendMode.BINGX))
|
|
_run_signal_plan(
|
|
kernel,
|
|
side,
|
|
[
|
|
SignalAction("entry", 75_000.0, reason="ENTRY"),
|
|
SignalAction("mark", 74_200.0 if side == TradeSide.SHORT else 75_800.0, reason="MARK"),
|
|
SignalAction("exit", 74_900.0 if side == TradeSide.SHORT else 75_100.0, target_size=0.5, reason="TP1"),
|
|
SignalAction("cancel", 74_900.0 if side == TradeSide.SHORT else 75_100.0, reason="CANCEL" if hung_exit or cancel_reject else "NO_CANCEL", require_close=hung_exit or cancel_reject),
|
|
SignalAction("exit", 74_850.0 if side == TradeSide.SHORT else 75_150.0, target_size=0.5, reason="TP2"),
|
|
],
|
|
)
|
|
slot = kernel.slot(0)
|
|
assert backend.connected is False
|
|
assert any(call[0] == "submit_intent" for call in backend.calls)
|
|
assert slot.trade_id.startswith("signal-")
|
|
assert slot.fsm_state in {TradeStage.CLOSED, TradeStage.POSITION_OPEN, TradeStage.EXIT_WORKING}
|
|
if not hung_exit:
|
|
assert slot.closed is True
|
|
else:
|
|
assert any(call[0] == "cancel_order" for call in backend.calls)
|
|
|
|
|
|
FUZZ_SEEDS = tuple(range(12))
|
|
FUZZ_VENUES = ("mock", "bingx")
|
|
|
|
|
|
@pytest.mark.parametrize("seed", FUZZ_SEEDS, ids=lambda seed: f"seed-{seed}")
|
|
@pytest.mark.parametrize("venue_kind", FUZZ_VENUES, ids=lambda venue_kind: f"venue-{venue_kind}")
|
|
def test_e2e_chaos_fuzz_matrix(seed: int, venue_kind: str) -> None:
|
|
rng = random.Random(20260527 + seed)
|
|
side = rng.choice([TradeSide.SHORT, TradeSide.LONG])
|
|
if venue_kind == "mock":
|
|
steps = [
|
|
VenueScriptStep("entry", "entry_full" if rng.random() > 0.2 else "entry_partial", fill_ratio=1.0 if rng.random() > 0.5 else 0.5),
|
|
VenueScriptStep("exit", "ack_only" if rng.random() > 0.35 else "exit_partial", fill_ratio=0.5, cancel_kind="cancel_reject" if rng.random() > 0.75 else "cancel_ack", submit_advances=False if rng.random() > 0.35 else True),
|
|
VenueScriptStep("exit2", "exit_full", fill_ratio=1.0),
|
|
]
|
|
venue = ScriptedVenueAdapter(steps)
|
|
kernel = _kernel(venue)
|
|
else:
|
|
backend = _bingx_backend_for_plan(side, hung_exit=rng.random() > 0.4, cancel_reject=rng.random() > 0.7)
|
|
venue = BingxVenueAdapter(backend=backend)
|
|
kernel = _kernel(venue)
|
|
kernel.update_control(ControlUpdate(backend_mode=BackendMode.BINGX))
|
|
|
|
trade_id = f"fuzz-{venue_kind}-{seed}"
|
|
kernel.process_intent(_intent(action=KernelCommandType.ENTER, trade_id=trade_id, side=side, target_size=1.0, price=75_000.0, reason="ENTER"))
|
|
|
|
for idx in range(rng.randint(2, 5)):
|
|
op = rng.choice(["mark", "exit", "cancel", "reconcile"])
|
|
slot = kernel.slot(0)
|
|
if op == "mark":
|
|
kernel.process_intent(_intent(action=KernelCommandType.MARK_PRICE, trade_id=trade_id, side=side, target_size=1.0, price=74_000.0 if side == TradeSide.SHORT else 76_000.0, reason=f"MARK-{idx}"))
|
|
elif op == "exit" and slot.fsm_state in {TradeStage.POSITION_OPEN, TradeStage.EXIT_WORKING}:
|
|
kernel.process_intent(_intent(action=KernelCommandType.EXIT, trade_id=trade_id, side=side, target_size=max(0.1, slot.size or 0.5), price=74_900.0 if side == TradeSide.SHORT else 75_100.0, exit_leg_ratios=(0.5, 0.5), reason=f"EXIT-{idx}"))
|
|
elif op == "cancel" and slot.active_exit_order is not None:
|
|
kernel.process_intent(_intent(action=KernelCommandType.CANCEL, trade_id=trade_id, side=side, target_size=slot.active_exit_order.intended_size, price=74_900.0 if side == TradeSide.SHORT else 75_100.0, reason=f"CANCEL-{idx}"))
|
|
elif op == "reconcile":
|
|
kernel.process_intent(_intent(action=KernelCommandType.RECONCILE, trade_id=trade_id, side=side, target_size=1.0, price=75_000.0, reason=f"RECONCILE-{idx}"))
|
|
|
|
final_slot = kernel.slot(0)
|
|
assert final_slot.trade_id == trade_id
|
|
assert final_slot.fsm_state in {
|
|
TradeStage.ENTRY_WORKING,
|
|
TradeStage.POSITION_OPEN,
|
|
TradeStage.EXIT_WORKING,
|
|
TradeStage.CLOSED,
|
|
TradeStage.STALE_STATE_RECONCILING,
|
|
}
|
|
assert kernel.account.snapshot.equity == pytest.approx(kernel.account.snapshot.capital + kernel.account.snapshot.unrealized_pnl, abs=1e-9)
|
|
assert kernel.snapshot()["control"]["runtime_namespace"] == "dita_v2"
|
|
if final_slot.closed:
|
|
assert final_slot.size == pytest.approx(0.0, abs=1e-9)
|
|
else:
|
|
assert final_slot.fsm_state in {
|
|
TradeStage.ENTRY_WORKING,
|
|
TradeStage.POSITION_OPEN,
|
|
TradeStage.EXIT_WORKING,
|
|
TradeStage.STALE_STATE_RECONCILING,
|
|
}
|