Snapshot PINK DITAv2 system + Sprint 0 flaw-fix verification
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>
This commit is contained in:
391
prod/tests/test_dita_v2_bingx_adapter.py
Normal file
391
prod/tests/test_dita_v2_bingx_adapter.py
Normal file
@@ -0,0 +1,391 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from prod.clean_arch.dita_v2 import (
|
||||
BingxVenueAdapter,
|
||||
ExecutionKernel,
|
||||
InMemoryControlPlane,
|
||||
KernelCommandType,
|
||||
KernelControlSnapshot,
|
||||
KernelIntent,
|
||||
KernelMode,
|
||||
KernelEventKind,
|
||||
KernelVerbosity,
|
||||
TradeSide,
|
||||
TradeStage,
|
||||
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",
|
||||
) -> 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=False,
|
||||
)
|
||||
|
||||
|
||||
class FakeBingxBackend:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
snapshots: list[ExchangeStateSnapshot],
|
||||
receipt: ExecutionReceipt | None = None,
|
||||
cancel_response: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
self.snapshots = snapshots
|
||||
self.receipt = receipt
|
||||
self.cancel_response = cancel_response or {"status": "CANCELED"}
|
||||
self.calls: list[tuple[str, Any]] = []
|
||||
self.submitted: list[Any] = []
|
||||
self.canceled: list[tuple[Any, str]] = []
|
||||
self._refresh_count = 0
|
||||
self.connected = False
|
||||
|
||||
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))
|
||||
index = min(self._refresh_count, len(self.snapshots) - 1)
|
||||
snapshot = self.snapshots[index]
|
||||
if self._refresh_count < len(self.snapshots) - 1:
|
||||
self._refresh_count += 1
|
||||
return snapshot
|
||||
|
||||
async def submit_intent(self, legacy_intent: Any) -> ExecutionReceipt:
|
||||
self.calls.append(("submit_intent", legacy_intent.trade_id))
|
||||
self.submitted.append(legacy_intent)
|
||||
if self.receipt is None:
|
||||
raise AssertionError("receipt must be configured")
|
||||
return self.receipt
|
||||
|
||||
async def cancel_order(self, order: VenueOrder, *, reason: str = "") -> dict[str, Any]:
|
||||
self.calls.append(("cancel_order", order.venue_order_id, reason))
|
||||
self.canceled.append((order, reason))
|
||||
return dict(self.cancel_response)
|
||||
|
||||
|
||||
def _intent(
|
||||
*,
|
||||
action: KernelCommandType = KernelCommandType.ENTER,
|
||||
trade_id: str = "trade-1",
|
||||
slot_id: int = 0,
|
||||
asset: str = "BTCUSDT",
|
||||
side: TradeSide = TradeSide.SHORT,
|
||||
target_size: float = 1.0,
|
||||
leverage: float = 2.0,
|
||||
reference_price: float = 75_000.0,
|
||||
reason: str = "TEST",
|
||||
) -> KernelIntent:
|
||||
return KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id=f"{trade_id}:{action.value}",
|
||||
trade_id=trade_id,
|
||||
slot_id=slot_id,
|
||||
asset=asset,
|
||||
side=side,
|
||||
action=action,
|
||||
reference_price=reference_price,
|
||||
target_size=target_size,
|
||||
leverage=leverage,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
|
||||
def test_submit_maps_bingx_ack_and_snapshot_fill_to_ditav2_events() -> None:
|
||||
ack_row = {
|
||||
"orderId": "1001",
|
||||
"clientOrderId": "cid-1",
|
||||
"clientOrderID": "cid-1",
|
||||
"symbol": "BTC-USDT",
|
||||
"status": "NEW",
|
||||
"executedQty": "0",
|
||||
"cumFilledQty": "0",
|
||||
}
|
||||
fill_row = {
|
||||
"clientOrderId": "cid-1",
|
||||
"clientOrderID": "cid-1",
|
||||
"orderId": "1001",
|
||||
"symbol": "BTC-USDT",
|
||||
"status": "FILLED",
|
||||
"executedQty": "1",
|
||||
"lastFilledQty": "1",
|
||||
"lastFillPrice": "75000",
|
||||
}
|
||||
backend = FakeBingxBackend(
|
||||
snapshots=[
|
||||
_snapshot(),
|
||||
_snapshot(
|
||||
positions=[
|
||||
{
|
||||
"symbol": "BTC-USDT",
|
||||
"positionSide": "SHORT",
|
||||
"positionAmt": "-1",
|
||||
"avgPrice": "75000",
|
||||
"markPrice": "75010",
|
||||
"leverage": "2",
|
||||
}
|
||||
],
|
||||
open_orders=[ack_row],
|
||||
all_orders=[ack_row],
|
||||
all_fills=[fill_row],
|
||||
),
|
||||
],
|
||||
receipt=ExecutionReceipt(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
status="NEW",
|
||||
symbol="BTC-USDT",
|
||||
side="SELL",
|
||||
action="ENTER",
|
||||
quantity=1.0,
|
||||
price=75_000.0,
|
||||
client_order_id="cid-1",
|
||||
order_id="1001",
|
||||
raw_ack=ack_row,
|
||||
raw_state={},
|
||||
),
|
||||
)
|
||||
adapter = BingxVenueAdapter(backend=backend)
|
||||
|
||||
events = adapter.submit(_intent())
|
||||
|
||||
assert backend.connected is False
|
||||
assert backend.submitted
|
||||
assert [event.kind for event in events] == [event.kind for event in events if event.kind.value]
|
||||
assert events[0].kind.value == "ORDER_ACK"
|
||||
assert events[0].status == VenueEventStatus.ACKED
|
||||
assert events[0].venue_client_id == "cid-1"
|
||||
assert events[0].venue_order_id == "1001"
|
||||
assert len(events) == 2
|
||||
assert events[1].kind.value == "FULL_FILL"
|
||||
assert events[1].status == VenueEventStatus.FILLED
|
||||
assert events[1].filled_size == pytest.approx(1.0)
|
||||
assert events[1].remaining_size == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_cancel_uses_bingx_cancel_surface_and_maps_cancel_ack() -> None:
|
||||
cancel_row = {
|
||||
"orderId": "2001",
|
||||
"clientOrderId": "cid-2",
|
||||
"clientOrderID": "cid-2",
|
||||
"symbol": "BTC-USDT",
|
||||
"status": "CANCELED",
|
||||
}
|
||||
backend = FakeBingxBackend(
|
||||
snapshots=[
|
||||
_snapshot(
|
||||
open_orders=[cancel_row],
|
||||
all_orders=[cancel_row],
|
||||
),
|
||||
_snapshot(),
|
||||
],
|
||||
cancel_response=cancel_row,
|
||||
)
|
||||
adapter = BingxVenueAdapter(backend=backend)
|
||||
order = VenueOrder(
|
||||
internal_trade_id="trade-2",
|
||||
venue_order_id="2001",
|
||||
venue_client_id="cid-2",
|
||||
side=TradeSide.SHORT,
|
||||
intended_size=1.0,
|
||||
status=VenueOrderStatus.NEW,
|
||||
metadata={"slot_id": 0, "asset": "BTCUSDT"},
|
||||
)
|
||||
|
||||
events = adapter.cancel(order, reason="MANUAL_CLOSE")
|
||||
|
||||
assert backend.canceled
|
||||
assert events[0].kind.value == "CANCEL_ACK"
|
||||
assert events[0].status == VenueEventStatus.CANCELED
|
||||
assert events[0].venue_order_id == "2001"
|
||||
assert events[0].reason == "MANUAL_CLOSE"
|
||||
|
||||
|
||||
def test_reconcile_and_open_views_normalize_bingx_rows() -> None:
|
||||
ack_row = {
|
||||
"orderId": "3001",
|
||||
"clientOrderId": "cid-3",
|
||||
"clientOrderID": "cid-3",
|
||||
"symbol": "ETH-USDT",
|
||||
"status": "NEW",
|
||||
"executedQty": "0",
|
||||
}
|
||||
fill_row = {
|
||||
"clientOrderId": "cid-3",
|
||||
"clientOrderID": "cid-3",
|
||||
"orderId": "3001",
|
||||
"symbol": "ETH-USDT",
|
||||
"status": "PARTIALLY_FILLED",
|
||||
"executedQty": "2",
|
||||
"lastFilledQty": "1",
|
||||
"lastFillPrice": "2500",
|
||||
}
|
||||
position_row = {
|
||||
"symbol": "ETH-USDT",
|
||||
"positionSide": "LONG",
|
||||
"positionAmt": "2",
|
||||
"avgPrice": "2500",
|
||||
"markPrice": "2510",
|
||||
"leverage": "3",
|
||||
}
|
||||
backend = FakeBingxBackend(
|
||||
snapshots=[
|
||||
_snapshot(
|
||||
positions=[position_row],
|
||||
open_orders=[ack_row],
|
||||
all_orders=[ack_row, fill_row],
|
||||
all_fills=[fill_row],
|
||||
)
|
||||
]
|
||||
)
|
||||
adapter = BingxVenueAdapter(backend=backend)
|
||||
|
||||
orders = adapter.open_orders()
|
||||
positions = adapter.open_positions()
|
||||
events = adapter.reconcile()
|
||||
|
||||
assert orders[0].status == VenueOrderStatus.NEW
|
||||
assert orders[0].venue_client_id == "cid-3"
|
||||
assert positions[0]["positionAmt"] == "2"
|
||||
assert any(event.kind.value == "PARTIAL_FILL" for event in events)
|
||||
assert any(event.kind.value == "ORDER_ACK" for event in events)
|
||||
|
||||
|
||||
def test_kernel_can_drive_through_bingx_venue_shim() -> None:
|
||||
ack_row = {
|
||||
"orderId": "4001",
|
||||
"clientOrderId": "cid-4",
|
||||
"clientOrderID": "cid-4",
|
||||
"symbol": "BTC-USDT",
|
||||
"status": "NEW",
|
||||
"executedQty": "0",
|
||||
}
|
||||
fill_row = {
|
||||
"clientOrderId": "cid-4",
|
||||
"clientOrderID": "cid-4",
|
||||
"orderId": "4001",
|
||||
"symbol": "BTC-USDT",
|
||||
"status": "FILLED",
|
||||
"executedQty": "1",
|
||||
"lastFilledQty": "1",
|
||||
"lastFillPrice": "75000",
|
||||
}
|
||||
backend = FakeBingxBackend(
|
||||
snapshots=[
|
||||
_snapshot(),
|
||||
_snapshot(
|
||||
positions=[
|
||||
{
|
||||
"symbol": "BTC-USDT",
|
||||
"positionSide": "SHORT",
|
||||
"positionAmt": "-1",
|
||||
"avgPrice": "75000",
|
||||
"markPrice": "75010",
|
||||
"leverage": "2",
|
||||
}
|
||||
],
|
||||
open_orders=[ack_row],
|
||||
all_orders=[ack_row],
|
||||
all_fills=[fill_row],
|
||||
),
|
||||
],
|
||||
receipt=ExecutionReceipt(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
status="NEW",
|
||||
symbol="BTC-USDT",
|
||||
side="SELL",
|
||||
action="ENTER",
|
||||
quantity=1.0,
|
||||
price=75_000.0,
|
||||
client_order_id="cid-4",
|
||||
order_id="4001",
|
||||
raw_ack=ack_row,
|
||||
raw_state={},
|
||||
),
|
||||
)
|
||||
kernel = ExecutionKernel(
|
||||
control_plane=InMemoryControlPlane(
|
||||
KernelControlSnapshot(mode=KernelMode.DEBUG, verbosity=KernelVerbosity.TRACE)
|
||||
),
|
||||
venue=BingxVenueAdapter(backend=backend),
|
||||
)
|
||||
|
||||
outcome = kernel.process_intent(_intent(trade_id="trade-4"))
|
||||
|
||||
slot = kernel.slot(0)
|
||||
assert outcome.accepted is True
|
||||
assert slot.fsm_state == TradeStage.POSITION_OPEN
|
||||
assert slot.trade_id == "trade-4"
|
||||
assert backend.submitted
|
||||
|
||||
|
||||
def test_submit_maps_bingx_rate_limit_to_first_class_venue_event() -> None:
|
||||
backend = FakeBingxBackend(
|
||||
snapshots=[_snapshot(), _snapshot()],
|
||||
receipt=ExecutionReceipt(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
status="RATE_LIMITED",
|
||||
symbol="BTC-USDT",
|
||||
side="SELL",
|
||||
action="ENTER",
|
||||
quantity=1.0,
|
||||
price=75_000.0,
|
||||
client_order_id="cid-rate-limit",
|
||||
order_id="",
|
||||
raw_ack={
|
||||
"status": "RATE_LIMITED",
|
||||
"msg": "code:100410 endpoint is in disabled/frequency-limited period",
|
||||
"retryAfter": int(datetime.now(timezone.utc).timestamp() * 1000) + 2_500,
|
||||
},
|
||||
raw_state={},
|
||||
),
|
||||
)
|
||||
adapter = BingxVenueAdapter(backend=backend)
|
||||
|
||||
events = adapter.submit(_intent(trade_id="trade-rate-limit"))
|
||||
|
||||
assert len(events) == 1
|
||||
assert events[0].kind == KernelEventKind.RATE_LIMITED
|
||||
assert events[0].status == VenueEventStatus.RATE_LIMITED
|
||||
assert events[0].venue_client_id == "cid-rate-limit"
|
||||
assert events[0].metadata["retry_after_ms"] >= 0
|
||||
94
prod/tests/test_dita_v2_control_plane.py
Normal file
94
prod/tests/test_dita_v2_control_plane.py
Normal file
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from prod.clean_arch.dita_v2 import (
|
||||
BackendMode,
|
||||
ControlUpdate,
|
||||
InMemoryControlPlane,
|
||||
ZincControlPlane,
|
||||
KernelControlSnapshot,
|
||||
KernelMode,
|
||||
KernelVerbosity,
|
||||
RealZincControlPlane,
|
||||
build_control_plane,
|
||||
)
|
||||
from prod.clean_arch.dita_v2.real_control_plane import SharedRegion
|
||||
|
||||
|
||||
HAS_REAL_ZINC = SharedRegion is not None
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_REAL_ZINC, "Real Zinc adapter is unavailable")
|
||||
class TestDITAv2RealControlPlane(unittest.TestCase):
|
||||
def test_build_control_plane_defaults_to_zinc(self) -> None:
|
||||
plane = build_control_plane()
|
||||
self.assertIsInstance(plane, ZincControlPlane)
|
||||
|
||||
def test_roundtrip_update_and_read(self) -> None:
|
||||
prefix = f"dita_v2_control_{uuid4().hex}"
|
||||
writer = RealZincControlPlane(prefix=prefix, create=True)
|
||||
reader = RealZincControlPlane(prefix=prefix, create=False)
|
||||
try:
|
||||
snapshot = writer.update(
|
||||
ControlUpdate(
|
||||
mode=KernelMode.DEBUG,
|
||||
verbosity=KernelVerbosity.TRACE,
|
||||
backend_mode=BackendMode.BINGX,
|
||||
trace_transitions=True,
|
||||
mirror_to_hazelcast=True,
|
||||
)
|
||||
)
|
||||
self.assertEqual(snapshot.mode, KernelMode.DEBUG)
|
||||
self.assertEqual(snapshot.verbosity, KernelVerbosity.TRACE)
|
||||
self.assertEqual(snapshot.backend_mode, BackendMode.BINGX)
|
||||
self.assertTrue(snapshot.trace_transitions)
|
||||
read_back = reader.read()
|
||||
self.assertEqual(read_back.mode, KernelMode.DEBUG)
|
||||
self.assertEqual(read_back.verbosity, KernelVerbosity.TRACE)
|
||||
self.assertEqual(read_back.backend_mode, BackendMode.BINGX)
|
||||
self.assertTrue(read_back.trace_transitions)
|
||||
finally:
|
||||
writer.close()
|
||||
reader.close()
|
||||
|
||||
def test_env_can_select_real_control_plane(self) -> None:
|
||||
prefix = f"dita_v2_control_{uuid4().hex}"
|
||||
previous = os.environ.get("DITA_V2_CONTROL_PLANE")
|
||||
os.environ["DITA_V2_CONTROL_PLANE"] = "REAL_ZINC"
|
||||
try:
|
||||
plane = build_control_plane(prefix=prefix)
|
||||
self.assertIsInstance(plane, RealZincControlPlane)
|
||||
if isinstance(plane, RealZincControlPlane):
|
||||
plane.close()
|
||||
finally:
|
||||
if previous is None:
|
||||
os.environ.pop("DITA_V2_CONTROL_PLANE", None)
|
||||
else:
|
||||
os.environ["DITA_V2_CONTROL_PLANE"] = previous
|
||||
|
||||
def test_initial_snapshot_is_default(self) -> None:
|
||||
prefix = f"dita_v2_control_{uuid4().hex}"
|
||||
plane = RealZincControlPlane(prefix=prefix, create=True)
|
||||
try:
|
||||
snapshot = plane.read()
|
||||
self.assertEqual(snapshot, KernelControlSnapshot())
|
||||
finally:
|
||||
plane.close()
|
||||
|
||||
|
||||
class TestDITAv2InMemoryControlPlane(unittest.TestCase):
|
||||
def test_wait_and_notify(self) -> None:
|
||||
plane = InMemoryControlPlane()
|
||||
self.assertFalse(plane.wait(timeout_ms=1))
|
||||
plane.notify()
|
||||
self.assertTrue(plane.wait(timeout_ms=1))
|
||||
snapshot = plane.update(ControlUpdate(mode=KernelMode.DEBUG, verbosity=KernelVerbosity.TRACE))
|
||||
self.assertEqual(snapshot.mode, KernelMode.DEBUG)
|
||||
self.assertEqual(snapshot.verbosity, KernelVerbosity.TRACE)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
37
prod/tests/test_dita_v2_docs.py
Normal file
37
prod/tests/test_dita_v2_docs.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
|
||||
class TestDITAv2Docs(unittest.TestCase):
|
||||
def test_kernel_reference_exists(self) -> None:
|
||||
text = Path("/mnt/dolphinng5_predict/prod/docs/DITA_V2_KERNEL_REFERENCE.md").read_text()
|
||||
self.assertIn("# DITAv2 Kernel Reference", text)
|
||||
self.assertIn("dolphin:dita_v2", text)
|
||||
self.assertIn("prod/clean_arch/dita_v2/rust_backend.py", text)
|
||||
self.assertIn("write-through", text)
|
||||
self.assertIn("notify/wait", text)
|
||||
self.assertIn("50 collected cases", text)
|
||||
self.assertIn("full-stack E2E / functional tests", text)
|
||||
self.assertIn("mocked exchange-first and BingX-basic E2E paths", text)
|
||||
self.assertIn("KernelSeverity.WARNING", text)
|
||||
self.assertIn("release_eta", text)
|
||||
self.assertIn("retryable", text)
|
||||
self.assertIn("dita_v2_live_bingx_smoke.py", text)
|
||||
self.assertIn("--dry-run", text)
|
||||
|
||||
def test_system_bible_points_to_dita_v2_reference(self) -> None:
|
||||
bible = Path("/mnt/dolphinng5_predict/prod/docs/SYSTEM_BIBLE_v7.md").read_text()
|
||||
self.assertIn("DITA_V2_KERNEL_REFERENCE.md", bible)
|
||||
self.assertIn("DITAv2 execution/launcher/operator surface", bible)
|
||||
self.assertIn("write-through Zinc mirror semantics", bible)
|
||||
self.assertIn("one-shot notify/wait signal contract", bible)
|
||||
self.assertIn("full-stack DITAv2 E2E/functional matrix", bible)
|
||||
self.assertIn("retryable transient throttling", bible)
|
||||
self.assertIn("dita_v2_live_bingx_smoke.py", bible)
|
||||
self.assertIn("--dry-run", bible)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
907
prod/tests/test_dita_v2_e2e_functional.py
Normal file
907
prod/tests/test_dita_v2_e2e_functional.py
Normal file
@@ -0,0 +1,907 @@
|
||||
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,
|
||||
}
|
||||
612
prod/tests/test_dita_v2_hardening.py
Normal file
612
prod/tests/test_dita_v2_hardening.py
Normal file
@@ -0,0 +1,612 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from prod.clean_arch.dita_v2 import (
|
||||
BackendMode,
|
||||
ControlUpdate,
|
||||
ExecutionKernel,
|
||||
InMemoryControlPlane,
|
||||
InMemoryZincPlane,
|
||||
KernelCommandType,
|
||||
KernelControlSnapshot,
|
||||
KernelDiagnosticCode,
|
||||
KernelEventKind,
|
||||
KernelIntent,
|
||||
KernelMode,
|
||||
KernelVerbosity,
|
||||
MemoryKernelJournal,
|
||||
TradeSide,
|
||||
TradeSlot,
|
||||
TradeStage,
|
||||
VenueEvent,
|
||||
VenueEventStatus,
|
||||
VenueOrder,
|
||||
VenueOrderStatus,
|
||||
)
|
||||
|
||||
|
||||
class NoopVenueAdapter:
|
||||
def submit(self, intent): # type: ignore[override]
|
||||
return []
|
||||
|
||||
def cancel(self, order, *, reason: str = ""): # type: ignore[override]
|
||||
return []
|
||||
|
||||
def open_orders(self): # type: ignore[override]
|
||||
return []
|
||||
|
||||
def open_positions(self): # type: ignore[override]
|
||||
return []
|
||||
|
||||
def reconcile(self): # type: ignore[override]
|
||||
return []
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IntentGuardCase:
|
||||
name: str
|
||||
slot_id: int
|
||||
seed_state: str
|
||||
action: KernelCommandType
|
||||
trade_id: str
|
||||
intent_trade_id: str
|
||||
expected_state: TradeStage
|
||||
expected_code: KernelDiagnosticCode
|
||||
expected_accepted: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DuplicateCase:
|
||||
name: str
|
||||
seed_state: str
|
||||
first_kind: KernelEventKind
|
||||
second_kind: KernelEventKind
|
||||
expected_state: TradeStage
|
||||
event_factory_name: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StaleCase:
|
||||
name: str
|
||||
second_kind: KernelEventKind
|
||||
same_event_id_as_initial: bool
|
||||
expected_accepted: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ZincMirrorCase:
|
||||
name: str
|
||||
op: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SlotRigorCase:
|
||||
name: str
|
||||
op: str
|
||||
|
||||
|
||||
def _build_kernel(slot_count: int = 3) -> tuple[ExecutionKernel, MemoryKernelJournal, InMemoryZincPlane]:
|
||||
journal = MemoryKernelJournal()
|
||||
zinc = InMemoryZincPlane()
|
||||
kernel = ExecutionKernel(
|
||||
max_slots=slot_count,
|
||||
control_plane=InMemoryControlPlane(
|
||||
KernelControlSnapshot(
|
||||
mode=KernelMode.DEBUG,
|
||||
verbosity=KernelVerbosity.TRACE,
|
||||
backend_mode=BackendMode.MOCK,
|
||||
debug_clickhouse_enabled=True,
|
||||
trace_transitions=True,
|
||||
mirror_to_hazelcast=True,
|
||||
)
|
||||
),
|
||||
venue=NoopVenueAdapter(),
|
||||
journal=journal,
|
||||
zinc_plane=zinc,
|
||||
)
|
||||
return kernel, journal, zinc
|
||||
|
||||
|
||||
def _make_entry_order(trade_id: str, slot_id: int, *, size: float = 1.0, status: VenueOrderStatus = VenueOrderStatus.NEW) -> VenueOrder:
|
||||
return VenueOrder(
|
||||
internal_trade_id=trade_id,
|
||||
venue_order_id=f"V-ENTRY-{slot_id}-{trade_id}",
|
||||
venue_client_id=f"{trade_id}:entry:{slot_id}",
|
||||
side=TradeSide.SHORT,
|
||||
intended_size=size,
|
||||
filled_size=size if status == VenueOrderStatus.FILLED else 0.0,
|
||||
average_fill_price=100.0,
|
||||
status=status,
|
||||
metadata={"slot_id": slot_id},
|
||||
)
|
||||
|
||||
|
||||
def _make_exit_order(trade_id: str, slot_id: int, *, size: float, status: VenueOrderStatus = VenueOrderStatus.NEW) -> VenueOrder:
|
||||
return VenueOrder(
|
||||
internal_trade_id=trade_id,
|
||||
venue_order_id=f"V-EXIT-{slot_id}-{trade_id}",
|
||||
venue_client_id=f"{trade_id}:exit:{slot_id}",
|
||||
side=TradeSide.SHORT,
|
||||
intended_size=size,
|
||||
filled_size=size if status == VenueOrderStatus.FILLED else 0.0,
|
||||
average_fill_price=99.0,
|
||||
status=status,
|
||||
metadata={"slot_id": slot_id},
|
||||
)
|
||||
|
||||
|
||||
def _seed_free_slot(slot_id: int) -> TradeSlot:
|
||||
return TradeSlot(slot_id=slot_id)
|
||||
|
||||
|
||||
def _seed_entry_working_slot(trade_id: str, slot_id: int) -> TradeSlot:
|
||||
return TradeSlot(
|
||||
slot_id=slot_id,
|
||||
trade_id=trade_id,
|
||||
asset="BTCUSDT",
|
||||
side=TradeSide.SHORT,
|
||||
entry_price=0.0,
|
||||
size=0.0,
|
||||
initial_size=0.0,
|
||||
leverage=2.0,
|
||||
entry_time=datetime.now(timezone.utc),
|
||||
exit_leg_ratios=(1.0,),
|
||||
active_leg_index=0,
|
||||
active_entry_order=_make_entry_order(trade_id, slot_id, status=VenueOrderStatus.NEW),
|
||||
fsm_state=TradeStage.ENTRY_WORKING,
|
||||
)
|
||||
|
||||
|
||||
def _seed_position_open_slot(trade_id: str, slot_id: int, *, size: float = 1.0, side: TradeSide = TradeSide.SHORT) -> TradeSlot:
|
||||
return TradeSlot(
|
||||
slot_id=slot_id,
|
||||
trade_id=trade_id,
|
||||
asset="BTCUSDT",
|
||||
side=side,
|
||||
entry_price=100.0,
|
||||
size=size,
|
||||
initial_size=size,
|
||||
leverage=2.0,
|
||||
entry_time=datetime.now(timezone.utc),
|
||||
exit_leg_ratios=(0.5, 0.5),
|
||||
active_leg_index=0,
|
||||
active_entry_order=_make_entry_order(trade_id, slot_id, size=size, status=VenueOrderStatus.FILLED),
|
||||
fsm_state=TradeStage.POSITION_OPEN,
|
||||
)
|
||||
|
||||
|
||||
def _seed_exit_working_slot(trade_id: str, slot_id: int, *, size: float = 1.0) -> TradeSlot:
|
||||
slot = _seed_position_open_slot(trade_id, slot_id, size=size)
|
||||
slot.active_exit_order = _make_exit_order(trade_id, slot_id, size=slot.next_exit_ratio() * size, status=VenueOrderStatus.NEW)
|
||||
slot.fsm_state = TradeStage.EXIT_WORKING
|
||||
return slot
|
||||
|
||||
|
||||
def _seed_closed_slot(trade_id: str, slot_id: int) -> TradeSlot:
|
||||
return TradeSlot(
|
||||
slot_id=slot_id,
|
||||
trade_id=trade_id,
|
||||
asset="BTCUSDT",
|
||||
side=TradeSide.SHORT,
|
||||
entry_price=100.0,
|
||||
size=0.0,
|
||||
initial_size=1.0,
|
||||
leverage=2.0,
|
||||
entry_time=datetime.now(timezone.utc),
|
||||
closed=True,
|
||||
fsm_state=TradeStage.CLOSED,
|
||||
)
|
||||
|
||||
|
||||
def _make_intent(
|
||||
*,
|
||||
trade_id: str,
|
||||
slot_id: int,
|
||||
action: KernelCommandType,
|
||||
leverage: float = 2.0,
|
||||
size: float = 1.0,
|
||||
side: TradeSide = TradeSide.SHORT,
|
||||
reason: str = "HARNESS",
|
||||
) -> KernelIntent:
|
||||
return KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id=f"intent-{trade_id}-{action.value}-{slot_id}",
|
||||
trade_id=trade_id,
|
||||
slot_id=slot_id,
|
||||
asset="BTCUSDT",
|
||||
side=side,
|
||||
action=action,
|
||||
reference_price=100.0,
|
||||
target_size=size,
|
||||
leverage=leverage,
|
||||
exit_leg_ratios=(0.5, 0.5) if action == KernelCommandType.EXIT else (1.0,),
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
|
||||
def _make_event(
|
||||
slot: TradeSlot,
|
||||
*,
|
||||
kind: KernelEventKind,
|
||||
event_id: str,
|
||||
filled_size: float = 0.0,
|
||||
reason: str = "",
|
||||
slot_id: int | None = None,
|
||||
) -> VenueEvent:
|
||||
order = slot.active_exit_order or slot.active_entry_order
|
||||
venue_order_id = order.venue_order_id if order else f"V-{kind.value}-{slot.slot_id}"
|
||||
venue_client_id = order.venue_client_id if order else f"{slot.trade_id}:client:{slot.slot_id}"
|
||||
status = {
|
||||
KernelEventKind.ORDER_ACK: VenueEventStatus.ACKED,
|
||||
KernelEventKind.ORDER_REJECT: VenueEventStatus.REJECTED,
|
||||
KernelEventKind.PARTIAL_FILL: VenueEventStatus.PARTIALLY_FILLED,
|
||||
KernelEventKind.FULL_FILL: VenueEventStatus.FILLED,
|
||||
KernelEventKind.CANCEL_ACK: VenueEventStatus.CANCELED,
|
||||
KernelEventKind.CANCEL_REJECT: VenueEventStatus.CANCELED_REJECTED,
|
||||
KernelEventKind.MARK_PRICE: VenueEventStatus.ACKED,
|
||||
KernelEventKind.RECONCILE: VenueEventStatus.ACKED,
|
||||
KernelEventKind.CONTROL: VenueEventStatus.ACKED,
|
||||
}[kind]
|
||||
return VenueEvent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
event_id=event_id,
|
||||
trade_id=slot.trade_id,
|
||||
slot_id=slot.slot_id if slot_id is None else slot_id,
|
||||
kind=kind,
|
||||
status=status,
|
||||
venue_order_id=venue_order_id,
|
||||
venue_client_id=venue_client_id,
|
||||
side=slot.side if slot.side != TradeSide.FLAT else TradeSide.SHORT,
|
||||
asset=slot.asset or "BTCUSDT",
|
||||
price=99.0 if kind == KernelEventKind.MARK_PRICE else 100.0,
|
||||
size=max(slot.size, 1.0),
|
||||
filled_size=filled_size,
|
||||
remaining_size=max(0.0, max(slot.size, 1.0) - filled_size),
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
|
||||
INTENT_GUARD_CASES = [
|
||||
IntentGuardCase("invalid_negative_enter", -1, "free", KernelCommandType.ENTER, "trade-neg", "trade-neg", TradeStage.IDLE, KernelDiagnosticCode.INVALID_SLOT_ID, False),
|
||||
IntentGuardCase("invalid_high_exit", 99, "free", KernelCommandType.EXIT, "trade-high", "trade-high", TradeStage.IDLE, KernelDiagnosticCode.INVALID_SLOT_ID, False),
|
||||
IntentGuardCase("unsupported_control", 0, "free", KernelCommandType.CONTROL, "trade-control", "trade-control", TradeStage.IDLE, KernelDiagnosticCode.UNSUPPORTED_INTENT, False),
|
||||
IntentGuardCase("free_exit", 0, "free", KernelCommandType.EXIT, "trade-free-exit", "trade-free-exit", TradeStage.IDLE, KernelDiagnosticCode.NO_OPEN_POSITION, False),
|
||||
IntentGuardCase("free_cancel", 0, "free", KernelCommandType.CANCEL, "trade-free-cancel", "trade-free-cancel", TradeStage.IDLE, KernelDiagnosticCode.NO_ACTIVE_EXIT_ORDER, False),
|
||||
IntentGuardCase("busy_enter_different_trade", 0, "position_open", KernelCommandType.ENTER, "trade-open", "trade-new", TradeStage.POSITION_OPEN, KernelDiagnosticCode.SLOT_BUSY, False),
|
||||
IntentGuardCase("same_trade_enter_allowed", 0, "position_open", KernelCommandType.ENTER, "trade-open", "trade-open", TradeStage.ORDER_REQUESTED, KernelDiagnosticCode.OK, True),
|
||||
IntentGuardCase("closed_exit", 0, "closed", KernelCommandType.EXIT, "trade-closed", "trade-closed", TradeStage.CLOSED, KernelDiagnosticCode.NO_OPEN_POSITION, False),
|
||||
IntentGuardCase("open_reconcile", 0, "position_open", KernelCommandType.RECONCILE, "trade-reconcile", "trade-reconcile", TradeStage.STALE_STATE_RECONCILING, KernelDiagnosticCode.STALE_STATE_RECONCILE, True),
|
||||
IntentGuardCase("free_mark_price", 0, "free", KernelCommandType.MARK_PRICE, "trade-mark", "trade-mark", TradeStage.IDLE, KernelDiagnosticCode.OK, True),
|
||||
]
|
||||
|
||||
|
||||
DUPLICATE_CASES = [
|
||||
DuplicateCase("entry_ack_duplicate", "entry_working", KernelEventKind.ORDER_ACK, KernelEventKind.ORDER_ACK, TradeStage.ENTRY_WORKING, "ack"),
|
||||
DuplicateCase("entry_partial_duplicate", "entry_working", KernelEventKind.PARTIAL_FILL, KernelEventKind.PARTIAL_FILL, TradeStage.ENTRY_WORKING, "partial-entry"),
|
||||
DuplicateCase("entry_full_duplicate", "entry_working", KernelEventKind.FULL_FILL, KernelEventKind.FULL_FILL, TradeStage.POSITION_OPEN, "full-entry"),
|
||||
DuplicateCase("exit_ack_duplicate", "exit_working", KernelEventKind.CANCEL_ACK, KernelEventKind.CANCEL_ACK, TradeStage.POSITION_OPEN, "ack-exit"),
|
||||
DuplicateCase("exit_partial_duplicate", "exit_working", KernelEventKind.PARTIAL_FILL, KernelEventKind.PARTIAL_FILL, TradeStage.EXIT_WORKING, "partial-exit"),
|
||||
DuplicateCase("exit_full_duplicate", "exit_working", KernelEventKind.FULL_FILL, KernelEventKind.FULL_FILL, TradeStage.CLOSED, "full-exit"),
|
||||
DuplicateCase("cancel_reject_duplicate", "exit_working", KernelEventKind.CANCEL_REJECT, KernelEventKind.CANCEL_REJECT, TradeStage.EXIT_WORKING, "reject-exit"),
|
||||
DuplicateCase("mark_price_duplicate", "position_open", KernelEventKind.MARK_PRICE, KernelEventKind.MARK_PRICE, TradeStage.POSITION_OPEN, "mark"),
|
||||
DuplicateCase("reconcile_duplicate", "position_open", KernelEventKind.RECONCILE, KernelEventKind.RECONCILE, TradeStage.STALE_STATE_RECONCILING, "reconcile"),
|
||||
DuplicateCase("entry_reject_duplicate", "entry_working", KernelEventKind.ORDER_REJECT, KernelEventKind.ORDER_REJECT, TradeStage.IDLE, "reject-entry"),
|
||||
]
|
||||
|
||||
|
||||
STALE_CASES = [
|
||||
StaleCase("stale_ack", KernelEventKind.ORDER_ACK, False, False),
|
||||
StaleCase("stale_reject", KernelEventKind.ORDER_REJECT, False, False),
|
||||
StaleCase("stale_partial", KernelEventKind.PARTIAL_FILL, False, False),
|
||||
StaleCase("stale_full", KernelEventKind.FULL_FILL, False, False),
|
||||
StaleCase("stale_cancel_ack", KernelEventKind.CANCEL_ACK, False, False),
|
||||
StaleCase("stale_cancel_reject", KernelEventKind.CANCEL_REJECT, False, False),
|
||||
StaleCase("stale_mark_price", KernelEventKind.MARK_PRICE, False, False),
|
||||
StaleCase("stale_control", KernelEventKind.CONTROL, False, False),
|
||||
StaleCase("stale_reconcile", KernelEventKind.RECONCILE, False, True),
|
||||
StaleCase("stale_duplicate_precedence", KernelEventKind.ORDER_ACK, True, False),
|
||||
]
|
||||
|
||||
|
||||
ZINC_MIRROR_CASES = [
|
||||
ZincMirrorCase("intent_published_on_enter", "intent"),
|
||||
ZincMirrorCase("invalid_slot_intent_still_publishes", "invalid_intent"),
|
||||
ZincMirrorCase("slot_write_updates_state_region", "direct_write"),
|
||||
ZincMirrorCase("venue_event_updates_state_region", "venue_event"),
|
||||
ZincMirrorCase("control_update_writes_region", "control_update"),
|
||||
ZincMirrorCase("snapshot_reflects_control", "snapshot"),
|
||||
ZincMirrorCase("reconcile_from_slots_writes_all", "reconcile"),
|
||||
ZincMirrorCase("free_slot_selects_first_free", "free_slot"),
|
||||
ZincMirrorCase("read_slots_sorted", "sorted_read"),
|
||||
ZincMirrorCase("slot_overwrite_replaces_previous_state", "overwrite"),
|
||||
]
|
||||
|
||||
|
||||
SLOT_RIGOR_CASES = [
|
||||
SlotRigorCase("idle_slot_is_free", "idle_free"),
|
||||
SlotRigorCase("closed_slot_is_free", "closed_free"),
|
||||
SlotRigorCase("entry_working_is_not_free", "entry_not_free"),
|
||||
SlotRigorCase("open_slot_is_not_free", "open_not_free"),
|
||||
SlotRigorCase("mark_price_zero_is_noop", "mark_zero"),
|
||||
SlotRigorCase("mark_price_negative_is_noop", "mark_negative"),
|
||||
SlotRigorCase("mark_price_nan_is_noop", "mark_nan"),
|
||||
SlotRigorCase("short_price_rise_negative_pnl", "short_rise"),
|
||||
SlotRigorCase("short_price_drop_positive_pnl", "short_drop"),
|
||||
SlotRigorCase("exit_leg_consume_and_clamp", "exit_leg"),
|
||||
]
|
||||
|
||||
|
||||
def _seed_for_intent_case(kernel: ExecutionKernel, case: IntentGuardCase) -> None:
|
||||
if case.seed_state == "free":
|
||||
return
|
||||
if case.seed_state == "entry_working":
|
||||
kernel._set_slot(_seed_entry_working_slot(case.trade_id, case.slot_id))
|
||||
return
|
||||
if case.seed_state == "position_open":
|
||||
kernel._set_slot(_seed_position_open_slot(case.trade_id, case.slot_id))
|
||||
return
|
||||
if case.seed_state == "exit_working":
|
||||
kernel._set_slot(_seed_exit_working_slot(case.trade_id, case.slot_id))
|
||||
return
|
||||
if case.seed_state == "closed":
|
||||
kernel._set_slot(_seed_closed_slot(case.trade_id, case.slot_id))
|
||||
return
|
||||
raise AssertionError(case.seed_state)
|
||||
|
||||
|
||||
def _seed_for_duplicate_case(kernel: ExecutionKernel, case: DuplicateCase) -> TradeSlot:
|
||||
if case.seed_state == "entry_working":
|
||||
slot = _seed_entry_working_slot(f"trade-{case.name}", 0)
|
||||
elif case.seed_state == "exit_working":
|
||||
slot = _seed_exit_working_slot(f"trade-{case.name}", 0)
|
||||
elif case.seed_state == "position_open":
|
||||
slot = _seed_position_open_slot(f"trade-{case.name}", 0)
|
||||
elif case.seed_state == "stale":
|
||||
slot = _seed_position_open_slot(f"trade-{case.name}", 0)
|
||||
else:
|
||||
raise AssertionError(case.seed_state)
|
||||
kernel._set_slot(slot)
|
||||
return kernel._get_slot(0)
|
||||
|
||||
|
||||
def _seed_for_stale_case(kernel: ExecutionKernel) -> TradeSlot:
|
||||
slot = _seed_position_open_slot("trade-stale", 0)
|
||||
kernel._set_slot(slot)
|
||||
return kernel._get_slot(0)
|
||||
|
||||
|
||||
def _seed_for_zinc_case(kernel: ExecutionKernel, case: ZincMirrorCase) -> None:
|
||||
if case.op == "intent":
|
||||
return
|
||||
if case.op == "invalid_intent":
|
||||
return
|
||||
if case.op == "direct_write":
|
||||
kernel._set_slot(_seed_position_open_slot("trade-write", 0))
|
||||
return
|
||||
if case.op == "venue_event":
|
||||
kernel._set_slot(_seed_entry_working_slot("trade-event", 0))
|
||||
return
|
||||
if case.op == "control_update":
|
||||
return
|
||||
if case.op == "snapshot":
|
||||
return
|
||||
if case.op == "reconcile":
|
||||
return
|
||||
if case.op == "free_slot":
|
||||
kernel._set_slot(_seed_position_open_slot("trade-free", 0))
|
||||
kernel._set_slot(_seed_free_slot(1))
|
||||
return
|
||||
if case.op == "sorted_read":
|
||||
return
|
||||
if case.op == "overwrite":
|
||||
return
|
||||
raise AssertionError(case.op)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", INTENT_GUARD_CASES, ids=lambda case: case.name)
|
||||
def test_kernel_intent_guard_matrix(case: IntentGuardCase) -> None:
|
||||
kernel, _, zinc = _build_kernel()
|
||||
_seed_for_intent_case(kernel, case)
|
||||
intent = _make_intent(
|
||||
trade_id=case.intent_trade_id,
|
||||
slot_id=case.slot_id,
|
||||
action=case.action,
|
||||
leverage=-3.0 if case.name == "same_trade_enter_allowed" else 2.5,
|
||||
size=1.0,
|
||||
reason=case.name,
|
||||
)
|
||||
outcome = kernel.process_intent(intent)
|
||||
assert outcome.accepted is case.expected_accepted
|
||||
assert outcome.diagnostic_code == case.expected_code
|
||||
assert outcome.state == case.expected_state
|
||||
if case.slot_id >= 0 and case.slot_id < kernel.max_slots:
|
||||
assert zinc.intent_region
|
||||
assert zinc.intent_region[-1].intent_id == intent.intent_id
|
||||
if case.name == "same_trade_enter_allowed":
|
||||
current = kernel.slot(case.slot_id).to_dict()
|
||||
assert current["fsm_state"] == TradeStage.ORDER_REQUESTED.value
|
||||
assert current["leverage"] == 1.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", DUPLICATE_CASES, ids=lambda case: case.name)
|
||||
def test_kernel_duplicate_event_matrix(case: DuplicateCase) -> None:
|
||||
kernel, _, _ = _build_kernel()
|
||||
slot = _seed_for_duplicate_case(kernel, case)
|
||||
fill_size = slot.size or 1.0
|
||||
if case.seed_state == "exit_working" and case.first_kind == KernelEventKind.PARTIAL_FILL:
|
||||
fill_size = max(0.1, fill_size * 0.4)
|
||||
first_event = _make_event(slot, kind=case.first_kind, event_id=f"dup-{case.name}", filled_size=fill_size)
|
||||
first = kernel.on_venue_event(first_event)
|
||||
second = kernel.on_venue_event(first_event)
|
||||
assert first.diagnostic_code in {
|
||||
KernelDiagnosticCode.OK,
|
||||
KernelDiagnosticCode.STALE_STATE_RECONCILE,
|
||||
KernelDiagnosticCode.ENTRY_ORDER_REJECTED,
|
||||
KernelDiagnosticCode.EXIT_ORDER_REJECTED,
|
||||
KernelDiagnosticCode.ORDER_REJECTED,
|
||||
KernelDiagnosticCode.CANCEL_REJECTED,
|
||||
}
|
||||
assert second.diagnostic_code == KernelDiagnosticCode.DUPLICATE_EVENT
|
||||
assert second.state == case.expected_state
|
||||
assert second.accepted is True
|
||||
assert kernel.slot(0).to_dict()["seen_event_ids"].count(first_event.event_id) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", STALE_CASES, ids=lambda case: case.name)
|
||||
def test_kernel_stale_state_matrix(case: StaleCase) -> None:
|
||||
kernel, _, _ = _build_kernel()
|
||||
slot = _seed_for_stale_case(kernel)
|
||||
initial = _make_event(slot, kind=KernelEventKind.RECONCILE, event_id="stale-entry", filled_size=slot.size or 1.0)
|
||||
initial_outcome = kernel.on_venue_event(initial)
|
||||
assert initial_outcome.diagnostic_code == KernelDiagnosticCode.OK
|
||||
assert kernel.slot(0).fsm_state == TradeStage.STALE_STATE_RECONCILING
|
||||
|
||||
if case.same_event_id_as_initial:
|
||||
event = _make_event(kernel._get_slot(0), kind=case.second_kind, event_id="stale-entry", filled_size=slot.size or 1.0, reason=case.name)
|
||||
else:
|
||||
event = _make_event(kernel._get_slot(0), kind=case.second_kind, event_id=f"stale-{case.name}", filled_size=slot.size or 1.0, reason=case.name)
|
||||
outcome = kernel.on_venue_event(event)
|
||||
if case.same_event_id_as_initial:
|
||||
assert outcome.diagnostic_code == KernelDiagnosticCode.DUPLICATE_EVENT
|
||||
assert outcome.accepted is True
|
||||
else:
|
||||
assert outcome.diagnostic_code == KernelDiagnosticCode.STALE_STATE_RECONCILE
|
||||
assert outcome.accepted is case.expected_accepted
|
||||
assert outcome.state == TradeStage.STALE_STATE_RECONCILING
|
||||
assert kernel.slot(0).fsm_state == TradeStage.STALE_STATE_RECONCILING
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", ZINC_MIRROR_CASES, ids=lambda case: case.name)
|
||||
def test_kernel_zinc_mirror_matrix(case: ZincMirrorCase) -> None:
|
||||
kernel, _, zinc = _build_kernel()
|
||||
_seed_for_zinc_case(kernel, case)
|
||||
if case.op == "intent":
|
||||
intent = _make_intent(trade_id="trade-intent", slot_id=0, action=KernelCommandType.ENTER, size=1.25)
|
||||
outcome = kernel.process_intent(intent)
|
||||
assert outcome.accepted is True
|
||||
assert zinc.intent_region
|
||||
assert zinc.intent_region[-1].intent_id == intent.intent_id
|
||||
assert zinc.read_slots()[0].trade_id == "trade-intent"
|
||||
elif case.op == "invalid_intent":
|
||||
intent = _make_intent(trade_id="trade-invalid", slot_id=-1, action=KernelCommandType.EXIT, size=1.0)
|
||||
outcome = kernel.process_intent(intent)
|
||||
assert outcome.diagnostic_code == KernelDiagnosticCode.INVALID_SLOT_ID
|
||||
assert len(zinc.intent_region) == 1
|
||||
assert zinc.intent_region[-1].intent_id == intent.intent_id
|
||||
elif case.op == "direct_write":
|
||||
slot = _seed_position_open_slot("trade-write", 0, size=1.5)
|
||||
kernel._set_slot(slot)
|
||||
mirrored = zinc.read_slots()[0]
|
||||
assert mirrored.trade_id == "trade-write"
|
||||
assert mirrored.size == 1.5
|
||||
assert mirrored.fsm_state == TradeStage.POSITION_OPEN
|
||||
elif case.op == "venue_event":
|
||||
slot = kernel._get_slot(0)
|
||||
event = _make_event(slot, kind=KernelEventKind.FULL_FILL, event_id="zinc-fill", filled_size=slot.size or 1.0)
|
||||
outcome = kernel.on_venue_event(event)
|
||||
assert outcome.diagnostic_code == KernelDiagnosticCode.OK
|
||||
mirrored = zinc.read_slots()[0]
|
||||
assert mirrored.fsm_state == TradeStage.POSITION_OPEN
|
||||
assert mirrored.seen_event_ids == ("zinc-fill",)
|
||||
elif case.op == "control_update":
|
||||
snapshot = kernel.update_control(
|
||||
ControlUpdate(
|
||||
mode=KernelMode.DEBUG,
|
||||
verbosity=KernelVerbosity.TRACE,
|
||||
trace_transitions=True,
|
||||
mirror_to_hazelcast=False,
|
||||
)
|
||||
)
|
||||
assert snapshot.mode == KernelMode.DEBUG
|
||||
assert zinc.read_control().mode == KernelMode.DEBUG
|
||||
assert zinc.read_control().trace_transitions is True
|
||||
elif case.op == "snapshot":
|
||||
kernel.update_control(ControlUpdate(mode=KernelMode.DEBUG, verbosity=KernelVerbosity.VERBOSE))
|
||||
payload = kernel.snapshot()
|
||||
assert payload["control"]["mode"] == KernelMode.DEBUG.value
|
||||
assert payload["control"]["verbosity"] == KernelVerbosity.VERBOSE.value
|
||||
elif case.op == "reconcile":
|
||||
slots = [
|
||||
_seed_position_open_slot("trade-a", 2),
|
||||
_seed_closed_slot("trade-b", 0),
|
||||
_seed_free_slot(1),
|
||||
]
|
||||
outcome = kernel.reconcile_from_slots(slots)
|
||||
assert outcome.diagnostic_code == KernelDiagnosticCode.RECONCILED
|
||||
mirrored_ids = [slot.slot_id for slot in zinc.read_slots()]
|
||||
assert mirrored_ids == [0, 1, 2]
|
||||
elif case.op == "free_slot":
|
||||
assert kernel.free_slot().slot_id == 1
|
||||
elif case.op == "sorted_read":
|
||||
kernel._set_slot(_seed_position_open_slot("trade-c", 2))
|
||||
kernel._set_slot(_seed_position_open_slot("trade-a", 0))
|
||||
kernel._set_slot(_seed_position_open_slot("trade-b", 1))
|
||||
ids = [slot.slot_id for slot in zinc.read_slots()]
|
||||
assert ids == [0, 1, 2]
|
||||
elif case.op == "overwrite":
|
||||
kernel._set_slot(_seed_position_open_slot("trade-old", 0, size=1.0))
|
||||
kernel._set_slot(_seed_position_open_slot("trade-new", 0, size=2.0))
|
||||
mirrored = zinc.read_slots()[0]
|
||||
assert mirrored.trade_id == "trade-new"
|
||||
assert mirrored.size == 2.0
|
||||
assert mirrored.initial_size == 2.0
|
||||
else: # pragma: no cover - exhaustive
|
||||
raise AssertionError(case.op)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", SLOT_RIGOR_CASES, ids=lambda case: case.name)
|
||||
def test_trade_slot_state_machine_rigor_matrix(case: SlotRigorCase) -> None:
|
||||
if case.op == "idle_free":
|
||||
slot = TradeSlot(slot_id=0)
|
||||
assert slot.is_free() is True
|
||||
assert slot.is_open() is False
|
||||
elif case.op == "closed_free":
|
||||
slot = _seed_closed_slot("trade-closed", 0)
|
||||
assert slot.is_free() is True
|
||||
assert slot.is_open() is False
|
||||
elif case.op == "entry_not_free":
|
||||
slot = _seed_entry_working_slot("trade-entry", 0)
|
||||
assert slot.is_free() is False
|
||||
assert slot.is_open() is True
|
||||
elif case.op == "open_not_free":
|
||||
slot = _seed_position_open_slot("trade-open", 0)
|
||||
assert slot.is_free() is False
|
||||
assert slot.is_open() is True
|
||||
elif case.op == "mark_zero":
|
||||
slot = _seed_position_open_slot("trade-mark", 0, size=1.0)
|
||||
slot.mark_price(0.0)
|
||||
assert slot.unrealized_pnl == 0.0
|
||||
elif case.op == "mark_negative":
|
||||
slot = _seed_position_open_slot("trade-mark", 0, size=1.0)
|
||||
slot.mark_price(-10.0)
|
||||
assert slot.unrealized_pnl == 0.0
|
||||
elif case.op == "mark_nan":
|
||||
slot = _seed_position_open_slot("trade-mark", 0, size=1.0)
|
||||
slot.mark_price(float("nan"))
|
||||
assert slot.unrealized_pnl == 0.0
|
||||
elif case.op == "short_rise":
|
||||
slot = _seed_position_open_slot("trade-short-rise", 0, size=1.0, side=TradeSide.SHORT)
|
||||
slot.mark_price(110.0)
|
||||
assert slot.unrealized_pnl < 0.0
|
||||
elif case.op == "short_drop":
|
||||
slot = _seed_position_open_slot("trade-short-drop", 0, size=1.0, side=TradeSide.SHORT)
|
||||
slot.mark_price(90.0)
|
||||
assert slot.unrealized_pnl > 0.0
|
||||
elif case.op == "exit_leg":
|
||||
slot = _seed_position_open_slot("trade-leg", 0, size=1.0)
|
||||
slot.exit_leg_ratios = (0.25, 0.75)
|
||||
first = slot.consume_exit_leg()
|
||||
second = slot.consume_exit_leg()
|
||||
third = slot.consume_exit_leg()
|
||||
assert first == 0.25
|
||||
assert second == 0.75
|
||||
assert third == 1.0
|
||||
assert slot.active_leg_index == 2
|
||||
assert slot.next_exit_ratio() == 1.0
|
||||
else: # pragma: no cover - exhaustive
|
||||
raise AssertionError(case.op)
|
||||
196
prod/tests/test_dita_v2_hazelcast.py
Normal file
196
prod/tests/test_dita_v2_hazelcast.py
Normal file
@@ -0,0 +1,196 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import unittest
|
||||
|
||||
from prod.clean_arch.dita_v2 import (
|
||||
ControlUpdate,
|
||||
ExecutionKernel,
|
||||
HazelcastProjection,
|
||||
KernelCommandType,
|
||||
KernelControlSnapshot,
|
||||
KernelIntent,
|
||||
KernelMode,
|
||||
KernelVerbosity,
|
||||
MockVenueAdapter,
|
||||
MockVenueScenario,
|
||||
TradeSide,
|
||||
TradeStage,
|
||||
TradeSlot,
|
||||
build_projection,
|
||||
build_position_state_row,
|
||||
)
|
||||
from prod.clean_arch.dita_v2.hazelcast_projection import HazelcastProjector, HazelcastRowWriter
|
||||
|
||||
|
||||
class CaptureSink:
|
||||
def __init__(self) -> None:
|
||||
self.rows: list[tuple[str, dict[str, object]]] = []
|
||||
|
||||
def __call__(self, name: str, row: dict[str, object]) -> None:
|
||||
self.rows.append((name, dict(row)))
|
||||
|
||||
|
||||
class FakeMap:
|
||||
def __init__(self) -> None:
|
||||
self.rows: dict[str, object] = {}
|
||||
|
||||
def put(self, key: str, value: object) -> None:
|
||||
self.rows[key] = value
|
||||
|
||||
|
||||
class FakeTopic:
|
||||
def __init__(self) -> None:
|
||||
self.messages: list[str] = []
|
||||
|
||||
def publish(self, message: str) -> None:
|
||||
self.messages.append(message)
|
||||
|
||||
|
||||
class FakeHazelcastClient:
|
||||
def __init__(self) -> None:
|
||||
self.maps: dict[str, FakeMap] = {}
|
||||
self.topics: dict[str, FakeTopic] = {}
|
||||
|
||||
def get_map(self, name: str) -> FakeMap:
|
||||
return self.maps.setdefault(name, FakeMap())
|
||||
|
||||
def get_topic(self, name: str) -> FakeTopic:
|
||||
return self.topics.setdefault(name, FakeTopic())
|
||||
|
||||
|
||||
class TestDITAv2Hazelcast(unittest.TestCase):
|
||||
def test_build_position_state_row_has_compatibility_fields(self) -> None:
|
||||
slot = TradeSlot(
|
||||
slot_id=0,
|
||||
trade_id="trade-1",
|
||||
asset="BTCUSDT",
|
||||
side=TradeSide.SHORT,
|
||||
entry_price=100.0,
|
||||
size=1.0,
|
||||
initial_size=1.0,
|
||||
leverage=2.0,
|
||||
fsm_state=TradeStage.POSITION_OPEN,
|
||||
)
|
||||
row = build_position_state_row(
|
||||
slot,
|
||||
KernelControlSnapshot(
|
||||
mode=KernelMode.DEBUG,
|
||||
verbosity=KernelVerbosity.TRACE,
|
||||
runtime_namespace="dita_v2",
|
||||
strategy_namespace="dita_v2",
|
||||
event_namespace="dita_v2",
|
||||
actor_name="ExecutionKernel",
|
||||
exec_venue="bingx",
|
||||
data_venue="binance",
|
||||
ledger_authority="exchange",
|
||||
),
|
||||
)
|
||||
for key in (
|
||||
"runtime_namespace",
|
||||
"strategy_namespace",
|
||||
"event_namespace",
|
||||
"actor_name",
|
||||
"exec_venue",
|
||||
"data_venue",
|
||||
"ledger_authority",
|
||||
"trade_id",
|
||||
"asset",
|
||||
"slot_id",
|
||||
"fsm_state",
|
||||
):
|
||||
self.assertIn(key, row)
|
||||
self.assertEqual(row["trade_id"], "trade-1")
|
||||
self.assertEqual(row["fsm_state"], TradeStage.POSITION_OPEN.value)
|
||||
|
||||
def test_projection_sink_writes_blue_pink_compatible_rows(self) -> None:
|
||||
sink = CaptureSink()
|
||||
projection = HazelcastProjection(writer=sink)
|
||||
control = KernelControlSnapshot(mode=KernelMode.DEBUG, verbosity=KernelVerbosity.TRACE)
|
||||
projection.write_control(control)
|
||||
slot = TradeSlot(
|
||||
slot_id=1,
|
||||
trade_id="trade-2",
|
||||
asset="ETHUSDT",
|
||||
side=TradeSide.LONG,
|
||||
entry_price=50.0,
|
||||
size=2.0,
|
||||
initial_size=2.0,
|
||||
leverage=3.0,
|
||||
fsm_state=TradeStage.POSITION_OPEN,
|
||||
)
|
||||
projection.write_slot(slot)
|
||||
self.assertGreaterEqual(len(sink.rows), 2)
|
||||
control_name, control_row = sink.rows[0]
|
||||
slot_name, slot_row = sink.rows[1]
|
||||
self.assertEqual(control_name, "hz:dita_control")
|
||||
self.assertEqual(slot_name, "hz:dita_active_slots")
|
||||
self.assertEqual(control_row["mode"], KernelMode.DEBUG.value)
|
||||
self.assertEqual(slot_row["trade_id"], "trade-2")
|
||||
self.assertEqual(slot_row["runtime_namespace"], "dita_v2")
|
||||
self.assertEqual(slot_row["ledger_authority"], "exchange")
|
||||
|
||||
def test_hazelcast_row_writer_routes_maps_and_topics(self) -> None:
|
||||
client = FakeHazelcastClient()
|
||||
writer = HazelcastRowWriter(client)
|
||||
writer("hz:dita_active_slots", {"trade_id": "trade-3", "slot_id": 0})
|
||||
writer("hz:dita_control", {"mode": "DEBUG"})
|
||||
writer("hz:dita_trade_events", {"event_id": "evt-1", "trade_id": "trade-3"})
|
||||
self.assertIn("trade-3", client.get_map("hz:dita_active_slots").rows)
|
||||
self.assertIn("control", client.get_map("hz:dita_control").rows)
|
||||
self.assertEqual(len(client.get_topic("hz:dita_trade_events").messages), 1)
|
||||
|
||||
def test_build_projection_uses_client_when_requested(self) -> None:
|
||||
client = FakeHazelcastClient()
|
||||
projection = build_projection(client=client, prefer_real_hazelcast=True)
|
||||
projection.write_control(KernelControlSnapshot(mode=KernelMode.DEBUG, verbosity=KernelVerbosity.TRACE))
|
||||
projection.write_slot(
|
||||
TradeSlot(
|
||||
slot_id=0,
|
||||
trade_id="trade-4",
|
||||
asset="BTCUSDT",
|
||||
side=TradeSide.SHORT,
|
||||
entry_price=100.0,
|
||||
size=1.0,
|
||||
initial_size=1.0,
|
||||
leverage=2.0,
|
||||
fsm_state=TradeStage.POSITION_OPEN,
|
||||
)
|
||||
)
|
||||
self.assertIn("control", client.get_map("hz:dita_control").rows)
|
||||
self.assertIn("trade-4", client.get_map("hz:dita_active_slots").rows)
|
||||
|
||||
def test_kernel_emits_projection_rows(self) -> None:
|
||||
sink = CaptureSink()
|
||||
kernel = ExecutionKernel(
|
||||
control_plane=None,
|
||||
venue=MockVenueAdapter(MockVenueScenario(emit_fill_on_submit=True, partial_fill_ratio=1.0)),
|
||||
projection=HazelcastProjection(writer=sink),
|
||||
)
|
||||
kernel.update_control(ControlUpdate(mode=KernelMode.DEBUG, verbosity=KernelVerbosity.TRACE))
|
||||
kernel.process_intent(
|
||||
KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id="intent-1",
|
||||
trade_id="trade-1",
|
||||
slot_id=0,
|
||||
asset="BTCUSDT",
|
||||
side=TradeSide.SHORT,
|
||||
action=KernelCommandType.ENTER,
|
||||
reference_price=100.0,
|
||||
target_size=1.0,
|
||||
leverage=2.0,
|
||||
exit_leg_ratios=(1.0,),
|
||||
reason="TEST",
|
||||
)
|
||||
)
|
||||
names = [name for name, _ in sink.rows]
|
||||
self.assertIn("hz:dita_control", names)
|
||||
self.assertIn("hz:dita_active_slots", names)
|
||||
slot_rows = [row for name, row in sink.rows if name == "hz:dita_active_slots"]
|
||||
self.assertTrue(any(row["trade_id"] == "trade-1" for row in slot_rows))
|
||||
self.assertTrue(any(row["runtime_namespace"] == "dita_v2" for row in slot_rows))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
231
prod/tests/test_dita_v2_kernel.py
Normal file
231
prod/tests/test_dita_v2_kernel.py
Normal file
@@ -0,0 +1,231 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import unittest
|
||||
|
||||
from prod.clean_arch.dita_v2 import (
|
||||
AccountProjection,
|
||||
BackendMode,
|
||||
ControlUpdate,
|
||||
ExecutionKernel,
|
||||
InMemoryControlPlane,
|
||||
InMemoryZincPlane,
|
||||
KernelCommandType,
|
||||
KernelControlSnapshot,
|
||||
KernelEventKind,
|
||||
KernelIntent,
|
||||
KernelMode,
|
||||
KernelVerbosity,
|
||||
MemoryKernelJournal,
|
||||
MockVenueAdapter,
|
||||
MockVenueScenario,
|
||||
TradeSide,
|
||||
TradeSlot,
|
||||
TradeStage,
|
||||
VenueEvent,
|
||||
VenueEventStatus,
|
||||
)
|
||||
|
||||
|
||||
def mk_intent(
|
||||
*,
|
||||
action: KernelCommandType = KernelCommandType.ENTER,
|
||||
slot_id: int = 0,
|
||||
trade_id: str = "trade-1",
|
||||
asset: str = "BTCUSDT",
|
||||
side: TradeSide = TradeSide.SHORT,
|
||||
target_size: float = 1.0,
|
||||
leverage: float = 2.0,
|
||||
reference_price: float = 100.0,
|
||||
exit_leg_ratios=(1.0,),
|
||||
reason: str = "TEST",
|
||||
) -> KernelIntent:
|
||||
return KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id=f"intent-{trade_id}-{action.value}",
|
||||
trade_id=trade_id,
|
||||
slot_id=slot_id,
|
||||
asset=asset,
|
||||
side=side,
|
||||
action=action,
|
||||
reference_price=reference_price,
|
||||
target_size=target_size,
|
||||
leverage=leverage,
|
||||
exit_leg_ratios=tuple(exit_leg_ratios),
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
|
||||
class TestDITAv2ControlPlane(unittest.TestCase):
|
||||
def test_control_plane_updates_and_mirrors(self):
|
||||
plane = InMemoryControlPlane()
|
||||
updated = plane.update(
|
||||
ControlUpdate(
|
||||
mode=KernelMode.DEBUG,
|
||||
verbosity=KernelVerbosity.TRACE,
|
||||
backend_mode=BackendMode.BINGX,
|
||||
trace_transitions=True,
|
||||
)
|
||||
)
|
||||
self.assertEqual(updated.mode, KernelMode.DEBUG)
|
||||
self.assertEqual(updated.verbosity, KernelVerbosity.TRACE)
|
||||
self.assertEqual(updated.backend_mode, BackendMode.BINGX)
|
||||
self.assertTrue(updated.trace_transitions)
|
||||
self.assertEqual(plane.mirror()["mode"], KernelMode.DEBUG.value)
|
||||
|
||||
|
||||
class TestDITAv2Kernel(unittest.TestCase):
|
||||
def test_entry_ack_fill_reaches_position_open(self):
|
||||
journal = MemoryKernelJournal()
|
||||
zinc = InMemoryZincPlane()
|
||||
venue = MockVenueAdapter(MockVenueScenario(emit_fill_on_submit=True, partial_fill_ratio=1.0))
|
||||
kernel = ExecutionKernel(
|
||||
control_plane=InMemoryControlPlane(
|
||||
KernelControlSnapshot(mode=KernelMode.DEBUG, verbosity=KernelVerbosity.TRACE)
|
||||
),
|
||||
venue=venue,
|
||||
journal=journal,
|
||||
zinc_plane=zinc,
|
||||
)
|
||||
|
||||
outcome = kernel.process_intent(mk_intent())
|
||||
|
||||
slot = kernel.slot(0)
|
||||
self.assertTrue(outcome.accepted)
|
||||
self.assertEqual(slot.fsm_state, TradeStage.POSITION_OPEN)
|
||||
self.assertFalse(slot.closed)
|
||||
self.assertEqual(slot.trade_id, "trade-1")
|
||||
self.assertAlmostEqual(slot.size, 1.0, places=6)
|
||||
self.assertEqual(len(journal.rows), 3)
|
||||
self.assertEqual(len(zinc.intent_region), 1)
|
||||
self.assertEqual(zinc.read_control().mode, KernelMode.DEBUG)
|
||||
|
||||
def test_partial_fill_stays_working_then_full_fill_opens_position(self):
|
||||
journal = MemoryKernelJournal()
|
||||
venue = MockVenueAdapter(MockVenueScenario(emit_fill_on_submit=False, partial_fill_ratio=0.5))
|
||||
kernel = ExecutionKernel(
|
||||
control_plane=InMemoryControlPlane(
|
||||
KernelControlSnapshot(mode=KernelMode.DEBUG, verbosity=KernelVerbosity.TRACE)
|
||||
),
|
||||
venue=venue,
|
||||
journal=journal,
|
||||
)
|
||||
|
||||
kernel.process_intent(mk_intent())
|
||||
slot = kernel.slot(0)
|
||||
self.assertEqual(slot.fsm_state, TradeStage.ENTRY_WORKING)
|
||||
self.assertAlmostEqual(slot.size, 0.5, places=6)
|
||||
|
||||
full_fill = VenueEvent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
event_id="evt-full",
|
||||
trade_id="trade-1",
|
||||
slot_id=0,
|
||||
kind=KernelEventKind.FULL_FILL,
|
||||
status=VenueEventStatus.FILLED,
|
||||
venue_order_id=slot.active_entry_order.venue_order_id if slot.active_entry_order else "V-00000001",
|
||||
venue_client_id=slot.active_entry_order.venue_client_id if slot.active_entry_order else "trade-1:intent-trade-1-ENTER",
|
||||
side=TradeSide.SHORT,
|
||||
asset="BTCUSDT",
|
||||
price=100.0,
|
||||
size=1.0,
|
||||
filled_size=1.0,
|
||||
remaining_size=0.0,
|
||||
)
|
||||
kernel.on_venue_event(full_fill)
|
||||
|
||||
self.assertEqual(slot.fsm_state, TradeStage.POSITION_OPEN)
|
||||
self.assertFalse(slot.closed)
|
||||
self.assertAlmostEqual(slot.size, 1.0, places=6)
|
||||
|
||||
def test_two_leg_exit_closes_only_after_final_leg(self):
|
||||
kernel = ExecutionKernel(
|
||||
control_plane=InMemoryControlPlane(
|
||||
KernelControlSnapshot(mode=KernelMode.DEBUG, verbosity=KernelVerbosity.TRACE)
|
||||
),
|
||||
venue=MockVenueAdapter(MockVenueScenario(emit_fill_on_submit=True, partial_fill_ratio=1.0)),
|
||||
journal=MemoryKernelJournal(),
|
||||
)
|
||||
|
||||
kernel.process_intent(mk_intent())
|
||||
slot = kernel.slot(0)
|
||||
slot.exit_leg_ratios = (0.5, 0.5)
|
||||
|
||||
first_exit = kernel.process_intent(
|
||||
mk_intent(action=KernelCommandType.EXIT, target_size=0.5, exit_leg_ratios=(0.5, 0.5), reason="TP1")
|
||||
)
|
||||
self.assertTrue(first_exit.accepted)
|
||||
self.assertEqual(slot.fsm_state, TradeStage.POSITION_OPEN)
|
||||
self.assertFalse(slot.closed)
|
||||
self.assertAlmostEqual(slot.size, 0.5, places=6)
|
||||
|
||||
second_exit = kernel.process_intent(
|
||||
mk_intent(action=KernelCommandType.EXIT, target_size=0.5, exit_leg_ratios=(0.5, 0.5), reason="TP2")
|
||||
)
|
||||
self.assertTrue(second_exit.accepted)
|
||||
self.assertTrue(slot.closed)
|
||||
self.assertEqual(slot.fsm_state, TradeStage.CLOSED)
|
||||
self.assertAlmostEqual(slot.size, 0.0, places=6)
|
||||
|
||||
def test_reconcile_sets_stale_state(self):
|
||||
kernel = ExecutionKernel(
|
||||
control_plane=InMemoryControlPlane(),
|
||||
venue=MockVenueAdapter(),
|
||||
journal=MemoryKernelJournal(),
|
||||
)
|
||||
kernel.process_intent(mk_intent())
|
||||
slot = kernel.slot(0)
|
||||
kernel.process_intent(mk_intent(action=KernelCommandType.RECONCILE))
|
||||
self.assertEqual(slot.fsm_state, TradeStage.STALE_STATE_RECONCILING)
|
||||
|
||||
def test_account_projection_aggregates_slots(self):
|
||||
projection = AccountProjection()
|
||||
slots = [
|
||||
TradeSlot(
|
||||
slot_id=0,
|
||||
trade_id="t1",
|
||||
asset="BTCUSDT",
|
||||
side=TradeSide.SHORT,
|
||||
entry_price=100.0,
|
||||
size=1.0,
|
||||
initial_size=1.0,
|
||||
leverage=2.0,
|
||||
fsm_state=TradeStage.POSITION_OPEN,
|
||||
metadata={"mark_price": 99.0},
|
||||
),
|
||||
TradeSlot(
|
||||
slot_id=1,
|
||||
trade_id="t2",
|
||||
asset="ETHUSDT",
|
||||
side=TradeSide.LONG,
|
||||
entry_price=50.0,
|
||||
size=2.0,
|
||||
initial_size=2.0,
|
||||
leverage=3.0,
|
||||
fsm_state=TradeStage.EXIT_WORKING,
|
||||
metadata={"mark_price": 55.0},
|
||||
),
|
||||
]
|
||||
|
||||
projection.observe_slots(slots)
|
||||
self.assertEqual(projection.snapshot.open_positions, 2)
|
||||
self.assertAlmostEqual(projection.snapshot.open_notional, 209.0, places=6)
|
||||
self.assertGreater(projection.snapshot.leverage, 0.0)
|
||||
|
||||
def test_debug_mode_journal_records_transitions(self):
|
||||
journal = MemoryKernelJournal()
|
||||
kernel = ExecutionKernel(
|
||||
control_plane=InMemoryControlPlane(
|
||||
KernelControlSnapshot(mode=KernelMode.DEBUG, verbosity=KernelVerbosity.TRACE)
|
||||
),
|
||||
venue=MockVenueAdapter(MockVenueScenario(emit_fill_on_submit=True, partial_fill_ratio=1.0)),
|
||||
journal=journal,
|
||||
)
|
||||
|
||||
kernel.process_intent(mk_intent())
|
||||
self.assertGreaterEqual(len(journal.rows), 2)
|
||||
self.assertTrue(all("slot_state" in row for row in journal.rows))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
579
prod/tests/test_dita_v2_kernel_fsm_matrix.py
Normal file
579
prod/tests/test_dita_v2_kernel_fsm_matrix.py
Normal file
@@ -0,0 +1,579 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from prod.clean_arch.dita_v2 import (
|
||||
AccountProjection,
|
||||
BingxVenueAdapter,
|
||||
BackendMode,
|
||||
ControlUpdate,
|
||||
ExecutionKernel,
|
||||
InMemoryControlPlane,
|
||||
InMemoryZincPlane,
|
||||
KernelCommandType,
|
||||
KernelControlSnapshot,
|
||||
KernelDiagnosticCode,
|
||||
KernelEventKind,
|
||||
KernelIntent,
|
||||
KernelMode,
|
||||
KernelOutcome,
|
||||
KernelSeverity,
|
||||
KernelVerbosity,
|
||||
MemoryKernelJournal,
|
||||
MockVenueAdapter,
|
||||
MockVenueScenario,
|
||||
TradeSide,
|
||||
TradeSlot,
|
||||
TradeStage,
|
||||
VenueEvent,
|
||||
VenueEventStatus,
|
||||
VenueOrder,
|
||||
VenueOrderStatus,
|
||||
)
|
||||
|
||||
|
||||
def mk_intent(
|
||||
*,
|
||||
action: KernelCommandType = KernelCommandType.ENTER,
|
||||
slot_id: int = 0,
|
||||
trade_id: str = "trade-1",
|
||||
asset: str = "BTCUSDT",
|
||||
side: TradeSide = TradeSide.SHORT,
|
||||
target_size: float = 1.0,
|
||||
leverage: float = 2.0,
|
||||
reference_price: float = 100.0,
|
||||
exit_leg_ratios=(1.0,),
|
||||
reason: str = "TEST",
|
||||
) -> KernelIntent:
|
||||
return KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id=f"intent-{trade_id}-{action.value}",
|
||||
trade_id=trade_id,
|
||||
slot_id=slot_id,
|
||||
asset=asset,
|
||||
side=side,
|
||||
action=action,
|
||||
reference_price=reference_price,
|
||||
target_size=target_size,
|
||||
leverage=leverage,
|
||||
exit_leg_ratios=tuple(exit_leg_ratios),
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
|
||||
def mk_event(
|
||||
*,
|
||||
kind: KernelEventKind,
|
||||
status: VenueEventStatus,
|
||||
trade_id: str = "trade-1",
|
||||
slot_id: int = 0,
|
||||
venue_order_id: str = "V-00000001",
|
||||
venue_client_id: str = "trade-1:intent-1",
|
||||
side: TradeSide = TradeSide.SHORT,
|
||||
asset: str = "BTCUSDT",
|
||||
price: float = 100.0,
|
||||
size: float = 1.0,
|
||||
filled_size: float = 1.0,
|
||||
remaining_size: float = 0.0,
|
||||
reason: str = "",
|
||||
) -> VenueEvent:
|
||||
return VenueEvent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
event_id=f"evt-{kind.value.lower()}",
|
||||
trade_id=trade_id,
|
||||
slot_id=slot_id,
|
||||
kind=kind,
|
||||
status=status,
|
||||
venue_order_id=venue_order_id,
|
||||
venue_client_id=venue_client_id,
|
||||
side=side,
|
||||
asset=asset,
|
||||
price=price,
|
||||
size=size,
|
||||
filled_size=filled_size,
|
||||
remaining_size=remaining_size,
|
||||
reason=reason,
|
||||
raw_payload={"status": status.value},
|
||||
)
|
||||
|
||||
|
||||
def mk_kernel(
|
||||
*,
|
||||
max_slots: int = 3,
|
||||
venue: Any | None = None,
|
||||
control_mode: KernelMode = KernelMode.DEBUG,
|
||||
verbosity: KernelVerbosity = KernelVerbosity.TRACE,
|
||||
) -> ExecutionKernel:
|
||||
return ExecutionKernel(
|
||||
max_slots=max_slots,
|
||||
control_plane=InMemoryControlPlane(
|
||||
KernelControlSnapshot(mode=control_mode, verbosity=verbosity, backend_mode=BackendMode.MOCK)
|
||||
),
|
||||
venue=venue or MockVenueAdapter(),
|
||||
journal=MemoryKernelJournal(),
|
||||
zinc_plane=InMemoryZincPlane(),
|
||||
account=AccountProjection(),
|
||||
)
|
||||
|
||||
|
||||
def _seed_open_slot(slot: TradeSlot, *, trade_id: str = "trade-1", asset: str = "BTCUSDT") -> None:
|
||||
slot.trade_id = trade_id
|
||||
slot.asset = asset
|
||||
slot.side = TradeSide.SHORT
|
||||
slot.entry_price = 100.0
|
||||
slot.size = 1.0
|
||||
slot.initial_size = 1.0
|
||||
slot.leverage = 2.0
|
||||
slot.fsm_state = TradeStage.POSITION_OPEN
|
||||
slot.active_entry_order = VenueOrder(
|
||||
internal_trade_id=trade_id,
|
||||
venue_order_id="V-00000001",
|
||||
venue_client_id=f"{trade_id}:entry",
|
||||
side=TradeSide.SHORT,
|
||||
intended_size=1.0,
|
||||
status=VenueOrderStatus.FILLED,
|
||||
metadata={"slot_id": slot.slot_id, "asset": asset},
|
||||
)
|
||||
|
||||
|
||||
def _seed_entry_order(slot: TradeSlot, *, trade_id: str = "trade-1", asset: str = "BTCUSDT", status: VenueOrderStatus = VenueOrderStatus.NEW) -> None:
|
||||
slot.active_entry_order = VenueOrder(
|
||||
internal_trade_id=trade_id,
|
||||
venue_order_id="V-00000001",
|
||||
venue_client_id=f"{trade_id}:entry",
|
||||
side=TradeSide.SHORT,
|
||||
intended_size=1.0,
|
||||
status=status,
|
||||
metadata={"slot_id": slot.slot_id, "asset": asset},
|
||||
)
|
||||
|
||||
|
||||
def _seed_exit_order(slot: TradeSlot, *, trade_id: str = "trade-1", asset: str = "BTCUSDT", intended_size: float = 0.5) -> None:
|
||||
slot.active_exit_order = VenueOrder(
|
||||
internal_trade_id=trade_id,
|
||||
venue_order_id="V-00000002",
|
||||
venue_client_id=f"{trade_id}:exit",
|
||||
side=TradeSide.SHORT,
|
||||
intended_size=intended_size,
|
||||
status=VenueOrderStatus.NEW,
|
||||
metadata={"slot_id": slot.slot_id, "asset": asset},
|
||||
)
|
||||
|
||||
|
||||
def _configure_slot_state(slot: TradeSlot, state: TradeStage, *, trade_id: str = "trade-1", asset: str = "BTCUSDT") -> None:
|
||||
slot.trade_id = trade_id if state not in {TradeStage.IDLE, TradeStage.CLOSED} else ""
|
||||
slot.asset = asset if state not in {TradeStage.IDLE, TradeStage.CLOSED} else ""
|
||||
slot.side = TradeSide.SHORT if state not in {TradeStage.IDLE, TradeStage.CLOSED} else TradeSide.FLAT
|
||||
slot.entry_price = 100.0 if state not in {TradeStage.IDLE, TradeStage.CLOSED} else 0.0
|
||||
slot.size = 1.0 if state in {TradeStage.POSITION_OPEN, TradeStage.EXIT_WORKING, TradeStage.EXIT_REQUESTED, TradeStage.EXIT_SENT, TradeStage.ENTRY_WORKING, TradeStage.ORDER_REQUESTED, TradeStage.ORDER_SENT} else 0.0
|
||||
slot.initial_size = slot.size
|
||||
slot.leverage = 2.0 if state not in {TradeStage.IDLE, TradeStage.CLOSED} else 0.0
|
||||
slot.fsm_state = state
|
||||
slot.closed = state == TradeStage.CLOSED
|
||||
slot.active_entry_order = None
|
||||
slot.active_exit_order = None
|
||||
if state in {TradeStage.ORDER_REQUESTED, TradeStage.ORDER_SENT, TradeStage.ENTRY_WORKING, TradeStage.POSITION_OPEN, TradeStage.POSITION_OPENED}:
|
||||
slot.active_entry_order = VenueOrder(
|
||||
internal_trade_id=trade_id,
|
||||
venue_order_id="V-00000001",
|
||||
venue_client_id=f"{trade_id}:entry",
|
||||
side=TradeSide.SHORT,
|
||||
intended_size=1.0,
|
||||
status=VenueOrderStatus.NEW if state in {TradeStage.ORDER_REQUESTED, TradeStage.ORDER_SENT, TradeStage.ENTRY_WORKING} else VenueOrderStatus.FILLED,
|
||||
metadata={"slot_id": slot.slot_id, "asset": asset},
|
||||
)
|
||||
if state in {TradeStage.EXIT_REQUESTED, TradeStage.EXIT_SENT, TradeStage.EXIT_WORKING}:
|
||||
slot.active_exit_order = VenueOrder(
|
||||
internal_trade_id=trade_id,
|
||||
venue_order_id="V-00000002",
|
||||
venue_client_id=f"{trade_id}:exit",
|
||||
side=TradeSide.SHORT,
|
||||
intended_size=0.5,
|
||||
status=VenueOrderStatus.NEW,
|
||||
metadata={"slot_id": slot.slot_id, "asset": asset},
|
||||
)
|
||||
|
||||
|
||||
# 18 invalid-intent slot tests
|
||||
@pytest.mark.parametrize(
|
||||
"slot_id,action,expected",
|
||||
[
|
||||
(-1, KernelCommandType.ENTER, KernelDiagnosticCode.INVALID_SLOT_ID),
|
||||
(-1, KernelCommandType.EXIT, KernelDiagnosticCode.INVALID_SLOT_ID),
|
||||
(-1, KernelCommandType.MARK_PRICE, KernelDiagnosticCode.INVALID_SLOT_ID),
|
||||
(-1, KernelCommandType.RECONCILE, KernelDiagnosticCode.INVALID_SLOT_ID),
|
||||
(-1, KernelCommandType.CANCEL, KernelDiagnosticCode.INVALID_SLOT_ID),
|
||||
(3, KernelCommandType.ENTER, KernelDiagnosticCode.INVALID_SLOT_ID),
|
||||
(3, KernelCommandType.EXIT, KernelDiagnosticCode.INVALID_SLOT_ID),
|
||||
(3, KernelCommandType.MARK_PRICE, KernelDiagnosticCode.INVALID_SLOT_ID),
|
||||
(3, KernelCommandType.RECONCILE, KernelDiagnosticCode.INVALID_SLOT_ID),
|
||||
(3, KernelCommandType.CANCEL, KernelDiagnosticCode.INVALID_SLOT_ID),
|
||||
(99, KernelCommandType.ENTER, KernelDiagnosticCode.INVALID_SLOT_ID),
|
||||
(99, KernelCommandType.EXIT, KernelDiagnosticCode.INVALID_SLOT_ID),
|
||||
(99, KernelCommandType.MARK_PRICE, KernelDiagnosticCode.INVALID_SLOT_ID),
|
||||
(99, KernelCommandType.RECONCILE, KernelDiagnosticCode.INVALID_SLOT_ID),
|
||||
(99, KernelCommandType.CANCEL, KernelDiagnosticCode.INVALID_SLOT_ID),
|
||||
(7, KernelCommandType.ENTER, KernelDiagnosticCode.INVALID_SLOT_ID),
|
||||
(7, KernelCommandType.EXIT, KernelDiagnosticCode.INVALID_SLOT_ID),
|
||||
(7, KernelCommandType.MARK_PRICE, KernelDiagnosticCode.INVALID_SLOT_ID),
|
||||
],
|
||||
)
|
||||
def test_kernel_rejects_invalid_slot_ids_with_codes(slot_id: int, action: KernelCommandType, expected: KernelDiagnosticCode) -> None:
|
||||
kernel = mk_kernel(max_slots=3)
|
||||
outcome = kernel.process_intent(mk_intent(slot_id=slot_id, action=action))
|
||||
assert outcome.accepted is False
|
||||
assert outcome.diagnostic_code == expected
|
||||
assert outcome.details["reason"] == "INVALID_SLOT_ID"
|
||||
|
||||
|
||||
# 20 entry-path tests
|
||||
@pytest.mark.parametrize(
|
||||
"scenario,expected_state,expected_code,expected_size",
|
||||
[
|
||||
(MockVenueScenario(emit_fill_on_submit=True, partial_fill_ratio=1.0), TradeStage.POSITION_OPEN, KernelDiagnosticCode.OK, 1.0),
|
||||
(MockVenueScenario(emit_fill_on_submit=True, partial_fill_ratio=0.5), TradeStage.ENTRY_WORKING, KernelDiagnosticCode.OK, 0.5),
|
||||
(MockVenueScenario(emit_fill_on_submit=False, partial_fill_ratio=0.5), TradeStage.ENTRY_WORKING, KernelDiagnosticCode.OK, 0.5),
|
||||
(MockVenueScenario(reject_entries=True), TradeStage.IDLE, KernelDiagnosticCode.OK, 0.0),
|
||||
(MockVenueScenario(emit_fill_on_submit=True, partial_fill_ratio=0.25), TradeStage.ENTRY_WORKING, KernelDiagnosticCode.OK, 0.25),
|
||||
(MockVenueScenario(emit_fill_on_submit=True, partial_fill_ratio=0.75), TradeStage.ENTRY_WORKING, KernelDiagnosticCode.OK, 0.75),
|
||||
(MockVenueScenario(emit_ack_before_fill=True, emit_fill_on_submit=False, partial_fill_ratio=0.0), TradeStage.ENTRY_WORKING, KernelDiagnosticCode.OK, 0.0),
|
||||
(MockVenueScenario(emit_ack_before_fill=True, emit_fill_on_submit=True, partial_fill_ratio=1.0), TradeStage.POSITION_OPEN, KernelDiagnosticCode.OK, 1.0),
|
||||
(MockVenueScenario(emit_ack_before_fill=False, emit_fill_on_submit=True, partial_fill_ratio=1.0), TradeStage.POSITION_OPEN, KernelDiagnosticCode.OK, 1.0),
|
||||
(MockVenueScenario(emit_ack_before_fill=False, emit_fill_on_submit=True, partial_fill_ratio=0.5), TradeStage.ENTRY_WORKING, KernelDiagnosticCode.OK, 0.5),
|
||||
(MockVenueScenario(emit_ack_before_fill=True, emit_fill_on_submit=True, partial_fill_ratio=0.9), TradeStage.ENTRY_WORKING, KernelDiagnosticCode.OK, 0.9),
|
||||
(MockVenueScenario(emit_ack_before_fill=True, emit_fill_on_submit=True, partial_fill_ratio=0.1), TradeStage.ENTRY_WORKING, KernelDiagnosticCode.OK, 0.1),
|
||||
(MockVenueScenario(emit_ack_before_fill=True, emit_fill_on_submit=True, partial_fill_ratio=1.0, reject_entries=False), TradeStage.POSITION_OPEN, KernelDiagnosticCode.OK, 1.0),
|
||||
(MockVenueScenario(emit_ack_before_fill=True, emit_fill_on_submit=False, partial_fill_ratio=1.0), TradeStage.POSITION_OPEN, KernelDiagnosticCode.OK, 1.0),
|
||||
(MockVenueScenario(emit_ack_before_fill=True, emit_fill_on_submit=False, partial_fill_ratio=0.2), TradeStage.ENTRY_WORKING, KernelDiagnosticCode.OK, 0.2),
|
||||
(MockVenueScenario(emit_ack_before_fill=False, emit_fill_on_submit=False, partial_fill_ratio=0.3), TradeStage.ENTRY_WORKING, KernelDiagnosticCode.OK, 0.3),
|
||||
(MockVenueScenario(emit_ack_before_fill=False, emit_fill_on_submit=False, partial_fill_ratio=1.0), TradeStage.POSITION_OPEN, KernelDiagnosticCode.OK, 1.0),
|
||||
(MockVenueScenario(emit_ack_before_fill=False, emit_fill_on_submit=False, partial_fill_ratio=0.0), TradeStage.ENTRY_WORKING, KernelDiagnosticCode.OK, 0.0),
|
||||
(MockVenueScenario(emit_ack_before_fill=True, emit_fill_on_submit=True, partial_fill_ratio=0.6), TradeStage.ENTRY_WORKING, KernelDiagnosticCode.OK, 0.6),
|
||||
(MockVenueScenario(emit_ack_before_fill=True, emit_fill_on_submit=True, partial_fill_ratio=0.4), TradeStage.ENTRY_WORKING, KernelDiagnosticCode.OK, 0.4),
|
||||
],
|
||||
)
|
||||
def test_kernel_entry_path_matrix(
|
||||
scenario: MockVenueScenario,
|
||||
expected_state: TradeStage,
|
||||
expected_code: KernelDiagnosticCode,
|
||||
expected_size: float,
|
||||
) -> None:
|
||||
kernel = mk_kernel(venue=MockVenueAdapter(scenario))
|
||||
outcome = kernel.process_intent(mk_intent())
|
||||
assert outcome.accepted is True
|
||||
assert outcome.diagnostic_code == expected_code
|
||||
assert kernel.slot(0).fsm_state == expected_state
|
||||
assert kernel.slot(0).size == pytest.approx(expected_size, abs=1e-6)
|
||||
|
||||
|
||||
# 20 exit-path tests
|
||||
@pytest.mark.parametrize(
|
||||
"initial_state,event_kind,event_status,expected_state,expected_code",
|
||||
[
|
||||
(TradeStage.POSITION_OPEN, KernelEventKind.PARTIAL_FILL, VenueEventStatus.PARTIALLY_FILLED, TradeStage.EXIT_WORKING, KernelDiagnosticCode.OK),
|
||||
(TradeStage.POSITION_OPEN, KernelEventKind.FULL_FILL, VenueEventStatus.FILLED, TradeStage.CLOSED, KernelDiagnosticCode.OK),
|
||||
(TradeStage.EXIT_REQUESTED, KernelEventKind.PARTIAL_FILL, VenueEventStatus.PARTIALLY_FILLED, TradeStage.EXIT_WORKING, KernelDiagnosticCode.OK),
|
||||
(TradeStage.EXIT_REQUESTED, KernelEventKind.FULL_FILL, VenueEventStatus.FILLED, TradeStage.CLOSED, KernelDiagnosticCode.OK),
|
||||
(TradeStage.EXIT_SENT, KernelEventKind.PARTIAL_FILL, VenueEventStatus.PARTIALLY_FILLED, TradeStage.EXIT_WORKING, KernelDiagnosticCode.OK),
|
||||
(TradeStage.EXIT_SENT, KernelEventKind.FULL_FILL, VenueEventStatus.FILLED, TradeStage.CLOSED, KernelDiagnosticCode.OK),
|
||||
(TradeStage.EXIT_WORKING, KernelEventKind.PARTIAL_FILL, VenueEventStatus.PARTIALLY_FILLED, TradeStage.EXIT_WORKING, KernelDiagnosticCode.OK),
|
||||
(TradeStage.EXIT_WORKING, KernelEventKind.FULL_FILL, VenueEventStatus.FILLED, TradeStage.CLOSED, KernelDiagnosticCode.OK),
|
||||
(TradeStage.EXIT_WORKING, KernelEventKind.CANCEL_ACK, VenueEventStatus.CANCELED, TradeStage.POSITION_OPEN, KernelDiagnosticCode.OK),
|
||||
(TradeStage.EXIT_WORKING, KernelEventKind.CANCEL_REJECT, VenueEventStatus.CANCELED_REJECTED, TradeStage.EXIT_WORKING, KernelDiagnosticCode.CANCEL_REJECTED),
|
||||
(TradeStage.POSITION_OPEN, KernelEventKind.CANCEL_ACK, VenueEventStatus.CANCELED, TradeStage.POSITION_OPEN, KernelDiagnosticCode.OK),
|
||||
(TradeStage.POSITION_OPEN, KernelEventKind.CANCEL_REJECT, VenueEventStatus.CANCELED_REJECTED, TradeStage.POSITION_OPEN, KernelDiagnosticCode.CANCEL_REJECTED),
|
||||
(TradeStage.EXIT_REQUESTED, KernelEventKind.CANCEL_ACK, VenueEventStatus.CANCELED, TradeStage.POSITION_OPEN, KernelDiagnosticCode.OK),
|
||||
(TradeStage.EXIT_SENT, KernelEventKind.CANCEL_ACK, VenueEventStatus.CANCELED, TradeStage.POSITION_OPEN, KernelDiagnosticCode.OK),
|
||||
(TradeStage.EXIT_REQUESTED, KernelEventKind.CANCEL_REJECT, VenueEventStatus.CANCELED_REJECTED, TradeStage.EXIT_REQUESTED, KernelDiagnosticCode.CANCEL_REJECTED),
|
||||
(TradeStage.EXIT_SENT, KernelEventKind.CANCEL_REJECT, VenueEventStatus.CANCELED_REJECTED, TradeStage.EXIT_SENT, KernelDiagnosticCode.CANCEL_REJECTED),
|
||||
(TradeStage.POSITION_OPEN, KernelEventKind.ORDER_REJECT, VenueEventStatus.REJECTED, TradeStage.POSITION_OPEN, KernelDiagnosticCode.EXIT_ORDER_REJECTED),
|
||||
(TradeStage.EXIT_WORKING, KernelEventKind.ORDER_REJECT, VenueEventStatus.REJECTED, TradeStage.POSITION_OPEN, KernelDiagnosticCode.EXIT_ORDER_REJECTED),
|
||||
(TradeStage.EXIT_REQUESTED, KernelEventKind.ORDER_REJECT, VenueEventStatus.REJECTED, TradeStage.POSITION_OPEN, KernelDiagnosticCode.EXIT_ORDER_REJECTED),
|
||||
(TradeStage.EXIT_SENT, KernelEventKind.ORDER_REJECT, VenueEventStatus.REJECTED, TradeStage.POSITION_OPEN, KernelDiagnosticCode.EXIT_ORDER_REJECTED),
|
||||
],
|
||||
)
|
||||
def test_kernel_exit_path_matrix(
|
||||
initial_state: TradeStage,
|
||||
event_kind: KernelEventKind,
|
||||
event_status: VenueEventStatus,
|
||||
expected_state: TradeStage,
|
||||
expected_code: KernelDiagnosticCode,
|
||||
) -> None:
|
||||
kernel = mk_kernel()
|
||||
slot = kernel.slot(0)
|
||||
_configure_slot_state(slot, initial_state)
|
||||
if event_kind in {KernelEventKind.ORDER_REJECT, KernelEventKind.PARTIAL_FILL, KernelEventKind.FULL_FILL}:
|
||||
_seed_exit_order(slot, trade_id=slot.trade_id or "trade-1", asset="BTCUSDT", intended_size=slot.size or 0.5)
|
||||
if initial_state in {TradeStage.EXIT_REQUESTED, TradeStage.EXIT_SENT, TradeStage.EXIT_WORKING} and event_kind in {
|
||||
KernelEventKind.CANCEL_ACK,
|
||||
KernelEventKind.CANCEL_REJECT,
|
||||
KernelEventKind.ORDER_ACK,
|
||||
}:
|
||||
_seed_exit_order(slot, trade_id=slot.trade_id or "trade-1", asset="BTCUSDT", intended_size=slot.size or 0.5)
|
||||
outcome = kernel.on_venue_event(
|
||||
mk_event(
|
||||
kind=event_kind,
|
||||
status=event_status,
|
||||
trade_id=slot.trade_id or "trade-1",
|
||||
venue_order_id=slot.active_exit_order.venue_order_id if slot.active_exit_order else "V-00000002",
|
||||
venue_client_id=slot.active_exit_order.venue_client_id if slot.active_exit_order else "trade-1:exit",
|
||||
side=TradeSide.SHORT,
|
||||
asset="BTCUSDT",
|
||||
size=float(slot.size or 0.5),
|
||||
filled_size=float(slot.size or 0.5) if event_kind == KernelEventKind.FULL_FILL else float((slot.size or 0.5) / 2.0),
|
||||
remaining_size=0.0,
|
||||
)
|
||||
)
|
||||
assert outcome.diagnostic_code == expected_code
|
||||
assert kernel.slot(0).fsm_state == expected_state
|
||||
|
||||
|
||||
# 18 event-resolution tests
|
||||
@pytest.mark.parametrize(
|
||||
"event,initial_state,expected_state,expected_code",
|
||||
[
|
||||
(mk_event(kind=KernelEventKind.ORDER_ACK, status=VenueEventStatus.ACKED), TradeStage.IDLE, TradeStage.ENTRY_WORKING, KernelDiagnosticCode.OK),
|
||||
(mk_event(kind=KernelEventKind.ORDER_ACK, status=VenueEventStatus.ACKED), TradeStage.EXIT_REQUESTED, TradeStage.EXIT_WORKING, KernelDiagnosticCode.OK),
|
||||
(mk_event(kind=KernelEventKind.ORDER_REJECT, status=VenueEventStatus.REJECTED), TradeStage.ENTRY_WORKING, TradeStage.IDLE, KernelDiagnosticCode.ENTRY_ORDER_REJECTED),
|
||||
(mk_event(kind=KernelEventKind.ORDER_REJECT, status=VenueEventStatus.REJECTED), TradeStage.EXIT_WORKING, TradeStage.POSITION_OPEN, KernelDiagnosticCode.EXIT_ORDER_REJECTED),
|
||||
(mk_event(kind=KernelEventKind.ORDER_REJECT, status=VenueEventStatus.REJECTED), TradeStage.IDLE, TradeStage.IDLE, KernelDiagnosticCode.ORDER_REJECTED),
|
||||
(mk_event(kind=KernelEventKind.PARTIAL_FILL, status=VenueEventStatus.PARTIALLY_FILLED), TradeStage.ENTRY_WORKING, TradeStage.ENTRY_WORKING, KernelDiagnosticCode.OK),
|
||||
(mk_event(kind=KernelEventKind.FULL_FILL, status=VenueEventStatus.FILLED), TradeStage.ENTRY_WORKING, TradeStage.POSITION_OPEN, KernelDiagnosticCode.OK),
|
||||
(mk_event(kind=KernelEventKind.PARTIAL_FILL, status=VenueEventStatus.PARTIALLY_FILLED), TradeStage.EXIT_WORKING, TradeStage.EXIT_WORKING, KernelDiagnosticCode.OK),
|
||||
(mk_event(kind=KernelEventKind.FULL_FILL, status=VenueEventStatus.FILLED), TradeStage.EXIT_WORKING, TradeStage.CLOSED, KernelDiagnosticCode.OK),
|
||||
(mk_event(kind=KernelEventKind.CANCEL_ACK, status=VenueEventStatus.CANCELED), TradeStage.EXIT_WORKING, TradeStage.POSITION_OPEN, KernelDiagnosticCode.OK),
|
||||
(mk_event(kind=KernelEventKind.CANCEL_REJECT, status=VenueEventStatus.CANCELED_REJECTED), TradeStage.EXIT_WORKING, TradeStage.EXIT_WORKING, KernelDiagnosticCode.CANCEL_REJECTED),
|
||||
(mk_event(kind=KernelEventKind.MARK_PRICE, status=VenueEventStatus.ACKED), TradeStage.POSITION_OPEN, TradeStage.POSITION_OPEN, KernelDiagnosticCode.OK),
|
||||
(mk_event(kind=KernelEventKind.RECONCILE, status=VenueEventStatus.ACKED), TradeStage.POSITION_OPEN, TradeStage.STALE_STATE_RECONCILING, KernelDiagnosticCode.OK),
|
||||
(mk_event(kind=KernelEventKind.ORDER_ACK, status=VenueEventStatus.ACKED, venue_order_id="V-2"), TradeStage.POSITION_OPEN, TradeStage.POSITION_OPEN, KernelDiagnosticCode.OK),
|
||||
(mk_event(kind=KernelEventKind.ORDER_ACK, status=VenueEventStatus.ACKED, venue_order_id="V-3"), TradeStage.ENTRY_WORKING, TradeStage.ENTRY_WORKING, KernelDiagnosticCode.OK),
|
||||
(mk_event(kind=KernelEventKind.FULL_FILL, status=VenueEventStatus.FILLED, venue_order_id="V-4"), TradeStage.EXIT_WORKING, TradeStage.CLOSED, KernelDiagnosticCode.OK),
|
||||
(mk_event(kind=KernelEventKind.CANCEL_ACK, status=VenueEventStatus.CANCELED, venue_order_id="V-5"), TradeStage.POSITION_OPEN, TradeStage.POSITION_OPEN, KernelDiagnosticCode.OK),
|
||||
(mk_event(kind=KernelEventKind.CANCEL_REJECT, status=VenueEventStatus.CANCELED_REJECTED, venue_order_id="V-6"), TradeStage.POSITION_OPEN, TradeStage.POSITION_OPEN, KernelDiagnosticCode.CANCEL_REJECTED),
|
||||
],
|
||||
)
|
||||
def test_kernel_event_matrix(event: VenueEvent, initial_state: TradeStage, expected_state: TradeStage, expected_code: KernelDiagnosticCode) -> None:
|
||||
kernel = mk_kernel()
|
||||
slot = kernel.slot(0)
|
||||
_configure_slot_state(slot, initial_state)
|
||||
entry_states = {TradeStage.IDLE, TradeStage.ORDER_REQUESTED, TradeStage.ORDER_SENT, TradeStage.ENTRY_WORKING}
|
||||
exit_states = {TradeStage.POSITION_OPEN, TradeStage.EXIT_REQUESTED, TradeStage.EXIT_SENT, TradeStage.EXIT_WORKING}
|
||||
|
||||
if initial_state in entry_states and event.kind in {KernelEventKind.ORDER_ACK, KernelEventKind.PARTIAL_FILL, KernelEventKind.FULL_FILL}:
|
||||
_seed_entry_order(slot, trade_id="trade-1", asset="BTCUSDT")
|
||||
elif initial_state == TradeStage.ENTRY_WORKING and event.kind == KernelEventKind.ORDER_REJECT:
|
||||
_seed_entry_order(slot, trade_id="trade-1", asset="BTCUSDT")
|
||||
|
||||
if initial_state in exit_states:
|
||||
if event.kind == KernelEventKind.ORDER_REJECT:
|
||||
_seed_exit_order(slot, trade_id="trade-1", asset="BTCUSDT", intended_size=1.0)
|
||||
elif event.kind in {KernelEventKind.PARTIAL_FILL, KernelEventKind.FULL_FILL}:
|
||||
_seed_exit_order(slot, trade_id="trade-1", asset="BTCUSDT", intended_size=1.0)
|
||||
elif initial_state in {TradeStage.EXIT_REQUESTED, TradeStage.EXIT_SENT, TradeStage.EXIT_WORKING} and event.kind in {
|
||||
KernelEventKind.ORDER_ACK,
|
||||
KernelEventKind.CANCEL_ACK,
|
||||
KernelEventKind.CANCEL_REJECT,
|
||||
}:
|
||||
_seed_exit_order(slot, trade_id="trade-1", asset="BTCUSDT", intended_size=1.0)
|
||||
if initial_state == TradeStage.POSITION_OPEN and event.kind == KernelEventKind.ORDER_ACK:
|
||||
slot.active_entry_order = None
|
||||
|
||||
fill_size = 1.0 if event.kind == KernelEventKind.FULL_FILL else 0.5 if event.kind == KernelEventKind.PARTIAL_FILL else 0.0
|
||||
resolved_event = mk_event(
|
||||
kind=event.kind,
|
||||
status=event.status,
|
||||
trade_id=event.trade_id,
|
||||
slot_id=event.slot_id,
|
||||
venue_order_id=slot.active_entry_order.venue_order_id if slot.active_entry_order else slot.active_exit_order.venue_order_id if slot.active_exit_order else event.venue_order_id,
|
||||
venue_client_id=slot.active_entry_order.venue_client_id if slot.active_entry_order else slot.active_exit_order.venue_client_id if slot.active_exit_order else event.venue_client_id,
|
||||
side=event.side,
|
||||
asset=event.asset,
|
||||
price=event.price,
|
||||
size=1.0,
|
||||
filled_size=fill_size,
|
||||
remaining_size=max(0.0, 1.0 - fill_size),
|
||||
reason=event.reason,
|
||||
)
|
||||
outcome = kernel.on_venue_event(resolved_event)
|
||||
assert outcome.state == expected_state
|
||||
assert outcome.diagnostic_code == expected_code
|
||||
|
||||
|
||||
def test_kernel_rate_limited_event_is_characterized_without_state_drift() -> None:
|
||||
kernel = mk_kernel()
|
||||
slot = kernel.slot(0)
|
||||
_configure_slot_state(slot, TradeStage.ENTRY_WORKING)
|
||||
_seed_entry_order(slot, trade_id="trade-rate-limit", asset="BTCUSDT")
|
||||
before = slot.to_dict()
|
||||
|
||||
outcome = kernel.on_venue_event(
|
||||
mk_event(
|
||||
kind=KernelEventKind.RATE_LIMITED,
|
||||
status=VenueEventStatus.RATE_LIMITED,
|
||||
trade_id="trade-rate-limit",
|
||||
venue_order_id="V-RATE-LIMITED",
|
||||
venue_client_id="trade-rate-limit:entry",
|
||||
reason="code:100410 endpoint is in disabled/frequency-limited period",
|
||||
size=1.0,
|
||||
filled_size=0.0,
|
||||
remaining_size=1.0,
|
||||
)
|
||||
)
|
||||
|
||||
after = kernel.slot(0).to_dict()
|
||||
assert outcome.accepted is False
|
||||
assert outcome.diagnostic_code == KernelDiagnosticCode.RATE_LIMITED
|
||||
assert outcome.severity == KernelSeverity.WARNING
|
||||
assert outcome.details["venue_event_kind"] == KernelEventKind.RATE_LIMITED.value
|
||||
assert outcome.details["severity"] == KernelSeverity.WARNING.value
|
||||
assert outcome.details["release_eta"] == "few minutes"
|
||||
assert outcome.details["retryable"] is True
|
||||
assert after["fsm_state"] == before["fsm_state"]
|
||||
assert after["trade_id"] == before["trade_id"]
|
||||
assert after["size"] == before["size"]
|
||||
|
||||
|
||||
# 24 fuzz cases
|
||||
@pytest.mark.parametrize("seed", list(range(24)))
|
||||
def test_kernel_fuzz_event_sequences(seed: int) -> None:
|
||||
rng = random.Random(seed)
|
||||
kernel = mk_kernel(max_slots=4)
|
||||
current_trade_id = f"trade-{seed}"
|
||||
|
||||
# Seed one slot open for exit/reconcile fuzzing.
|
||||
seed_slot = kernel.slot(0)
|
||||
_seed_open_slot(seed_slot, trade_id=current_trade_id)
|
||||
seed_slot.exit_leg_ratios = (0.25, 0.25, 0.5)
|
||||
|
||||
kinds = [
|
||||
KernelEventKind.ORDER_ACK,
|
||||
KernelEventKind.ORDER_REJECT,
|
||||
KernelEventKind.PARTIAL_FILL,
|
||||
KernelEventKind.FULL_FILL,
|
||||
KernelEventKind.CANCEL_ACK,
|
||||
KernelEventKind.CANCEL_REJECT,
|
||||
KernelEventKind.MARK_PRICE,
|
||||
KernelEventKind.RECONCILE,
|
||||
]
|
||||
|
||||
for idx in range(12):
|
||||
kind = rng.choice(kinds)
|
||||
if kind in {KernelEventKind.ORDER_ACK, KernelEventKind.ORDER_REJECT}:
|
||||
seed_slot.active_entry_order = VenueOrder(
|
||||
internal_trade_id=current_trade_id,
|
||||
venue_order_id=f"V-{seed:04d}-{idx:02d}",
|
||||
venue_client_id=f"{current_trade_id}:entry-{idx}",
|
||||
side=TradeSide.SHORT,
|
||||
intended_size=1.0,
|
||||
status=VenueOrderStatus.NEW,
|
||||
metadata={"slot_id": 0, "asset": "BTCUSDT"},
|
||||
)
|
||||
if kind in {KernelEventKind.CANCEL_ACK, KernelEventKind.CANCEL_REJECT, KernelEventKind.PARTIAL_FILL, KernelEventKind.FULL_FILL}:
|
||||
seed_slot.active_exit_order = VenueOrder(
|
||||
internal_trade_id=current_trade_id,
|
||||
venue_order_id=f"V-{seed:04d}-{idx:02d}",
|
||||
venue_client_id=f"{current_trade_id}:exit-{idx}",
|
||||
side=TradeSide.SHORT,
|
||||
intended_size=0.5,
|
||||
filled_size=0.0,
|
||||
status=VenueOrderStatus.NEW,
|
||||
metadata={"slot_id": 0, "asset": "BTCUSDT"},
|
||||
)
|
||||
event = mk_event(kind=kind, status=_status_for_kind(kind), trade_id=current_trade_id, venue_order_id=f"V-{seed:04d}-{idx:02d}", venue_client_id=f"{current_trade_id}:{idx}")
|
||||
outcome = kernel.on_venue_event(event)
|
||||
assert isinstance(outcome, KernelOutcome)
|
||||
assert outcome.diagnostic_code in set(KernelDiagnosticCode)
|
||||
assert kernel.slot(0).fsm_state in set(TradeStage)
|
||||
|
||||
|
||||
def _status_for_kind(kind: KernelEventKind) -> VenueEventStatus:
|
||||
return {
|
||||
KernelEventKind.ORDER_ACK: VenueEventStatus.ACKED,
|
||||
KernelEventKind.ORDER_REJECT: VenueEventStatus.REJECTED,
|
||||
KernelEventKind.PARTIAL_FILL: VenueEventStatus.PARTIALLY_FILLED,
|
||||
KernelEventKind.FULL_FILL: VenueEventStatus.FILLED,
|
||||
KernelEventKind.CANCEL_ACK: VenueEventStatus.CANCELED,
|
||||
KernelEventKind.CANCEL_REJECT: VenueEventStatus.CANCELED_REJECTED,
|
||||
KernelEventKind.MARK_PRICE: VenueEventStatus.ACKED,
|
||||
KernelEventKind.RECONCILE: VenueEventStatus.ACKED,
|
||||
}[kind]
|
||||
|
||||
|
||||
# 22 explicit edge-condition tests
|
||||
@pytest.mark.parametrize(
|
||||
"slot_state,action,expected_code",
|
||||
[
|
||||
(TradeStage.IDLE, KernelCommandType.EXIT, KernelDiagnosticCode.NO_OPEN_POSITION),
|
||||
(TradeStage.CLOSED, KernelCommandType.EXIT, KernelDiagnosticCode.NO_OPEN_POSITION),
|
||||
(TradeStage.POSITION_OPEN, KernelCommandType.CANCEL, KernelDiagnosticCode.NO_ACTIVE_EXIT_ORDER),
|
||||
(TradeStage.IDLE, KernelCommandType.CANCEL, KernelDiagnosticCode.NO_ACTIVE_EXIT_ORDER),
|
||||
(TradeStage.IDLE, KernelCommandType.RECONCILE, KernelDiagnosticCode.STALE_STATE_RECONCILE),
|
||||
(TradeStage.POSITION_OPEN, KernelCommandType.RECONCILE, KernelDiagnosticCode.STALE_STATE_RECONCILE),
|
||||
(TradeStage.POSITION_OPEN, KernelCommandType.MARK_PRICE, KernelDiagnosticCode.OK),
|
||||
(TradeStage.EXIT_WORKING, KernelCommandType.MARK_PRICE, KernelDiagnosticCode.OK),
|
||||
(TradeStage.ENTRY_WORKING, KernelCommandType.MARK_PRICE, KernelDiagnosticCode.OK),
|
||||
(TradeStage.ORDER_REQUESTED, KernelCommandType.MARK_PRICE, KernelDiagnosticCode.OK),
|
||||
(TradeStage.ORDER_SENT, KernelCommandType.MARK_PRICE, KernelDiagnosticCode.OK),
|
||||
(TradeStage.EXIT_REQUESTED, KernelCommandType.MARK_PRICE, KernelDiagnosticCode.OK),
|
||||
(TradeStage.EXIT_SENT, KernelCommandType.MARK_PRICE, KernelDiagnosticCode.OK),
|
||||
(TradeStage.STALE_STATE_RECONCILING, KernelCommandType.MARK_PRICE, KernelDiagnosticCode.OK),
|
||||
(TradeStage.POSITION_OPEN, KernelCommandType.ENTER, KernelDiagnosticCode.SLOT_BUSY),
|
||||
(TradeStage.EXIT_WORKING, KernelCommandType.ENTER, KernelDiagnosticCode.SLOT_BUSY),
|
||||
(TradeStage.ORDER_REQUESTED, KernelCommandType.ENTER, KernelDiagnosticCode.SLOT_BUSY),
|
||||
(TradeStage.ORDER_SENT, KernelCommandType.ENTER, KernelDiagnosticCode.SLOT_BUSY),
|
||||
(TradeStage.POSITION_OPEN, KernelCommandType.EXIT, KernelDiagnosticCode.OK),
|
||||
(TradeStage.EXIT_WORKING, KernelCommandType.EXIT, KernelDiagnosticCode.OK),
|
||||
(TradeStage.POSITION_OPEN, KernelCommandType.CANCEL, KernelDiagnosticCode.OK),
|
||||
(TradeStage.EXIT_WORKING, KernelCommandType.CANCEL, KernelDiagnosticCode.OK),
|
||||
],
|
||||
)
|
||||
def test_kernel_action_edge_conditions(slot_state: TradeStage, action: KernelCommandType, expected_code: KernelDiagnosticCode) -> None:
|
||||
kernel = mk_kernel(venue=MockVenueAdapter(MockVenueScenario(emit_fill_on_submit=True, partial_fill_ratio=1.0)))
|
||||
slot = kernel.slot(0)
|
||||
_configure_slot_state(slot, slot_state)
|
||||
if action == KernelCommandType.ENTER and expected_code == KernelDiagnosticCode.SLOT_BUSY:
|
||||
slot.trade_id = f"occupied-{slot_state.value.lower()}"
|
||||
if action == KernelCommandType.CANCEL and expected_code == KernelDiagnosticCode.OK:
|
||||
_seed_exit_order(slot, trade_id=slot.trade_id or "trade-1", asset=slot.asset or "BTCUSDT", intended_size=0.5)
|
||||
outcome = kernel.process_intent(mk_intent(action=action, target_size=0.5, exit_leg_ratios=(0.25, 0.25, 0.5)))
|
||||
assert outcome.diagnostic_code == expected_code
|
||||
|
||||
|
||||
# 20 transition-detail tests
|
||||
@pytest.mark.parametrize("mode", [KernelMode.NORMAL, KernelMode.DEBUG])
|
||||
@pytest.mark.parametrize("verbosity", [KernelVerbosity.QUIET, KernelVerbosity.TRACE])
|
||||
@pytest.mark.parametrize("control_enabled", [True, False])
|
||||
@pytest.mark.parametrize("closed", [True, False])
|
||||
@pytest.mark.parametrize("state", [TradeStage.IDLE, TradeStage.POSITION_OPEN])
|
||||
def test_transition_details_and_control_modes_are_captured(
|
||||
mode: KernelMode,
|
||||
verbosity: KernelVerbosity,
|
||||
control_enabled: bool,
|
||||
closed: bool,
|
||||
state: TradeStage,
|
||||
) -> None:
|
||||
kernel = mk_kernel()
|
||||
if control_enabled:
|
||||
kernel.update_control(
|
||||
ControlUpdate(
|
||||
mode=mode,
|
||||
verbosity=verbosity,
|
||||
trace_transitions=True,
|
||||
)
|
||||
)
|
||||
slot = kernel.slot(0)
|
||||
_seed_open_slot(slot)
|
||||
slot.fsm_state = state
|
||||
slot.closed = closed
|
||||
event = mk_event(kind=KernelEventKind.MARK_PRICE, status=VenueEventStatus.ACKED)
|
||||
outcome = kernel.on_venue_event(event)
|
||||
assert outcome.transitions
|
||||
transition = outcome.transitions[0]
|
||||
assert transition.control_mode in {KernelMode.NORMAL.value, KernelMode.DEBUG.value}
|
||||
assert transition.control_verbosity in {KernelVerbosity.QUIET.value, KernelVerbosity.TRACE.value}
|
||||
assert "asset" in transition.details
|
||||
assert "side" in transition.details
|
||||
903
prod/tests/test_dita_v2_kernel_state_machine_extensive.py
Normal file
903
prod/tests/test_dita_v2_kernel_state_machine_extensive.py
Normal file
@@ -0,0 +1,903 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from prod.clean_arch.dita_v2 import (
|
||||
AccountProjection,
|
||||
BackendMode,
|
||||
ControlUpdate,
|
||||
ExecutionKernel,
|
||||
HazelcastProjection,
|
||||
InMemoryControlPlane,
|
||||
InMemoryZincPlane,
|
||||
KernelCommandType,
|
||||
KernelControlSnapshot,
|
||||
KernelDiagnosticCode,
|
||||
KernelEventKind,
|
||||
KernelIntent,
|
||||
KernelMode,
|
||||
KernelOutcome,
|
||||
KernelVerbosity,
|
||||
MemoryKernelJournal,
|
||||
MockVenueAdapter,
|
||||
MockVenueScenario,
|
||||
TradeSide,
|
||||
TradeSlot,
|
||||
TradeStage,
|
||||
VenueEvent,
|
||||
VenueEventStatus,
|
||||
VenueOrder,
|
||||
VenueOrderStatus,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KernelRig:
|
||||
kernel: ExecutionKernel
|
||||
journal: MemoryKernelJournal
|
||||
zinc: InMemoryZincPlane
|
||||
projection: HazelcastProjection
|
||||
sink: "CaptureSink"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EntryCase:
|
||||
name: str
|
||||
scenario: MockVenueScenario
|
||||
expected_state: TradeStage
|
||||
expected_size: float
|
||||
rejected: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExitCase:
|
||||
name: str
|
||||
exit_leg_ratios: tuple[float, ...]
|
||||
fill_ratio: float
|
||||
expected_state: TradeStage
|
||||
expected_size: float
|
||||
expected_leg_index: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EventCase:
|
||||
name: str
|
||||
kind: KernelEventKind
|
||||
initial_state: TradeStage
|
||||
expected_state: TradeStage
|
||||
expected_code: KernelDiagnosticCode
|
||||
family: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReconcileCase:
|
||||
name: str
|
||||
slots: tuple[TradeSlot, ...]
|
||||
expected_open_positions: int
|
||||
expected_trade_ids: tuple[str, ...]
|
||||
|
||||
|
||||
class CaptureSink:
|
||||
def __init__(self) -> None:
|
||||
self.rows: list[tuple[str, dict[str, object]]] = []
|
||||
|
||||
def __call__(self, name: str, row: dict[str, object]) -> None:
|
||||
self.rows.append((name, dict(row)))
|
||||
|
||||
|
||||
def _build_kernel(
|
||||
*,
|
||||
venue: MockVenueAdapter | None = None,
|
||||
mode: KernelMode = KernelMode.DEBUG,
|
||||
verbosity: KernelVerbosity = KernelVerbosity.TRACE,
|
||||
backend_mode: BackendMode = BackendMode.MOCK,
|
||||
trace_transitions: bool = True,
|
||||
) -> KernelRig:
|
||||
sink = CaptureSink()
|
||||
journal = MemoryKernelJournal()
|
||||
zinc = InMemoryZincPlane()
|
||||
projection = HazelcastProjection(writer=sink)
|
||||
control_plane = InMemoryControlPlane(
|
||||
KernelControlSnapshot(
|
||||
mode=mode,
|
||||
verbosity=verbosity,
|
||||
backend_mode=backend_mode,
|
||||
trace_transitions=trace_transitions,
|
||||
debug_clickhouse_enabled=True,
|
||||
mirror_to_hazelcast=True,
|
||||
)
|
||||
)
|
||||
kernel = ExecutionKernel(
|
||||
max_slots=4,
|
||||
control_plane=control_plane,
|
||||
venue=venue or MockVenueAdapter(),
|
||||
journal=journal,
|
||||
account=AccountProjection(),
|
||||
projection=projection,
|
||||
zinc_plane=zinc,
|
||||
)
|
||||
return KernelRig(kernel=kernel, journal=journal, zinc=zinc, projection=projection, sink=sink)
|
||||
|
||||
|
||||
def _seed_entry_working(slot: TradeSlot, *, trade_id: str = "trade-1", asset: str = "BTCUSDT") -> None:
|
||||
slot.trade_id = trade_id
|
||||
slot.asset = asset
|
||||
slot.side = TradeSide.SHORT
|
||||
slot.entry_price = 100.0
|
||||
slot.size = 1.0
|
||||
slot.initial_size = 1.0
|
||||
slot.leverage = 2.0
|
||||
slot.closed = False
|
||||
slot.exit_leg_ratios = (1.0,)
|
||||
slot.active_leg_index = 0
|
||||
slot.fsm_state = TradeStage.ENTRY_WORKING
|
||||
slot.active_entry_order = VenueOrder(
|
||||
internal_trade_id=trade_id,
|
||||
venue_order_id="V-ENTRY-1",
|
||||
venue_client_id=f"{trade_id}:entry",
|
||||
side=TradeSide.SHORT,
|
||||
intended_size=1.0,
|
||||
status=VenueOrderStatus.NEW,
|
||||
metadata={"slot_id": slot.slot_id, "asset": asset},
|
||||
)
|
||||
slot.active_exit_order = None
|
||||
|
||||
|
||||
def _seed_position_open(
|
||||
slot: TradeSlot,
|
||||
*,
|
||||
trade_id: str = "trade-1",
|
||||
asset: str = "BTCUSDT",
|
||||
exit_leg_ratios: tuple[float, ...] = (1.0,),
|
||||
) -> None:
|
||||
_seed_entry_working(slot, trade_id=trade_id, asset=asset)
|
||||
slot.active_entry_order = VenueOrder(
|
||||
internal_trade_id=trade_id,
|
||||
venue_order_id="V-ENTRY-1",
|
||||
venue_client_id=f"{trade_id}:entry",
|
||||
side=TradeSide.SHORT,
|
||||
intended_size=1.0,
|
||||
filled_size=1.0,
|
||||
average_fill_price=100.0,
|
||||
status=VenueOrderStatus.FILLED,
|
||||
metadata={"slot_id": slot.slot_id, "asset": asset},
|
||||
)
|
||||
slot.fsm_state = TradeStage.POSITION_OPEN
|
||||
slot.size = 1.0
|
||||
slot.initial_size = 1.0
|
||||
slot.exit_leg_ratios = tuple(exit_leg_ratios)
|
||||
slot.active_leg_index = 0
|
||||
|
||||
|
||||
def _seed_exit_working(
|
||||
slot: TradeSlot,
|
||||
*,
|
||||
trade_id: str = "trade-1",
|
||||
asset: str = "BTCUSDT",
|
||||
exit_leg_ratios: tuple[float, ...] = (1.0,),
|
||||
active_leg_index: int = 0,
|
||||
) -> None:
|
||||
_seed_position_open(slot, trade_id=trade_id, asset=asset, exit_leg_ratios=exit_leg_ratios)
|
||||
slot.fsm_state = TradeStage.EXIT_WORKING
|
||||
slot.active_leg_index = active_leg_index
|
||||
slot.active_exit_order = VenueOrder(
|
||||
internal_trade_id=trade_id,
|
||||
venue_order_id="V-EXIT-1",
|
||||
venue_client_id=f"{trade_id}:exit",
|
||||
side=TradeSide.SHORT,
|
||||
intended_size=max(0.0, 1.0 * float(exit_leg_ratios[active_leg_index if active_leg_index < len(exit_leg_ratios) else 0])),
|
||||
status=VenueOrderStatus.NEW,
|
||||
metadata={"slot_id": slot.slot_id, "asset": asset},
|
||||
)
|
||||
|
||||
|
||||
def _seed_idle(slot: TradeSlot) -> None:
|
||||
slot.trade_id = ""
|
||||
slot.asset = ""
|
||||
slot.side = TradeSide.FLAT
|
||||
slot.entry_price = 0.0
|
||||
slot.size = 0.0
|
||||
slot.initial_size = 0.0
|
||||
slot.leverage = 0.0
|
||||
slot.entry_time = None
|
||||
slot.unrealized_pnl = 0.0
|
||||
slot.realized_pnl = 0.0
|
||||
slot.closed = False
|
||||
slot.exit_leg_ratios = (1.0,)
|
||||
slot.active_leg_index = 0
|
||||
slot.active_exit_order = None
|
||||
slot.active_entry_order = None
|
||||
slot.fsm_state = TradeStage.IDLE
|
||||
slot.close_reason = ""
|
||||
slot.last_event_time = None
|
||||
slot.metadata = {}
|
||||
|
||||
|
||||
def _seed_closed(slot: TradeSlot, *, trade_id: str = "trade-1", asset: str = "BTCUSDT") -> None:
|
||||
slot.trade_id = trade_id
|
||||
slot.asset = asset
|
||||
slot.side = TradeSide.SHORT
|
||||
slot.entry_price = 100.0
|
||||
slot.size = 0.0
|
||||
slot.initial_size = 1.0
|
||||
slot.leverage = 2.0
|
||||
slot.closed = True
|
||||
slot.exit_leg_ratios = (1.0,)
|
||||
slot.active_leg_index = 1
|
||||
slot.active_exit_order = None
|
||||
slot.active_entry_order = None
|
||||
slot.fsm_state = TradeStage.CLOSED
|
||||
slot.close_reason = "EXIT_FILLED"
|
||||
|
||||
|
||||
def _make_event(
|
||||
*,
|
||||
kind: KernelEventKind,
|
||||
status: VenueEventStatus,
|
||||
trade_id: str = "trade-1",
|
||||
slot_id: int = 0,
|
||||
venue_order_id: str = "V-ORDER-1",
|
||||
venue_client_id: str = "trade-1:client-1",
|
||||
side: TradeSide = TradeSide.SHORT,
|
||||
asset: str = "BTCUSDT",
|
||||
price: float = 100.0,
|
||||
size: float = 1.0,
|
||||
filled_size: float = 1.0,
|
||||
remaining_size: float = 0.0,
|
||||
reason: str = "",
|
||||
) -> VenueEvent:
|
||||
return VenueEvent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
event_id=f"evt-{kind.value.lower()}-{slot_id}-{trade_id}",
|
||||
trade_id=trade_id,
|
||||
slot_id=slot_id,
|
||||
kind=kind,
|
||||
status=status,
|
||||
venue_order_id=venue_order_id,
|
||||
venue_client_id=venue_client_id,
|
||||
side=side,
|
||||
asset=asset,
|
||||
price=price,
|
||||
size=size,
|
||||
filled_size=filled_size,
|
||||
remaining_size=remaining_size,
|
||||
reason=reason,
|
||||
raw_payload={"status": status.value, "kind": kind.value},
|
||||
)
|
||||
|
||||
|
||||
ENTRY_CASES = [
|
||||
EntryCase(
|
||||
name="full_fill_immediate",
|
||||
scenario=MockVenueScenario(emit_fill_on_submit=True, partial_fill_ratio=1.0),
|
||||
expected_state=TradeStage.POSITION_OPEN,
|
||||
expected_size=1.0,
|
||||
),
|
||||
EntryCase(
|
||||
name="partial_50_immediate",
|
||||
scenario=MockVenueScenario(emit_fill_on_submit=True, partial_fill_ratio=0.5),
|
||||
expected_state=TradeStage.ENTRY_WORKING,
|
||||
expected_size=0.5,
|
||||
),
|
||||
EntryCase(
|
||||
name="partial_50_ack_then_fill",
|
||||
scenario=MockVenueScenario(emit_fill_on_submit=False, partial_fill_ratio=0.5),
|
||||
expected_state=TradeStage.ENTRY_WORKING,
|
||||
expected_size=0.5,
|
||||
),
|
||||
EntryCase(
|
||||
name="no_fill_ack_only",
|
||||
scenario=MockVenueScenario(emit_fill_on_submit=False, partial_fill_ratio=0.0),
|
||||
expected_state=TradeStage.ENTRY_WORKING,
|
||||
expected_size=0.0,
|
||||
),
|
||||
EntryCase(
|
||||
name="ack_before_fill_full",
|
||||
scenario=MockVenueScenario(emit_ack_before_fill=False, emit_fill_on_submit=True, partial_fill_ratio=1.0),
|
||||
expected_state=TradeStage.POSITION_OPEN,
|
||||
expected_size=1.0,
|
||||
),
|
||||
EntryCase(
|
||||
name="ack_before_fill_partial",
|
||||
scenario=MockVenueScenario(emit_ack_before_fill=False, emit_fill_on_submit=True, partial_fill_ratio=0.25),
|
||||
expected_state=TradeStage.ENTRY_WORKING,
|
||||
expected_size=0.25,
|
||||
),
|
||||
EntryCase(
|
||||
name="reject_entry",
|
||||
scenario=MockVenueScenario(reject_entries=True),
|
||||
expected_state=TradeStage.IDLE,
|
||||
expected_size=0.0,
|
||||
rejected=True,
|
||||
),
|
||||
EntryCase(
|
||||
name="three_quarters_fill",
|
||||
scenario=MockVenueScenario(emit_fill_on_submit=True, partial_fill_ratio=0.75),
|
||||
expected_state=TradeStage.ENTRY_WORKING,
|
||||
expected_size=0.75,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
EXIT_CASES = [
|
||||
ExitCase(name="single_leg_full", exit_leg_ratios=(1.0,), fill_ratio=1.0, expected_state=TradeStage.CLOSED, expected_size=0.0, expected_leg_index=1),
|
||||
ExitCase(name="single_leg_partial", exit_leg_ratios=(1.0,), fill_ratio=0.5, expected_state=TradeStage.EXIT_WORKING, expected_size=0.5, expected_leg_index=0),
|
||||
ExitCase(name="two_leg_full", exit_leg_ratios=(0.5, 0.5), fill_ratio=1.0, expected_state=TradeStage.POSITION_OPEN, expected_size=0.5, expected_leg_index=1),
|
||||
ExitCase(name="two_leg_partial", exit_leg_ratios=(0.5, 0.5), fill_ratio=0.25, expected_state=TradeStage.EXIT_WORKING, expected_size=0.875, expected_leg_index=0),
|
||||
ExitCase(name="three_leg_full", exit_leg_ratios=(0.25, 0.25, 0.5), fill_ratio=1.0, expected_state=TradeStage.POSITION_OPEN, expected_size=0.75, expected_leg_index=1),
|
||||
ExitCase(name="three_leg_partial", exit_leg_ratios=(0.25, 0.25, 0.5), fill_ratio=0.5, expected_state=TradeStage.EXIT_WORKING, expected_size=0.875, expected_leg_index=0),
|
||||
ExitCase(name="tilted_full", exit_leg_ratios=(0.2, 0.3, 0.5), fill_ratio=1.0, expected_state=TradeStage.POSITION_OPEN, expected_size=0.8, expected_leg_index=1),
|
||||
ExitCase(name="tilted_partial", exit_leg_ratios=(0.2, 0.3, 0.5), fill_ratio=0.25, expected_state=TradeStage.EXIT_WORKING, expected_size=0.95, expected_leg_index=0),
|
||||
ExitCase(name="four_leg_full", exit_leg_ratios=(0.1, 0.2, 0.3, 0.4), fill_ratio=1.0, expected_state=TradeStage.POSITION_OPEN, expected_size=0.9, expected_leg_index=1),
|
||||
ExitCase(name="four_leg_partial", exit_leg_ratios=(0.1, 0.2, 0.3, 0.4), fill_ratio=0.5, expected_state=TradeStage.EXIT_WORKING, expected_size=0.95, expected_leg_index=0),
|
||||
ExitCase(name="balanced_full", exit_leg_ratios=(0.33, 0.33, 0.34), fill_ratio=1.0, expected_state=TradeStage.POSITION_OPEN, expected_size=0.67, expected_leg_index=1),
|
||||
ExitCase(name="balanced_partial", exit_leg_ratios=(0.33, 0.33, 0.34), fill_ratio=0.25, expected_state=TradeStage.EXIT_WORKING, expected_size=0.9175, expected_leg_index=0),
|
||||
]
|
||||
|
||||
|
||||
EVENT_CASES = [
|
||||
EventCase("ack_entry", KernelEventKind.ORDER_ACK, TradeStage.ENTRY_WORKING, TradeStage.ENTRY_WORKING, KernelDiagnosticCode.OK, "entry"),
|
||||
EventCase("ack_exit", KernelEventKind.ORDER_ACK, TradeStage.EXIT_REQUESTED, TradeStage.EXIT_WORKING, KernelDiagnosticCode.OK, "exit"),
|
||||
EventCase("reject_entry", KernelEventKind.ORDER_REJECT, TradeStage.ENTRY_WORKING, TradeStage.IDLE, KernelDiagnosticCode.ENTRY_ORDER_REJECTED, "entry"),
|
||||
EventCase("reject_exit", KernelEventKind.ORDER_REJECT, TradeStage.EXIT_WORKING, TradeStage.POSITION_OPEN, KernelDiagnosticCode.EXIT_ORDER_REJECTED, "exit"),
|
||||
EventCase("reject_idle", KernelEventKind.ORDER_REJECT, TradeStage.IDLE, TradeStage.IDLE, KernelDiagnosticCode.ORDER_REJECTED, "none"),
|
||||
EventCase("partial_entry", KernelEventKind.PARTIAL_FILL, TradeStage.ENTRY_WORKING, TradeStage.ENTRY_WORKING, KernelDiagnosticCode.OK, "entry"),
|
||||
EventCase("full_entry", KernelEventKind.FULL_FILL, TradeStage.ENTRY_WORKING, TradeStage.POSITION_OPEN, KernelDiagnosticCode.OK, "entry"),
|
||||
EventCase("partial_exit", KernelEventKind.PARTIAL_FILL, TradeStage.EXIT_WORKING, TradeStage.EXIT_WORKING, KernelDiagnosticCode.OK, "exit"),
|
||||
EventCase("full_exit", KernelEventKind.FULL_FILL, TradeStage.EXIT_WORKING, TradeStage.CLOSED, KernelDiagnosticCode.OK, "exit"),
|
||||
EventCase("cancel_ack_exit", KernelEventKind.CANCEL_ACK, TradeStage.EXIT_WORKING, TradeStage.POSITION_OPEN, KernelDiagnosticCode.OK, "exit"),
|
||||
EventCase("cancel_reject_exit", KernelEventKind.CANCEL_REJECT, TradeStage.EXIT_WORKING, TradeStage.EXIT_WORKING, KernelDiagnosticCode.CANCEL_REJECTED, "exit"),
|
||||
EventCase("mark_price_open", KernelEventKind.MARK_PRICE, TradeStage.POSITION_OPEN, TradeStage.POSITION_OPEN, KernelDiagnosticCode.OK, "none"),
|
||||
EventCase("reconcile_open", KernelEventKind.RECONCILE, TradeStage.POSITION_OPEN, TradeStage.STALE_STATE_RECONCILING, KernelDiagnosticCode.OK, "none"),
|
||||
EventCase("ack_open_no_entry", KernelEventKind.ORDER_ACK, TradeStage.POSITION_OPEN, TradeStage.POSITION_OPEN, KernelDiagnosticCode.OK, "none"),
|
||||
EventCase("cancel_ack_open_no_exit", KernelEventKind.CANCEL_ACK, TradeStage.POSITION_OPEN, TradeStage.POSITION_OPEN, KernelDiagnosticCode.OK, "none"),
|
||||
]
|
||||
|
||||
|
||||
RECONCILE_CASES = [
|
||||
ReconcileCase(
|
||||
name="empty_payload",
|
||||
slots=(),
|
||||
expected_open_positions=0,
|
||||
expected_trade_ids=(),
|
||||
),
|
||||
ReconcileCase(
|
||||
name="single_open",
|
||||
slots=(
|
||||
TradeSlot(
|
||||
slot_id=0,
|
||||
trade_id="trade-a",
|
||||
asset="BTCUSDT",
|
||||
side=TradeSide.SHORT,
|
||||
entry_price=100.0,
|
||||
size=1.0,
|
||||
initial_size=1.0,
|
||||
leverage=2.0,
|
||||
fsm_state=TradeStage.POSITION_OPEN,
|
||||
),
|
||||
),
|
||||
expected_open_positions=1,
|
||||
expected_trade_ids=("trade-a",),
|
||||
),
|
||||
ReconcileCase(
|
||||
name="open_and_exit",
|
||||
slots=(
|
||||
TradeSlot(
|
||||
slot_id=0,
|
||||
trade_id="trade-b",
|
||||
asset="BTCUSDT",
|
||||
side=TradeSide.SHORT,
|
||||
entry_price=100.0,
|
||||
size=0.5,
|
||||
initial_size=1.0,
|
||||
leverage=2.0,
|
||||
fsm_state=TradeStage.EXIT_WORKING,
|
||||
),
|
||||
TradeSlot(
|
||||
slot_id=1,
|
||||
trade_id="trade-c",
|
||||
asset="ETHUSDT",
|
||||
side=TradeSide.LONG,
|
||||
entry_price=50.0,
|
||||
size=0.0,
|
||||
initial_size=1.0,
|
||||
leverage=3.0,
|
||||
closed=True,
|
||||
fsm_state=TradeStage.CLOSED,
|
||||
),
|
||||
),
|
||||
expected_open_positions=1,
|
||||
expected_trade_ids=("trade-b", "trade-c"),
|
||||
),
|
||||
ReconcileCase(
|
||||
name="mixed_three",
|
||||
slots=(
|
||||
TradeSlot(
|
||||
slot_id=0,
|
||||
trade_id="trade-d",
|
||||
asset="BTCUSDT",
|
||||
side=TradeSide.SHORT,
|
||||
entry_price=100.0,
|
||||
size=1.0,
|
||||
initial_size=1.0,
|
||||
leverage=2.0,
|
||||
fsm_state=TradeStage.POSITION_OPEN,
|
||||
),
|
||||
TradeSlot(
|
||||
slot_id=1,
|
||||
trade_id="trade-e",
|
||||
asset="ETHUSDT",
|
||||
side=TradeSide.LONG,
|
||||
entry_price=50.0,
|
||||
size=0.0,
|
||||
initial_size=1.0,
|
||||
leverage=3.0,
|
||||
closed=True,
|
||||
fsm_state=TradeStage.CLOSED,
|
||||
),
|
||||
TradeSlot(
|
||||
slot_id=2,
|
||||
trade_id="trade-f",
|
||||
asset="SOLUSDT",
|
||||
side=TradeSide.SHORT,
|
||||
entry_price=20.0,
|
||||
size=0.25,
|
||||
initial_size=1.0,
|
||||
leverage=4.0,
|
||||
fsm_state=TradeStage.EXIT_WORKING,
|
||||
),
|
||||
),
|
||||
expected_open_positions=2,
|
||||
expected_trade_ids=("trade-d", "trade-e", "trade-f"),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _event_order_id(case: EventCase, resolver: str) -> str:
|
||||
if case.family == "entry":
|
||||
return "V-ENTRY-1"
|
||||
if case.family == "exit":
|
||||
return "V-EXIT-1"
|
||||
if resolver == "order_id":
|
||||
return "V-MISSING"
|
||||
return "V-ORDER-1"
|
||||
|
||||
|
||||
def _event_client_id(case: EventCase, resolver: str) -> str:
|
||||
if case.family == "entry":
|
||||
return "trade-1:entry"
|
||||
if case.family == "exit":
|
||||
return "trade-1:exit"
|
||||
if resolver == "order_id":
|
||||
return "trade-x:missing"
|
||||
return "trade-1:client-1"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [KernelMode.NORMAL, KernelMode.DEBUG])
|
||||
@pytest.mark.parametrize("verbosity", [KernelVerbosity.QUIET, KernelVerbosity.VERBOSE, KernelVerbosity.TRACE])
|
||||
@pytest.mark.parametrize("backend_mode", [BackendMode.MOCK, BackendMode.BINGX])
|
||||
@pytest.mark.parametrize("trace_transitions", [True, False])
|
||||
def test_kernel_control_plane_matrix(
|
||||
mode: KernelMode,
|
||||
verbosity: KernelVerbosity,
|
||||
backend_mode: BackendMode,
|
||||
trace_transitions: bool,
|
||||
) -> None:
|
||||
rig = _build_kernel()
|
||||
snapshot = rig.kernel.update_control(
|
||||
ControlUpdate(
|
||||
mode=mode,
|
||||
verbosity=verbosity,
|
||||
backend_mode=backend_mode,
|
||||
trace_transitions=trace_transitions,
|
||||
)
|
||||
)
|
||||
assert snapshot.mode == mode
|
||||
assert snapshot.verbosity == verbosity
|
||||
assert snapshot.backend_mode == backend_mode
|
||||
assert snapshot.trace_transitions == trace_transitions
|
||||
assert rig.kernel.control.mode == mode
|
||||
assert rig.kernel.control.verbosity == verbosity
|
||||
assert rig.zinc.read_control().mode == mode
|
||||
assert rig.zinc.read_control().verbosity == verbosity
|
||||
assert rig.projection.control_snapshot is not None
|
||||
assert rig.projection.control_snapshot.mode == mode
|
||||
assert rig.sink.rows[-1][0] == "hz:dita_control"
|
||||
assert rig.sink.rows[-1][1]["mode"] == mode.value
|
||||
assert rig.sink.rows[-1][1]["backend_mode"] == backend_mode.value
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [KernelMode.NORMAL, KernelMode.DEBUG])
|
||||
@pytest.mark.parametrize("verbosity", [KernelVerbosity.QUIET, KernelVerbosity.TRACE])
|
||||
@pytest.mark.parametrize("case", ENTRY_CASES, ids=[case.name for case in ENTRY_CASES])
|
||||
def test_kernel_entry_matrix(
|
||||
mode: KernelMode,
|
||||
verbosity: KernelVerbosity,
|
||||
case: EntryCase,
|
||||
) -> None:
|
||||
rig = _build_kernel(
|
||||
venue=MockVenueAdapter(case.scenario),
|
||||
mode=mode,
|
||||
verbosity=verbosity,
|
||||
backend_mode=BackendMode.MOCK,
|
||||
)
|
||||
outcome = rig.kernel.process_intent(
|
||||
KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id="intent-entry",
|
||||
trade_id="trade-1",
|
||||
slot_id=0,
|
||||
asset="BTCUSDT",
|
||||
side=TradeSide.SHORT,
|
||||
action=KernelCommandType.ENTER,
|
||||
reference_price=100.0,
|
||||
target_size=1.0,
|
||||
leverage=2.0,
|
||||
exit_leg_ratios=(1.0,),
|
||||
reason=case.name,
|
||||
)
|
||||
)
|
||||
slot = rig.kernel.slot(0)
|
||||
assert outcome.accepted is True
|
||||
assert outcome.diagnostic_code == KernelDiagnosticCode.OK
|
||||
assert slot.fsm_state == case.expected_state
|
||||
assert slot.size == pytest.approx(case.expected_size, abs=1e-6)
|
||||
assert rig.zinc.intent_region[-1].action == KernelCommandType.ENTER
|
||||
assert rig.zinc.state_region[0].fsm_state == case.expected_state
|
||||
assert rig.journal.rows
|
||||
if case.rejected:
|
||||
assert slot.trade_id == ""
|
||||
assert slot.asset == ""
|
||||
assert slot.active_entry_order is None
|
||||
assert slot.active_exit_order is None
|
||||
if case.expected_state == TradeStage.POSITION_OPEN:
|
||||
assert slot.closed is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", EXIT_CASES, ids=[case.name for case in EXIT_CASES])
|
||||
def test_kernel_exit_matrix(case: ExitCase) -> None:
|
||||
rig = _build_kernel(venue=MockVenueAdapter(MockVenueScenario(emit_fill_on_submit=True, partial_fill_ratio=case.fill_ratio)))
|
||||
slot = rig.kernel.slot(0)
|
||||
_seed_position_open(slot, exit_leg_ratios=case.exit_leg_ratios)
|
||||
slot.active_entry_order = None
|
||||
|
||||
outcome = rig.kernel.process_intent(
|
||||
KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id="intent-exit",
|
||||
trade_id=slot.trade_id,
|
||||
slot_id=0,
|
||||
asset=slot.asset,
|
||||
side=slot.side,
|
||||
action=KernelCommandType.EXIT,
|
||||
reference_price=99.0,
|
||||
target_size=case.exit_leg_ratios[0],
|
||||
leverage=slot.leverage,
|
||||
exit_leg_ratios=case.exit_leg_ratios,
|
||||
reason=case.name,
|
||||
)
|
||||
)
|
||||
|
||||
assert outcome.accepted is True
|
||||
assert outcome.diagnostic_code == KernelDiagnosticCode.OK
|
||||
assert slot.fsm_state == case.expected_state
|
||||
assert slot.active_leg_index == case.expected_leg_index
|
||||
assert slot.size == pytest.approx(case.expected_size, abs=1e-6)
|
||||
if case.expected_state == TradeStage.CLOSED:
|
||||
assert slot.closed is True
|
||||
assert slot.active_exit_order is None
|
||||
assert slot.active_entry_order is None
|
||||
else:
|
||||
assert slot.closed is False
|
||||
assert slot.active_exit_order is None or slot.active_exit_order.status in {
|
||||
VenueOrderStatus.PARTIALLY_FILLED,
|
||||
VenueOrderStatus.NEW,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("resolver", ["slot_id", "trade_id", "order_id"])
|
||||
@pytest.mark.parametrize("case", EVENT_CASES, ids=[case.name for case in EVENT_CASES])
|
||||
def test_kernel_event_resolution_matrix(case: EventCase, resolver: str) -> None:
|
||||
rig = _build_kernel()
|
||||
slot = rig.kernel.slot(0)
|
||||
|
||||
if case.family == "entry":
|
||||
_seed_entry_working(slot)
|
||||
elif case.family == "exit":
|
||||
_seed_exit_working(slot)
|
||||
elif case.initial_state == TradeStage.POSITION_OPEN:
|
||||
_seed_position_open(slot)
|
||||
elif case.initial_state == TradeStage.IDLE:
|
||||
_seed_idle(slot)
|
||||
|
||||
if resolver == "slot_id":
|
||||
event_slot_id = 0
|
||||
event_trade_id = "mismatch-trade"
|
||||
event_venue_order_id = "V-MISMATCH"
|
||||
elif resolver == "trade_id":
|
||||
event_slot_id = 99
|
||||
event_trade_id = slot.trade_id or "trade-1"
|
||||
event_venue_order_id = "V-MISMATCH"
|
||||
else:
|
||||
event_slot_id = 99
|
||||
event_trade_id = "mismatch-trade"
|
||||
event_venue_order_id = "V-ENTRY-1" if case.family == "entry" else "V-EXIT-1"
|
||||
|
||||
if case.kind == KernelEventKind.ORDER_ACK and case.family == "none":
|
||||
slot.active_entry_order = None
|
||||
slot.active_exit_order = None
|
||||
if case.kind == KernelEventKind.CANCEL_ACK and case.family == "none":
|
||||
slot.active_exit_order = None
|
||||
if case.kind == KernelEventKind.ORDER_REJECT and case.family == "exit":
|
||||
slot.active_entry_order = None
|
||||
|
||||
if case.kind in {KernelEventKind.ORDER_ACK, KernelEventKind.ORDER_REJECT, KernelEventKind.PARTIAL_FILL, KernelEventKind.FULL_FILL} and case.family == "entry" and resolver != "order_id":
|
||||
event_venue_order_id = slot.active_entry_order.venue_order_id
|
||||
event_trade_id = slot.trade_id
|
||||
if case.kind in {KernelEventKind.ORDER_ACK, KernelEventKind.ORDER_REJECT, KernelEventKind.PARTIAL_FILL, KernelEventKind.FULL_FILL, KernelEventKind.CANCEL_ACK, KernelEventKind.CANCEL_REJECT} and case.family == "exit" and resolver != "order_id":
|
||||
event_venue_order_id = slot.active_exit_order.venue_order_id
|
||||
event_trade_id = slot.trade_id
|
||||
|
||||
price = 98.0 if case.kind == KernelEventKind.MARK_PRICE else 100.0
|
||||
filled_size = 1.0 if case.kind in {KernelEventKind.FULL_FILL, KernelEventKind.ORDER_ACK} else 0.5
|
||||
remaining_size = max(0.0, 1.0 - filled_size)
|
||||
event = _make_event(
|
||||
kind=case.kind,
|
||||
status={
|
||||
KernelEventKind.ORDER_ACK: VenueEventStatus.ACKED,
|
||||
KernelEventKind.ORDER_REJECT: VenueEventStatus.REJECTED,
|
||||
KernelEventKind.PARTIAL_FILL: VenueEventStatus.PARTIALLY_FILLED,
|
||||
KernelEventKind.FULL_FILL: VenueEventStatus.FILLED,
|
||||
KernelEventKind.CANCEL_ACK: VenueEventStatus.CANCELED,
|
||||
KernelEventKind.CANCEL_REJECT: VenueEventStatus.CANCELED_REJECTED,
|
||||
KernelEventKind.MARK_PRICE: VenueEventStatus.ACKED,
|
||||
KernelEventKind.RECONCILE: VenueEventStatus.ACKED,
|
||||
}[case.kind],
|
||||
trade_id=event_trade_id,
|
||||
slot_id=event_slot_id,
|
||||
venue_order_id=event_venue_order_id,
|
||||
venue_client_id=_event_client_id(case, resolver),
|
||||
side=TradeSide.SHORT,
|
||||
asset="BTCUSDT",
|
||||
price=price,
|
||||
size=1.0,
|
||||
filled_size=filled_size,
|
||||
remaining_size=remaining_size,
|
||||
)
|
||||
|
||||
outcome = rig.kernel.on_venue_event(event)
|
||||
assert outcome.state == case.expected_state
|
||||
assert outcome.diagnostic_code == case.expected_code
|
||||
|
||||
if case.kind == KernelEventKind.MARK_PRICE:
|
||||
assert slot.unrealized_pnl > 0.0
|
||||
if case.kind == KernelEventKind.ORDER_REJECT and case.family == "entry":
|
||||
assert slot.trade_id == ""
|
||||
assert slot.asset == ""
|
||||
assert slot.size == 0.0
|
||||
if case.kind == KernelEventKind.ORDER_REJECT and case.family == "exit":
|
||||
assert slot.fsm_state == TradeStage.POSITION_OPEN
|
||||
assert slot.active_exit_order is None
|
||||
if case.kind == KernelEventKind.FULL_FILL and case.family == "entry":
|
||||
assert slot.fsm_state == TradeStage.POSITION_OPEN
|
||||
if case.kind == KernelEventKind.FULL_FILL and case.family == "exit" and case.expected_state == TradeStage.CLOSED:
|
||||
assert slot.closed is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", RECONCILE_CASES, ids=[case.name for case in RECONCILE_CASES])
|
||||
@pytest.mark.parametrize("mode", [KernelMode.NORMAL, KernelMode.DEBUG])
|
||||
@pytest.mark.parametrize("verbosity", [KernelVerbosity.QUIET, KernelVerbosity.TRACE])
|
||||
def test_kernel_reconcile_snapshot_matrix(
|
||||
case: ReconcileCase,
|
||||
mode: KernelMode,
|
||||
verbosity: KernelVerbosity,
|
||||
) -> None:
|
||||
rig = _build_kernel(mode=mode, verbosity=verbosity)
|
||||
outcome = rig.kernel.reconcile_from_slots(case.slots)
|
||||
assert outcome.accepted is True
|
||||
assert outcome.diagnostic_code == KernelDiagnosticCode.RECONCILED
|
||||
assert rig.kernel.snapshot()["account"]["open_positions"] == case.expected_open_positions
|
||||
assert tuple(slot.trade_id for slot in rig.kernel.state.slots if slot.trade_id) == case.expected_trade_ids
|
||||
assert len(rig.zinc.read_slots()) == len(rig.kernel.state.slots)
|
||||
assert any(name == "hz:dita_active_slots" for name, _ in rig.sink.rows)
|
||||
assert rig.projection.control_snapshot is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("seed", list(range(24)))
|
||||
def test_kernel_fuzz_transition_matrix(seed: int) -> None:
|
||||
rng = random.Random(seed)
|
||||
rig = _build_kernel(
|
||||
venue=MockVenueAdapter(MockVenueScenario(emit_fill_on_submit=True, partial_fill_ratio=0.5)),
|
||||
mode=KernelMode.DEBUG,
|
||||
verbosity=KernelVerbosity.TRACE,
|
||||
)
|
||||
|
||||
for step in range(20):
|
||||
slot_id = rng.randrange(0, len(rig.kernel.state.slots))
|
||||
slot = rig.kernel.slot(slot_id)
|
||||
op = rng.choice(["enter", "exit", "cancel", "mark", "reconcile", "control", "event"])
|
||||
|
||||
if op == "enter":
|
||||
trade_id = f"trade-{seed}-{step}"
|
||||
outcome = rig.kernel.process_intent(
|
||||
KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id=f"intent-{seed}-{step}-enter",
|
||||
trade_id=trade_id,
|
||||
slot_id=slot_id,
|
||||
asset="BTCUSDT",
|
||||
side=TradeSide.SHORT,
|
||||
action=KernelCommandType.ENTER,
|
||||
reference_price=100.0 + rng.random(),
|
||||
target_size=1.0,
|
||||
leverage=2.0,
|
||||
exit_leg_ratios=(0.5, 0.5),
|
||||
reason="fuzz-enter",
|
||||
)
|
||||
)
|
||||
assert outcome.diagnostic_code in set(KernelDiagnosticCode)
|
||||
elif op == "exit":
|
||||
outcome = rig.kernel.process_intent(
|
||||
KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id=f"intent-{seed}-{step}-exit",
|
||||
trade_id=slot.trade_id or f"trade-{seed}-{step}",
|
||||
slot_id=slot_id,
|
||||
asset=slot.asset or "BTCUSDT",
|
||||
side=TradeSide.SHORT,
|
||||
action=KernelCommandType.EXIT,
|
||||
reference_price=99.0 + rng.random(),
|
||||
target_size=max(0.1, slot.size or 0.1),
|
||||
leverage=slot.leverage or 2.0,
|
||||
exit_leg_ratios=slot.exit_leg_ratios or (1.0,),
|
||||
reason="fuzz-exit",
|
||||
)
|
||||
)
|
||||
assert outcome.diagnostic_code in {
|
||||
KernelDiagnosticCode.OK,
|
||||
KernelDiagnosticCode.NO_OPEN_POSITION,
|
||||
}
|
||||
elif op == "cancel":
|
||||
outcome = rig.kernel.process_intent(
|
||||
KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id=f"intent-{seed}-{step}-cancel",
|
||||
trade_id=slot.trade_id or f"trade-{seed}-{step}",
|
||||
slot_id=slot_id,
|
||||
asset=slot.asset or "BTCUSDT",
|
||||
side=TradeSide.SHORT,
|
||||
action=KernelCommandType.CANCEL,
|
||||
reference_price=99.0,
|
||||
target_size=max(0.1, slot.size or 0.1),
|
||||
leverage=slot.leverage or 2.0,
|
||||
exit_leg_ratios=slot.exit_leg_ratios or (1.0,),
|
||||
reason="fuzz-cancel",
|
||||
)
|
||||
)
|
||||
assert outcome.diagnostic_code in {
|
||||
KernelDiagnosticCode.OK,
|
||||
KernelDiagnosticCode.NO_ACTIVE_EXIT_ORDER,
|
||||
}
|
||||
elif op == "mark":
|
||||
outcome = rig.kernel.process_intent(
|
||||
KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id=f"intent-{seed}-{step}-mark",
|
||||
trade_id=slot.trade_id or f"trade-{seed}-{step}",
|
||||
slot_id=slot_id,
|
||||
asset=slot.asset or "BTCUSDT",
|
||||
side=slot.side if slot.side != TradeSide.FLAT else TradeSide.SHORT,
|
||||
action=KernelCommandType.MARK_PRICE,
|
||||
reference_price=95.0 + rng.random() * 10.0,
|
||||
target_size=max(0.1, slot.size or 0.1),
|
||||
leverage=slot.leverage or 2.0,
|
||||
exit_leg_ratios=slot.exit_leg_ratios or (1.0,),
|
||||
reason="fuzz-mark",
|
||||
)
|
||||
)
|
||||
assert outcome.diagnostic_code == KernelDiagnosticCode.OK
|
||||
elif op == "reconcile":
|
||||
outcome = rig.kernel.process_intent(
|
||||
KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id=f"intent-{seed}-{step}-reconcile",
|
||||
trade_id=slot.trade_id or f"trade-{seed}-{step}",
|
||||
slot_id=slot_id,
|
||||
asset=slot.asset or "BTCUSDT",
|
||||
side=slot.side if slot.side != TradeSide.FLAT else TradeSide.SHORT,
|
||||
action=KernelCommandType.RECONCILE,
|
||||
reference_price=100.0,
|
||||
target_size=max(0.1, slot.size or 0.1),
|
||||
leverage=slot.leverage or 2.0,
|
||||
exit_leg_ratios=slot.exit_leg_ratios or (1.0,),
|
||||
reason="fuzz-reconcile",
|
||||
)
|
||||
)
|
||||
assert outcome.diagnostic_code == KernelDiagnosticCode.STALE_STATE_RECONCILE
|
||||
elif op == "control":
|
||||
rig.kernel.update_control(
|
||||
ControlUpdate(
|
||||
mode=KernelMode.DEBUG if rng.random() < 0.5 else KernelMode.NORMAL,
|
||||
verbosity=rng.choice([KernelVerbosity.QUIET, KernelVerbosity.VERBOSE, KernelVerbosity.TRACE]),
|
||||
backend_mode=rng.choice([BackendMode.MOCK, BackendMode.BINGX]),
|
||||
trace_transitions=rng.random() < 0.5,
|
||||
)
|
||||
)
|
||||
elif op == "event":
|
||||
current = rig.kernel.slot(slot_id)
|
||||
if current.active_exit_order is not None:
|
||||
kind = rng.choice(
|
||||
[
|
||||
KernelEventKind.PARTIAL_FILL,
|
||||
KernelEventKind.FULL_FILL,
|
||||
KernelEventKind.CANCEL_ACK,
|
||||
KernelEventKind.CANCEL_REJECT,
|
||||
KernelEventKind.ORDER_REJECT,
|
||||
]
|
||||
)
|
||||
elif current.active_entry_order is not None:
|
||||
kind = rng.choice(
|
||||
[
|
||||
KernelEventKind.ORDER_ACK,
|
||||
KernelEventKind.PARTIAL_FILL,
|
||||
KernelEventKind.FULL_FILL,
|
||||
KernelEventKind.ORDER_REJECT,
|
||||
]
|
||||
)
|
||||
else:
|
||||
kind = rng.choice(
|
||||
[
|
||||
KernelEventKind.ORDER_REJECT,
|
||||
KernelEventKind.MARK_PRICE,
|
||||
KernelEventKind.RECONCILE,
|
||||
]
|
||||
)
|
||||
status = {
|
||||
KernelEventKind.ORDER_ACK: VenueEventStatus.ACKED,
|
||||
KernelEventKind.ORDER_REJECT: VenueEventStatus.REJECTED,
|
||||
KernelEventKind.PARTIAL_FILL: VenueEventStatus.PARTIALLY_FILLED,
|
||||
KernelEventKind.FULL_FILL: VenueEventStatus.FILLED,
|
||||
KernelEventKind.CANCEL_ACK: VenueEventStatus.CANCELED,
|
||||
KernelEventKind.CANCEL_REJECT: VenueEventStatus.CANCELED_REJECTED,
|
||||
KernelEventKind.MARK_PRICE: VenueEventStatus.ACKED,
|
||||
KernelEventKind.RECONCILE: VenueEventStatus.ACKED,
|
||||
}[kind]
|
||||
venue_order_id = "V-FUZZ"
|
||||
venue_client_id = f"fuzz:{seed}:{step}"
|
||||
if current.active_entry_order is not None:
|
||||
venue_order_id = current.active_entry_order.venue_order_id
|
||||
venue_client_id = current.active_entry_order.venue_client_id
|
||||
elif current.active_exit_order is not None:
|
||||
venue_order_id = current.active_exit_order.venue_order_id
|
||||
venue_client_id = current.active_exit_order.venue_client_id
|
||||
outcome = rig.kernel.on_venue_event(
|
||||
_make_event(
|
||||
kind=kind,
|
||||
status=status,
|
||||
trade_id=current.trade_id or f"trade-{seed}-{step}",
|
||||
slot_id=slot_id if rng.random() < 0.5 else 99,
|
||||
venue_order_id=venue_order_id,
|
||||
venue_client_id=venue_client_id,
|
||||
side=current.side if current.side != TradeSide.FLAT else TradeSide.SHORT,
|
||||
asset=current.asset or "BTCUSDT",
|
||||
price=98.0 if kind == KernelEventKind.MARK_PRICE else 100.0,
|
||||
size=max(0.1, current.size or 0.1),
|
||||
filled_size=max(0.1, current.size or 0.1),
|
||||
remaining_size=0.0,
|
||||
)
|
||||
)
|
||||
assert isinstance(outcome, KernelOutcome)
|
||||
assert outcome.diagnostic_code in set(KernelDiagnosticCode)
|
||||
|
||||
assert slot.fsm_state in set(TradeStage)
|
||||
assert slot.size >= 0.0
|
||||
assert slot.initial_size >= 0.0
|
||||
assert slot.active_leg_index >= 0
|
||||
if slot.closed:
|
||||
assert slot.size == pytest.approx(0.0, abs=1e-9)
|
||||
if slot.fsm_state == TradeStage.IDLE:
|
||||
assert slot.size == pytest.approx(0.0, abs=1e-9)
|
||||
494
prod/tests/test_dita_v2_kernel_state_machine_kernelsolo.py
Normal file
494
prod/tests/test_dita_v2_kernel_state_machine_kernelsolo.py
Normal file
@@ -0,0 +1,494 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from prod.clean_arch.dita_v2 import (
|
||||
AccountProjection,
|
||||
BackendMode,
|
||||
ControlUpdate,
|
||||
ExecutionKernel,
|
||||
InMemoryControlPlane,
|
||||
InMemoryZincPlane,
|
||||
KernelCommandType,
|
||||
KernelControlSnapshot,
|
||||
KernelDiagnosticCode,
|
||||
KernelEventKind,
|
||||
KernelIntent,
|
||||
KernelMode,
|
||||
KernelVerbosity,
|
||||
MemoryKernelJournal,
|
||||
TradeSide,
|
||||
TradeSlot,
|
||||
TradeStage,
|
||||
VenueAdapter,
|
||||
VenueEvent,
|
||||
VenueEventStatus,
|
||||
VenueOrder,
|
||||
VenueOrderStatus,
|
||||
)
|
||||
|
||||
|
||||
class NoopVenueAdapter:
|
||||
"""Venue stub that never emits events."""
|
||||
|
||||
def submit(self, intent: KernelIntent): # type: ignore[override]
|
||||
return []
|
||||
|
||||
def cancel(self, order: VenueOrder, *, reason: str = ""): # type: ignore[override]
|
||||
return []
|
||||
|
||||
def open_orders(self): # type: ignore[override]
|
||||
return []
|
||||
|
||||
def open_positions(self): # type: ignore[override]
|
||||
return []
|
||||
|
||||
def reconcile(self): # type: ignore[override]
|
||||
return []
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecoveryCase:
|
||||
name: str
|
||||
seed: int
|
||||
slot_count: int
|
||||
trade_count: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DuplicateCase:
|
||||
name: str
|
||||
initial_state: TradeStage
|
||||
kind: KernelEventKind
|
||||
family: str
|
||||
expected_state: TradeStage
|
||||
expected_code: KernelDiagnosticCode
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OutOfOrderCase:
|
||||
name: str
|
||||
seed: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReplayCase:
|
||||
name: str
|
||||
seed: int
|
||||
control_mode: KernelMode
|
||||
verbosity: KernelVerbosity
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ControlCase:
|
||||
name: str
|
||||
mode: KernelMode
|
||||
verbosity: KernelVerbosity
|
||||
backend_mode: BackendMode
|
||||
trace_transitions: bool
|
||||
|
||||
|
||||
def _build_kernel(slot_count: int = 4) -> tuple[ExecutionKernel, MemoryKernelJournal, InMemoryZincPlane]:
|
||||
journal = MemoryKernelJournal()
|
||||
zinc = InMemoryZincPlane()
|
||||
kernel = ExecutionKernel(
|
||||
max_slots=slot_count,
|
||||
control_plane=InMemoryControlPlane(
|
||||
KernelControlSnapshot(
|
||||
mode=KernelMode.DEBUG,
|
||||
verbosity=KernelVerbosity.TRACE,
|
||||
backend_mode=BackendMode.MOCK,
|
||||
debug_clickhouse_enabled=True,
|
||||
trace_transitions=True,
|
||||
mirror_to_hazelcast=True,
|
||||
)
|
||||
),
|
||||
venue=NoopVenueAdapter(),
|
||||
journal=journal,
|
||||
account=AccountProjection(),
|
||||
zinc_plane=zinc,
|
||||
)
|
||||
return kernel, journal, zinc
|
||||
|
||||
|
||||
def _enter_open(kernel: ExecutionKernel, *, trade_id: str, slot_id: int = 0, size: float = 1.0) -> None:
|
||||
kernel.process_intent(
|
||||
KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id=f"{trade_id}:enter",
|
||||
trade_id=trade_id,
|
||||
slot_id=slot_id,
|
||||
asset="BTCUSDT",
|
||||
side=TradeSide.SHORT,
|
||||
action=KernelCommandType.ENTER,
|
||||
reference_price=100.0,
|
||||
target_size=size,
|
||||
leverage=2.0,
|
||||
exit_leg_ratios=(1.0,),
|
||||
reason="enter",
|
||||
)
|
||||
)
|
||||
slot = kernel.slot(slot_id)
|
||||
ack = 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=slot.active_entry_order.venue_order_id if slot.active_entry_order else "",
|
||||
venue_client_id=slot.active_entry_order.venue_client_id if slot.active_entry_order else "",
|
||||
side=TradeSide.SHORT,
|
||||
asset="BTCUSDT",
|
||||
price=100.0,
|
||||
size=size,
|
||||
filled_size=size,
|
||||
remaining_size=0.0,
|
||||
)
|
||||
kernel.on_venue_event(ack)
|
||||
|
||||
|
||||
def _seed_exit_working(kernel: ExecutionKernel, *, trade_id: str, slot_id: int = 0, exit_ratio: tuple[float, ...] = (1.0,)) -> None:
|
||||
_enter_open(kernel, trade_id=trade_id, slot_id=slot_id, size=1.0)
|
||||
slot = kernel.slot(slot_id)
|
||||
kernel.process_intent(
|
||||
KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id=f"{trade_id}:exit",
|
||||
trade_id=trade_id,
|
||||
slot_id=slot_id,
|
||||
asset=slot.asset,
|
||||
side=slot.side,
|
||||
action=KernelCommandType.EXIT,
|
||||
reference_price=99.0,
|
||||
target_size=exit_ratio[0],
|
||||
leverage=slot.leverage,
|
||||
exit_leg_ratios=exit_ratio,
|
||||
reason="exit",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _seed_exit_only_slot(
|
||||
slot: TradeSlot,
|
||||
*,
|
||||
trade_id: str,
|
||||
state: TradeStage,
|
||||
asset: str = "BTCUSDT",
|
||||
) -> None:
|
||||
slot.trade_id = trade_id
|
||||
slot.asset = asset
|
||||
slot.side = TradeSide.SHORT
|
||||
slot.entry_price = 100.0
|
||||
slot.initial_size = 1.0
|
||||
slot.size = 1.0 if state != TradeStage.CLOSED else 0.0
|
||||
slot.leverage = 2.0
|
||||
slot.closed = state == TradeStage.CLOSED
|
||||
slot.exit_leg_ratios = (1.0,)
|
||||
slot.active_leg_index = 0
|
||||
slot.active_entry_order = None
|
||||
slot.active_exit_order = None if state == TradeStage.CLOSED else VenueOrder(
|
||||
internal_trade_id=trade_id,
|
||||
venue_order_id="V-EXIT-1",
|
||||
venue_client_id=f"{trade_id}:exit",
|
||||
side=TradeSide.SHORT,
|
||||
intended_size=1.0,
|
||||
filled_size=0.0,
|
||||
average_fill_price=0.0,
|
||||
status=VenueOrderStatus.NEW,
|
||||
metadata={"slot_id": slot.slot_id, "asset": asset},
|
||||
)
|
||||
slot.fsm_state = state
|
||||
|
||||
|
||||
def _fill_event(slot: TradeSlot, *, kind: KernelEventKind, filled_size: float, trade_id: str | None = None) -> VenueEvent:
|
||||
return VenueEvent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
event_id=f"evt-{kind.value.lower()}-{slot.slot_id}",
|
||||
trade_id=trade_id or slot.trade_id,
|
||||
slot_id=slot.slot_id,
|
||||
kind=kind,
|
||||
status={
|
||||
KernelEventKind.ORDER_ACK: VenueEventStatus.ACKED,
|
||||
KernelEventKind.ORDER_REJECT: VenueEventStatus.REJECTED,
|
||||
KernelEventKind.PARTIAL_FILL: VenueEventStatus.PARTIALLY_FILLED,
|
||||
KernelEventKind.FULL_FILL: VenueEventStatus.FILLED,
|
||||
KernelEventKind.CANCEL_ACK: VenueEventStatus.CANCELED,
|
||||
KernelEventKind.CANCEL_REJECT: VenueEventStatus.CANCELED_REJECTED,
|
||||
KernelEventKind.MARK_PRICE: VenueEventStatus.ACKED,
|
||||
KernelEventKind.RECONCILE: VenueEventStatus.ACKED,
|
||||
}[kind],
|
||||
venue_order_id=slot.active_entry_order.venue_order_id if slot.active_entry_order else slot.active_exit_order.venue_order_id if slot.active_exit_order else "V-ORDER",
|
||||
venue_client_id=slot.active_entry_order.venue_client_id if slot.active_entry_order else slot.active_exit_order.venue_client_id if slot.active_exit_order else "trade:client",
|
||||
side=slot.side if slot.side != TradeSide.FLAT else TradeSide.SHORT,
|
||||
asset=slot.asset or "BTCUSDT",
|
||||
price=98.0 if kind == KernelEventKind.MARK_PRICE else 100.0,
|
||||
size=max(1.0, slot.size or 1.0),
|
||||
filled_size=filled_size,
|
||||
remaining_size=max(0.0, max(1.0, slot.size or 1.0) - filled_size),
|
||||
)
|
||||
|
||||
|
||||
RECOVERY_CASES = [
|
||||
RecoveryCase("idle_only", seed=1, slot_count=4, trade_count=1),
|
||||
RecoveryCase("one_open", seed=2, slot_count=4, trade_count=1),
|
||||
RecoveryCase("mixed_two", seed=3, slot_count=4, trade_count=2),
|
||||
RecoveryCase("mixed_three", seed=4, slot_count=4, trade_count=3),
|
||||
RecoveryCase("all_open", seed=5, slot_count=4, trade_count=4),
|
||||
RecoveryCase("open_and_closed", seed=6, slot_count=5, trade_count=4),
|
||||
RecoveryCase("exit_working", seed=7, slot_count=4, trade_count=2),
|
||||
RecoveryCase("position_open_with_gap", seed=8, slot_count=4, trade_count=3),
|
||||
]
|
||||
|
||||
|
||||
DUPLICATE_CASES = [
|
||||
DuplicateCase("ack_entry_duplicate_regressed", TradeStage.POSITION_OPEN, KernelEventKind.ORDER_ACK, "entry", TradeStage.POSITION_OPEN, KernelDiagnosticCode.DUPLICATE_EVENT),
|
||||
DuplicateCase("ack_exit_duplicate_hold", TradeStage.POSITION_OPEN, KernelEventKind.ORDER_ACK, "exit", TradeStage.EXIT_WORKING, KernelDiagnosticCode.OK),
|
||||
DuplicateCase("partial_entry_duplicate_stays", TradeStage.ENTRY_WORKING, KernelEventKind.PARTIAL_FILL, "entry", TradeStage.ENTRY_WORKING, KernelDiagnosticCode.OK),
|
||||
DuplicateCase("full_entry_duplicate_noop", TradeStage.POSITION_OPEN, KernelEventKind.FULL_FILL, "entry", TradeStage.POSITION_OPEN, KernelDiagnosticCode.OK),
|
||||
DuplicateCase("partial_exit_duplicate_stays", TradeStage.EXIT_WORKING, KernelEventKind.PARTIAL_FILL, "exit", TradeStage.EXIT_WORKING, KernelDiagnosticCode.OK),
|
||||
DuplicateCase("full_exit_duplicate_closes", TradeStage.CLOSED, KernelEventKind.FULL_FILL, "exit", TradeStage.CLOSED, KernelDiagnosticCode.OK),
|
||||
DuplicateCase("cancel_ack_duplicate_open", TradeStage.POSITION_OPEN, KernelEventKind.CANCEL_ACK, "exit", TradeStage.POSITION_OPEN, KernelDiagnosticCode.OK),
|
||||
DuplicateCase("cancel_reject_duplicate_exit", TradeStage.EXIT_WORKING, KernelEventKind.CANCEL_REJECT, "exit", TradeStage.EXIT_WORKING, KernelDiagnosticCode.CANCEL_REJECTED),
|
||||
]
|
||||
|
||||
|
||||
OUT_OF_ORDER_CASES = [OutOfOrderCase(f"seed_{seed}", seed=seed) for seed in range(12)]
|
||||
|
||||
|
||||
REPLAY_CASES = [
|
||||
ReplayCase(f"replay_{seed}", seed=seed, control_mode=KernelMode.DEBUG if seed % 2 == 0 else KernelMode.NORMAL, verbosity=KernelVerbosity.TRACE if seed % 3 == 0 else KernelVerbosity.VERBOSE)
|
||||
for seed in range(12)
|
||||
]
|
||||
|
||||
|
||||
CONTROL_CASES = [
|
||||
ControlCase("normal_quiet_mock", KernelMode.NORMAL, KernelVerbosity.QUIET, BackendMode.MOCK, False),
|
||||
ControlCase("normal_trace_mock", KernelMode.NORMAL, KernelVerbosity.TRACE, BackendMode.MOCK, True),
|
||||
ControlCase("debug_trace_mock", KernelMode.DEBUG, KernelVerbosity.TRACE, BackendMode.MOCK, True),
|
||||
ControlCase("debug_verbose_bingx", KernelMode.DEBUG, KernelVerbosity.VERBOSE, BackendMode.BINGX, False),
|
||||
ControlCase("normal_verbose_bingx", KernelMode.NORMAL, KernelVerbosity.VERBOSE, BackendMode.BINGX, True),
|
||||
ControlCase("debug_quiet_bingx", KernelMode.DEBUG, KernelVerbosity.QUIET, BackendMode.BINGX, False),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", CONTROL_CASES, ids=[case.name for case in CONTROL_CASES])
|
||||
def test_kernel_zinc_control_plane_mirror(case: ControlCase) -> None:
|
||||
kernel, journal, zinc = _build_kernel()
|
||||
snapshot = kernel.update_control(
|
||||
ControlUpdate(
|
||||
mode=case.mode,
|
||||
verbosity=case.verbosity,
|
||||
backend_mode=case.backend_mode,
|
||||
trace_transitions=case.trace_transitions,
|
||||
)
|
||||
)
|
||||
assert snapshot.mode == case.mode
|
||||
assert snapshot.verbosity == case.verbosity
|
||||
assert snapshot.backend_mode == case.backend_mode
|
||||
assert snapshot.trace_transitions == case.trace_transitions
|
||||
assert zinc.read_control().mode == case.mode
|
||||
assert zinc.read_control().verbosity == case.verbosity
|
||||
assert journal.rows == []
|
||||
assert kernel.zinc_plane.read_control().mode == case.mode
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", RECOVERY_CASES, ids=[case.name for case in RECOVERY_CASES])
|
||||
def test_kernel_zinc_restart_recovery_matrix(case: RecoveryCase) -> None:
|
||||
kernel, _, zinc = _build_kernel(slot_count=case.slot_count)
|
||||
rng = random.Random(case.seed)
|
||||
|
||||
for idx in range(case.trade_count):
|
||||
slot_id = idx % case.slot_count
|
||||
_enter_open(kernel, trade_id=f"{case.name}-{idx}", slot_id=slot_id, size=1.0)
|
||||
if rng.random() < 0.5:
|
||||
_seed_exit_working(kernel, trade_id=f"{case.name}-{idx}", slot_id=slot_id, exit_ratio=(0.5, 0.5))
|
||||
if rng.random() < 0.5:
|
||||
kernel.process_intent(
|
||||
KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id=f"{case.name}-{idx}:mark",
|
||||
trade_id=f"{case.name}-{idx}",
|
||||
slot_id=slot_id,
|
||||
asset="BTCUSDT",
|
||||
side=TradeSide.SHORT,
|
||||
action=KernelCommandType.MARK_PRICE,
|
||||
reference_price=98.0,
|
||||
target_size=1.0,
|
||||
leverage=2.0,
|
||||
exit_leg_ratios=(1.0,),
|
||||
reason="mark",
|
||||
)
|
||||
)
|
||||
|
||||
snapshot_slots = zinc.read_slots()
|
||||
assert len(snapshot_slots) == case.trade_count
|
||||
|
||||
restarted, _, restarted_zinc = _build_kernel(slot_count=case.slot_count)
|
||||
outcome = restarted.reconcile_from_slots(snapshot_slots)
|
||||
assert outcome.accepted is True
|
||||
assert outcome.diagnostic_code == KernelDiagnosticCode.RECONCILED
|
||||
assert restarted.snapshot()["slots"] == kernel.snapshot()["slots"]
|
||||
assert [slot.to_dict() for slot in restarted_zinc.read_slots()] == restarted.snapshot()["slots"]
|
||||
assert restarted.account.snapshot.open_positions == kernel.account.snapshot.open_positions
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", DUPLICATE_CASES, ids=[case.name for case in DUPLICATE_CASES])
|
||||
def test_kernel_duplicate_event_idempotence_matrix(case: DuplicateCase) -> None:
|
||||
kernel, journal, zinc = _build_kernel()
|
||||
slot = kernel.slot(0)
|
||||
filled_size = 1.0 if case.kind == KernelEventKind.FULL_FILL else 0.25
|
||||
|
||||
if case.family == "entry":
|
||||
_enter_open(kernel, trade_id="dup-entry", slot_id=0, size=1.0)
|
||||
if case.kind == KernelEventKind.ORDER_ACK and case.initial_state == TradeStage.POSITION_OPEN:
|
||||
slot.active_entry_order = VenueOrder(
|
||||
internal_trade_id="dup-entry",
|
||||
venue_order_id="V-ENTRY-1",
|
||||
venue_client_id="dup-entry:entry",
|
||||
side=TradeSide.SHORT,
|
||||
intended_size=1.0,
|
||||
filled_size=1.0,
|
||||
average_fill_price=100.0,
|
||||
status=VenueOrderStatus.FILLED,
|
||||
metadata={"slot_id": 0, "asset": "BTCUSDT"},
|
||||
)
|
||||
slot.fsm_state = TradeStage.POSITION_OPEN
|
||||
else:
|
||||
_seed_exit_only_slot(slot, trade_id="dup-exit", state=case.initial_state)
|
||||
|
||||
before = slot.to_dict()
|
||||
event = _fill_event(
|
||||
slot,
|
||||
kind=case.kind,
|
||||
filled_size=filled_size,
|
||||
trade_id=slot.trade_id,
|
||||
)
|
||||
outcome_1 = kernel.on_venue_event(event)
|
||||
state_after_first = slot.fsm_state
|
||||
size_after_first = slot.size
|
||||
outcome_2 = kernel.on_venue_event(event)
|
||||
|
||||
assert outcome_1.diagnostic_code in set(KernelDiagnosticCode)
|
||||
assert outcome_2.diagnostic_code in set(KernelDiagnosticCode)
|
||||
assert slot.fsm_state == case.expected_state
|
||||
assert slot.fsm_state == state_after_first
|
||||
assert slot.size == pytest.approx(size_after_first, abs=1e-9)
|
||||
assert slot.size >= 0.0
|
||||
assert zinc.state_region[0].fsm_state == slot.fsm_state
|
||||
assert len(journal.rows) >= 1
|
||||
assert before["slot_id"] == slot.slot_id
|
||||
if case.expected_code == KernelDiagnosticCode.DUPLICATE_EVENT:
|
||||
assert outcome_2.diagnostic_code == KernelDiagnosticCode.DUPLICATE_EVENT
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", OUT_OF_ORDER_CASES, ids=[case.name for case in OUT_OF_ORDER_CASES])
|
||||
def test_kernel_out_of_order_venue_event_matrix(case: OutOfOrderCase) -> None:
|
||||
kernel, journal, zinc = _build_kernel()
|
||||
rng = random.Random(case.seed)
|
||||
|
||||
if rng.random() < 0.5:
|
||||
_enter_open(kernel, trade_id=f"ooo-{case.seed}", slot_id=0, size=1.0)
|
||||
else:
|
||||
_seed_exit_working(kernel, trade_id=f"ooo-{case.seed}", slot_id=0, exit_ratio=(0.5, 0.5))
|
||||
|
||||
slot = kernel.slot(0)
|
||||
sequence = [
|
||||
KernelEventKind.FULL_FILL,
|
||||
KernelEventKind.ORDER_ACK,
|
||||
KernelEventKind.PARTIAL_FILL,
|
||||
KernelEventKind.CANCEL_ACK,
|
||||
KernelEventKind.CANCEL_REJECT,
|
||||
KernelEventKind.ORDER_REJECT,
|
||||
KernelEventKind.MARK_PRICE,
|
||||
]
|
||||
rng.shuffle(sequence)
|
||||
|
||||
for idx, kind in enumerate(sequence):
|
||||
event = _fill_event(
|
||||
slot,
|
||||
kind=kind,
|
||||
filled_size=1.0 if kind == KernelEventKind.FULL_FILL else 0.5,
|
||||
trade_id=slot.trade_id,
|
||||
)
|
||||
event = VenueEvent(
|
||||
**{
|
||||
**event.__dict__,
|
||||
"event_id": f"ooo-{case.seed}-{idx}",
|
||||
"slot_id": 0 if rng.random() < 0.5 else 99,
|
||||
"venue_order_id": slot.active_entry_order.venue_order_id if slot.active_entry_order else slot.active_exit_order.venue_order_id if slot.active_exit_order else event.venue_order_id,
|
||||
"venue_client_id": slot.active_entry_order.venue_client_id if slot.active_entry_order else slot.active_exit_order.venue_client_id if slot.active_exit_order else event.venue_client_id,
|
||||
}
|
||||
)
|
||||
outcome = kernel.on_venue_event(event)
|
||||
assert isinstance(outcome.diagnostic_code, KernelDiagnosticCode)
|
||||
assert slot.size >= 0.0
|
||||
assert slot.initial_size >= 0.0
|
||||
assert slot.fsm_state in set(TradeStage)
|
||||
if slot.closed:
|
||||
assert slot.size == pytest.approx(0.0, abs=1e-9)
|
||||
|
||||
assert len(journal.rows) >= len(sequence)
|
||||
assert len(zinc.state_region) >= 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", REPLAY_CASES, ids=[case.name for case in REPLAY_CASES])
|
||||
def test_kernel_debug_journal_replay_matrix(case: ReplayCase) -> None:
|
||||
kernel, journal, zinc = _build_kernel()
|
||||
kernel.update_control(
|
||||
ControlUpdate(
|
||||
mode=case.control_mode,
|
||||
verbosity=case.verbosity,
|
||||
trace_transitions=True,
|
||||
debug_clickhouse_enabled=True,
|
||||
)
|
||||
)
|
||||
|
||||
rng = random.Random(case.seed)
|
||||
for idx in range(10):
|
||||
slot_id = idx % 2
|
||||
trade_id = f"{case.name}-{idx}"
|
||||
if idx % 3 == 0:
|
||||
_enter_open(kernel, trade_id=trade_id, slot_id=slot_id, size=1.0)
|
||||
elif idx % 3 == 1 and kernel.slot(slot_id).is_open():
|
||||
_seed_exit_working(kernel, trade_id=kernel.slot(slot_id).trade_id, slot_id=slot_id, exit_ratio=(0.5, 0.5))
|
||||
else:
|
||||
kernel.process_intent(
|
||||
KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id=f"{trade_id}:mark",
|
||||
trade_id=trade_id,
|
||||
slot_id=slot_id,
|
||||
asset="BTCUSDT",
|
||||
side=TradeSide.SHORT,
|
||||
action=KernelCommandType.MARK_PRICE,
|
||||
reference_price=97.0 + rng.random(),
|
||||
target_size=1.0,
|
||||
leverage=2.0,
|
||||
exit_leg_ratios=(1.0,),
|
||||
reason="journal-mark",
|
||||
)
|
||||
)
|
||||
|
||||
rows = list(journal.rows)
|
||||
assert rows
|
||||
for row in rows:
|
||||
slot_state = row["slot_state"]
|
||||
assert row["prev_state"] != ""
|
||||
assert row["next_state"] != ""
|
||||
assert slot_state["fsm_state"] == row["next_state"]
|
||||
assert row["control_mode"] in {KernelMode.NORMAL.value, KernelMode.DEBUG.value}
|
||||
assert row["control_verbosity"] in {KernelVerbosity.QUIET.value, KernelVerbosity.VERBOSE.value, KernelVerbosity.TRACE.value}
|
||||
|
||||
replayed, _, replayed_zinc = _build_kernel()
|
||||
replayed.update_control(
|
||||
ControlUpdate(mode=case.control_mode, verbosity=case.verbosity, trace_transitions=True, debug_clickhouse_enabled=True)
|
||||
)
|
||||
replayed.reconcile_from_slots(zinc.read_slots())
|
||||
assert replayed.snapshot()["slots"] == kernel.snapshot()["slots"]
|
||||
assert len(replayed_zinc.read_slots()) == len(replayed.snapshot()["slots"])
|
||||
assert [slot.slot_id for slot in replayed_zinc.read_slots()] == [slot.slot_id for slot in replayed.state.slots]
|
||||
437
prod/tests/test_dita_v2_kernel_state_machine_races.py
Normal file
437
prod/tests/test_dita_v2_kernel_state_machine_races.py
Normal file
@@ -0,0 +1,437 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from prod.clean_arch.dita_v2 import (
|
||||
BackendMode,
|
||||
ControlUpdate,
|
||||
ExecutionKernel,
|
||||
InMemoryControlPlane,
|
||||
InMemoryZincPlane,
|
||||
KernelCommandType,
|
||||
KernelDiagnosticCode,
|
||||
KernelEventKind,
|
||||
KernelIntent,
|
||||
KernelMode,
|
||||
KernelControlSnapshot,
|
||||
KernelVerbosity,
|
||||
MemoryKernelJournal,
|
||||
TradeSide,
|
||||
TradeSlot,
|
||||
TradeStage,
|
||||
VenueEvent,
|
||||
VenueEventStatus,
|
||||
VenueOrder,
|
||||
VenueOrderStatus,
|
||||
)
|
||||
|
||||
|
||||
class NoopVenueAdapter:
|
||||
def submit(self, intent: KernelIntent): # type: ignore[override]
|
||||
return []
|
||||
|
||||
def cancel(self, order: VenueOrder, *, reason: str = ""): # type: ignore[override]
|
||||
return []
|
||||
|
||||
def open_orders(self): # type: ignore[override]
|
||||
return []
|
||||
|
||||
def open_positions(self): # type: ignore[override]
|
||||
return []
|
||||
|
||||
def reconcile(self): # type: ignore[override]
|
||||
return []
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RaceCase:
|
||||
name: str
|
||||
seed_state: str
|
||||
first_kind: KernelEventKind
|
||||
second_kind: KernelEventKind
|
||||
expected_state: TradeStage
|
||||
expected_code_2: KernelDiagnosticCode
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OffByOneCase:
|
||||
name: str
|
||||
exit_leg_ratios: tuple[float, ...]
|
||||
fills: tuple[float, ...]
|
||||
expected_leg_index: int
|
||||
expected_closed: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MemoryCase:
|
||||
name: str
|
||||
max_slots: int
|
||||
write_slot_ids: tuple[int, ...]
|
||||
reconcile_slot_ids: tuple[int, ...]
|
||||
expected_written_count: int
|
||||
|
||||
|
||||
def _build_kernel(slot_count: int = 4) -> tuple[ExecutionKernel, MemoryKernelJournal, InMemoryZincPlane]:
|
||||
journal = MemoryKernelJournal()
|
||||
zinc = InMemoryZincPlane()
|
||||
kernel = ExecutionKernel(
|
||||
max_slots=slot_count,
|
||||
control_plane=InMemoryControlPlane(
|
||||
KernelControlSnapshot(
|
||||
mode=KernelMode.DEBUG,
|
||||
verbosity=KernelVerbosity.TRACE,
|
||||
backend_mode=BackendMode.MOCK,
|
||||
trace_transitions=True,
|
||||
debug_clickhouse_enabled=True,
|
||||
mirror_to_hazelcast=True,
|
||||
)
|
||||
),
|
||||
venue=NoopVenueAdapter(),
|
||||
journal=journal,
|
||||
zinc_plane=zinc,
|
||||
)
|
||||
return kernel, journal, zinc
|
||||
|
||||
|
||||
def _seed_entry_working(kernel: ExecutionKernel, *, trade_id: str, slot_id: int = 0, size: float = 1.0) -> None:
|
||||
slot = kernel.slot(slot_id)
|
||||
slot.trade_id = trade_id
|
||||
slot.asset = "BTCUSDT"
|
||||
slot.side = TradeSide.SHORT
|
||||
slot.entry_price = 100.0
|
||||
slot.size = 0.0
|
||||
slot.initial_size = 0.0
|
||||
slot.leverage = 2.0
|
||||
slot.entry_time = datetime.now(timezone.utc)
|
||||
slot.exit_leg_ratios = (1.0,)
|
||||
slot.active_leg_index = 0
|
||||
slot.closed = False
|
||||
slot.close_reason = ""
|
||||
slot.active_exit_order = None
|
||||
slot.active_entry_order = VenueOrder(
|
||||
internal_trade_id=trade_id,
|
||||
venue_order_id=f"V-ENTRY-{slot_id}",
|
||||
venue_client_id=f"{trade_id}:entry",
|
||||
side=TradeSide.SHORT,
|
||||
intended_size=size,
|
||||
filled_size=0.0,
|
||||
average_fill_price=0.0,
|
||||
status=VenueOrderStatus.NEW,
|
||||
metadata={"slot_id": slot_id},
|
||||
)
|
||||
slot.fsm_state = TradeStage.ENTRY_WORKING
|
||||
|
||||
|
||||
def _seed_position_open(kernel: ExecutionKernel, *, trade_id: str, slot_id: int = 0, size: float = 1.0) -> None:
|
||||
_seed_entry_working(kernel, trade_id=trade_id, slot_id=slot_id, size=size)
|
||||
slot = kernel.slot(slot_id)
|
||||
slot.size = size
|
||||
slot.initial_size = size
|
||||
slot.entry_price = 100.0
|
||||
slot.active_entry_order = VenueOrder(
|
||||
internal_trade_id=trade_id,
|
||||
venue_order_id=f"V-ENTRY-{slot_id}",
|
||||
venue_client_id=f"{trade_id}:entry",
|
||||
side=TradeSide.SHORT,
|
||||
intended_size=size,
|
||||
filled_size=size,
|
||||
average_fill_price=100.0,
|
||||
status=VenueOrderStatus.FILLED,
|
||||
metadata={"slot_id": slot_id},
|
||||
)
|
||||
slot.fsm_state = TradeStage.POSITION_OPEN
|
||||
|
||||
|
||||
def _seed_exit_working(kernel: ExecutionKernel, *, trade_id: str, slot_id: int = 0, exit_leg_ratios: tuple[float, ...] = (1.0,)) -> None:
|
||||
_seed_position_open(kernel, trade_id=trade_id, slot_id=slot_id, size=1.0)
|
||||
slot = kernel.slot(slot_id)
|
||||
slot.exit_leg_ratios = exit_leg_ratios
|
||||
slot.active_exit_order = VenueOrder(
|
||||
internal_trade_id=trade_id,
|
||||
venue_order_id=f"V-EXIT-{slot_id}",
|
||||
venue_client_id=f"{trade_id}:exit",
|
||||
side=TradeSide.SHORT,
|
||||
intended_size=slot.next_exit_ratio() * slot.initial_size,
|
||||
filled_size=0.0,
|
||||
average_fill_price=0.0,
|
||||
status=VenueOrderStatus.NEW,
|
||||
metadata={"slot_id": slot_id},
|
||||
)
|
||||
slot.fsm_state = TradeStage.EXIT_WORKING
|
||||
|
||||
|
||||
def _make_event(
|
||||
slot: TradeSlot,
|
||||
*,
|
||||
kind: KernelEventKind,
|
||||
event_id: str,
|
||||
filled_size: float,
|
||||
slot_id: int | None = None,
|
||||
venue_order_id: str | None = None,
|
||||
venue_client_id: str | None = None,
|
||||
reason: str = "",
|
||||
) -> VenueEvent:
|
||||
order_id = venue_order_id or (
|
||||
slot.active_exit_order.venue_order_id if slot.active_exit_order else slot.active_entry_order.venue_order_id if slot.active_entry_order else "V-ORDER"
|
||||
)
|
||||
client_id = venue_client_id or (
|
||||
slot.active_exit_order.venue_client_id if slot.active_exit_order else slot.active_entry_order.venue_client_id if slot.active_entry_order else "trade:client"
|
||||
)
|
||||
return VenueEvent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
event_id=event_id,
|
||||
trade_id=slot.trade_id,
|
||||
slot_id=slot.slot_id if slot_id is None else slot_id,
|
||||
kind=kind,
|
||||
status={
|
||||
KernelEventKind.ORDER_ACK: VenueEventStatus.ACKED,
|
||||
KernelEventKind.ORDER_REJECT: VenueEventStatus.REJECTED,
|
||||
KernelEventKind.PARTIAL_FILL: VenueEventStatus.PARTIALLY_FILLED,
|
||||
KernelEventKind.FULL_FILL: VenueEventStatus.FILLED,
|
||||
KernelEventKind.CANCEL_ACK: VenueEventStatus.CANCELED,
|
||||
KernelEventKind.CANCEL_REJECT: VenueEventStatus.CANCELED_REJECTED,
|
||||
KernelEventKind.MARK_PRICE: VenueEventStatus.ACKED,
|
||||
KernelEventKind.RECONCILE: VenueEventStatus.ACKED,
|
||||
}[kind],
|
||||
venue_order_id=order_id,
|
||||
venue_client_id=client_id,
|
||||
side=slot.side if slot.side != TradeSide.FLAT else TradeSide.SHORT,
|
||||
asset=slot.asset or "BTCUSDT",
|
||||
price=99.0 if kind == KernelEventKind.MARK_PRICE else 100.0,
|
||||
size=max(1.0, slot.size or 1.0),
|
||||
filled_size=filled_size,
|
||||
remaining_size=max(0.0, max(1.0, slot.size or 1.0) - filled_size),
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
|
||||
RACE_CASES = [
|
||||
RaceCase("entry_ack_then_fullfill", "entry_working", KernelEventKind.ORDER_ACK, KernelEventKind.FULL_FILL, TradeStage.POSITION_OPEN, KernelDiagnosticCode.OK),
|
||||
RaceCase("entry_fullfill_then_ack", "entry_working", KernelEventKind.FULL_FILL, KernelEventKind.ORDER_ACK, TradeStage.POSITION_OPEN, KernelDiagnosticCode.DUPLICATE_EVENT),
|
||||
RaceCase("entry_ack_then_reject", "entry_working", KernelEventKind.ORDER_ACK, KernelEventKind.ORDER_REJECT, TradeStage.IDLE, KernelDiagnosticCode.ENTRY_ORDER_REJECTED),
|
||||
RaceCase("entry_reject_then_ack", "entry_working", KernelEventKind.ORDER_REJECT, KernelEventKind.ORDER_ACK, TradeStage.IDLE, KernelDiagnosticCode.OK),
|
||||
RaceCase("entry_mark_then_fullfill", "entry_working", KernelEventKind.MARK_PRICE, KernelEventKind.FULL_FILL, TradeStage.POSITION_OPEN, KernelDiagnosticCode.OK),
|
||||
RaceCase("entry_reconcile_then_ack", "entry_working", KernelEventKind.RECONCILE, KernelEventKind.ORDER_ACK, TradeStage.STALE_STATE_RECONCILING, KernelDiagnosticCode.STALE_STATE_RECONCILE),
|
||||
RaceCase("exit_ack_then_fullfill", "exit_working", KernelEventKind.ORDER_ACK, KernelEventKind.FULL_FILL, TradeStage.CLOSED, KernelDiagnosticCode.OK),
|
||||
RaceCase("exit_fullfill_then_ack", "exit_working", KernelEventKind.FULL_FILL, KernelEventKind.ORDER_ACK, TradeStage.CLOSED, KernelDiagnosticCode.OK),
|
||||
RaceCase("exit_cancel_ack_then_fullfill", "exit_working", KernelEventKind.CANCEL_ACK, KernelEventKind.FULL_FILL, TradeStage.POSITION_OPEN, KernelDiagnosticCode.OK),
|
||||
RaceCase("exit_fullfill_then_cancel_ack", "exit_working", KernelEventKind.FULL_FILL, KernelEventKind.CANCEL_ACK, TradeStage.CLOSED, KernelDiagnosticCode.OK),
|
||||
RaceCase("exit_cancel_reject_then_ack", "exit_working", KernelEventKind.CANCEL_REJECT, KernelEventKind.CANCEL_ACK, TradeStage.POSITION_OPEN, KernelDiagnosticCode.OK),
|
||||
RaceCase("exit_mark_then_fullfill", "exit_working", KernelEventKind.MARK_PRICE, KernelEventKind.FULL_FILL, TradeStage.CLOSED, KernelDiagnosticCode.OK),
|
||||
]
|
||||
|
||||
|
||||
OFF_BY_ONE_CASES = [
|
||||
OffByOneCase("ratios_empty", (), (), 0, False),
|
||||
OffByOneCase("ratios_one", (1.0,), (1.0,), 1, True),
|
||||
OffByOneCase("ratios_two_equal", (0.5, 0.5), (0.5, 0.5), 2, True),
|
||||
OffByOneCase("ratios_three_tail", (0.25, 0.25, 0.5), (0.25, 0.25, 0.5), 3, True),
|
||||
OffByOneCase("ratios_three_front_loaded", (0.6, 0.3, 0.1), (0.6, 0.3, 0.1), 3, True),
|
||||
OffByOneCase("ratios_four_small", (0.1, 0.2, 0.3, 0.4), (0.1, 0.2, 0.3, 0.4), 4, True),
|
||||
]
|
||||
|
||||
|
||||
MEMORY_CASES = [
|
||||
MemoryCase("sparse_write_order", 5, (3, 1, 4), (3, 1, 4), 3),
|
||||
MemoryCase("overwrite_same_slot", 4, (2, 2, 2), (2,), 1),
|
||||
MemoryCase("capacity_trim", 3, (0, 1, 2, 3, 4), (0, 1, 2), 3),
|
||||
MemoryCase("single_slot_reconcile", 2, (1,), (1,), 1),
|
||||
MemoryCase("mixed_holes", 6, (5, 0, 3), (5, 0, 3), 3),
|
||||
MemoryCase("late_slot_overwrite", 4, (1, 3, 1), (1, 3), 2),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", RACE_CASES, ids=[case.name for case in RACE_CASES])
|
||||
def test_kernel_race_and_reorder_matrix(case: RaceCase) -> None:
|
||||
kernel, journal, zinc = _build_kernel()
|
||||
if case.seed_state == "entry_working":
|
||||
_seed_entry_working(kernel, trade_id=f"race-{case.name}")
|
||||
elif case.seed_state == "exit_working":
|
||||
_seed_exit_working(kernel, trade_id=f"race-{case.name}")
|
||||
else:
|
||||
_seed_position_open(kernel, trade_id=f"race-{case.name}")
|
||||
|
||||
slot = kernel.slot(0)
|
||||
first = _make_event(
|
||||
slot,
|
||||
kind=case.first_kind,
|
||||
event_id=f"{case.name}-first",
|
||||
filled_size=1.0 if case.first_kind == KernelEventKind.FULL_FILL else 0.5,
|
||||
)
|
||||
second = _make_event(
|
||||
slot,
|
||||
kind=case.second_kind,
|
||||
event_id=f"{case.name}-second",
|
||||
filled_size=1.0 if case.second_kind == KernelEventKind.FULL_FILL else 0.5,
|
||||
)
|
||||
|
||||
outcome_1 = kernel.on_venue_event(first)
|
||||
outcome_2 = kernel.on_venue_event(second)
|
||||
|
||||
assert outcome_1.diagnostic_code in set(KernelDiagnosticCode)
|
||||
assert outcome_2.diagnostic_code in set(KernelDiagnosticCode)
|
||||
assert slot.size >= 0.0
|
||||
assert slot.initial_size >= 0.0
|
||||
assert slot.fsm_state == case.expected_state
|
||||
assert outcome_2.diagnostic_code == case.expected_code_2
|
||||
assert zinc.state_region[0].fsm_state == slot.fsm_state
|
||||
assert len(journal.rows) >= 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", OFF_BY_ONE_CASES, ids=[case.name for case in OFF_BY_ONE_CASES])
|
||||
def test_kernel_exit_leg_off_by_one_matrix(case: OffByOneCase) -> None:
|
||||
kernel, journal, zinc = _build_kernel()
|
||||
_seed_exit_working(kernel, trade_id=f"obo-{case.name}", exit_leg_ratios=case.exit_leg_ratios)
|
||||
slot = kernel.slot(0)
|
||||
|
||||
assert slot.next_exit_ratio() == pytest.approx(case.exit_leg_ratios[0] if case.exit_leg_ratios else 1.0, abs=1e-9)
|
||||
|
||||
for idx, fill_size in enumerate(case.fills):
|
||||
event = _make_event(
|
||||
slot,
|
||||
kind=KernelEventKind.FULL_FILL,
|
||||
event_id=f"{case.name}-fill-{idx}",
|
||||
filled_size=fill_size,
|
||||
reason=f"leg-{idx}",
|
||||
)
|
||||
outcome = kernel.on_venue_event(event)
|
||||
assert outcome.accepted is True
|
||||
assert slot.size >= 0.0
|
||||
assert slot.active_leg_index <= max(len(case.exit_leg_ratios), 1)
|
||||
if idx < len(case.fills) - 1:
|
||||
assert slot.fsm_state == TradeStage.POSITION_OPEN
|
||||
rearm = kernel.process_intent(
|
||||
KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id=f"{case.name}-rearm-{idx}",
|
||||
trade_id=slot.trade_id,
|
||||
slot_id=slot.slot_id,
|
||||
asset=slot.asset,
|
||||
side=slot.side,
|
||||
action=KernelCommandType.EXIT,
|
||||
reference_price=100.0,
|
||||
target_size=slot.next_exit_ratio(),
|
||||
leverage=slot.leverage,
|
||||
exit_leg_ratios=case.exit_leg_ratios,
|
||||
reason=f"rearm-{idx}",
|
||||
)
|
||||
)
|
||||
assert rearm.accepted is True
|
||||
assert slot.fsm_state == TradeStage.EXIT_REQUESTED
|
||||
else:
|
||||
assert slot.fsm_state in {TradeStage.POSITION_OPEN, TradeStage.CLOSED}
|
||||
|
||||
assert slot.active_leg_index == case.expected_leg_index
|
||||
assert slot.closed is case.expected_closed
|
||||
if case.fills:
|
||||
assert zinc.state_region[0].active_leg_index == slot.active_leg_index
|
||||
else:
|
||||
assert zinc.state_region[0].trade_id == slot.trade_id
|
||||
assert zinc.state_region[0].fsm_state == TradeStage.EXIT_WORKING
|
||||
assert len(journal.rows) >= len(case.fills)
|
||||
assert slot.next_exit_ratio() == pytest.approx(1.0, abs=1e-9) if case.expected_closed else slot.next_exit_ratio() <= 1.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", MEMORY_CASES, ids=[case.name for case in MEMORY_CASES])
|
||||
def test_kernel_zinc_memory_anomaly_matrix(case: MemoryCase) -> None:
|
||||
kernel, journal, zinc = _build_kernel(slot_count=case.max_slots)
|
||||
rng = random.Random(hash(case.name) & 0xFFFFFFFF)
|
||||
|
||||
for idx, slot_id in enumerate(case.write_slot_ids):
|
||||
slot = kernel.slot(slot_id % case.max_slots)
|
||||
slot.trade_id = f"{case.name}-{idx}"
|
||||
slot.asset = "BTCUSDT"
|
||||
slot.side = TradeSide.SHORT
|
||||
slot.entry_price = 100.0
|
||||
slot.size = float(idx + 1)
|
||||
slot.initial_size = float(idx + 1)
|
||||
slot.leverage = 2.0
|
||||
slot.fsm_state = TradeStage.POSITION_OPEN if idx % 2 == 0 else TradeStage.EXIT_WORKING
|
||||
slot.active_entry_order = VenueOrder(
|
||||
internal_trade_id=slot.trade_id,
|
||||
venue_order_id=f"V-ENTRY-{slot_id}-{idx}",
|
||||
venue_client_id=f"{slot.trade_id}:entry",
|
||||
side=TradeSide.SHORT,
|
||||
intended_size=slot.size,
|
||||
filled_size=slot.size,
|
||||
average_fill_price=100.0,
|
||||
status=VenueOrderStatus.FILLED,
|
||||
metadata={"slot_id": slot.slot_id},
|
||||
)
|
||||
if slot.fsm_state == TradeStage.EXIT_WORKING:
|
||||
slot.active_exit_order = VenueOrder(
|
||||
internal_trade_id=slot.trade_id,
|
||||
venue_order_id=f"V-EXIT-{slot_id}-{idx}",
|
||||
venue_client_id=f"{slot.trade_id}:exit",
|
||||
side=TradeSide.SHORT,
|
||||
intended_size=max(0.1, slot.size / 2.0),
|
||||
filled_size=0.0,
|
||||
average_fill_price=0.0,
|
||||
status=VenueOrderStatus.NEW,
|
||||
metadata={"slot_id": slot.slot_id},
|
||||
)
|
||||
kernel.zinc_plane.write_slot(slot)
|
||||
|
||||
written = zinc.read_slots()
|
||||
assert len(written) == case.expected_written_count
|
||||
assert [slot.slot_id for slot in written] == sorted(set(slot_id % case.max_slots for slot_id in case.write_slot_ids))
|
||||
|
||||
# Shuffle a snapshot and reconcile it back into a fresh kernel to exercise
|
||||
# sparse, duplicate and truncated memory layouts without venue involvement.
|
||||
shuffled_snapshot = list(reversed(written))
|
||||
if rng.random() < 0.5:
|
||||
shuffled_snapshot.append(shuffled_snapshot[0])
|
||||
|
||||
restarted, restarted_journal, restarted_zinc = _build_kernel(slot_count=case.max_slots)
|
||||
outcome = restarted.reconcile_from_slots(shuffled_snapshot)
|
||||
assert outcome.accepted is True
|
||||
assert outcome.diagnostic_code == KernelDiagnosticCode.RECONCILED
|
||||
assert len(restarted_zinc.read_slots()) == case.max_slots
|
||||
assert restarted.snapshot()["slots"] == [slot.to_dict() for slot in restarted_zinc.read_slots()]
|
||||
assert len(restarted_journal.rows) == 0
|
||||
|
||||
# Feed a stale-state event against a reconstructed slot to ensure the kernel
|
||||
# stays stable even when the memory image is awkward.
|
||||
target_slot_id = restarted_zinc.read_slots()[0].slot_id
|
||||
slot = restarted.slot(target_slot_id)
|
||||
stale_intent = restarted.process_intent(
|
||||
KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id=f"{case.name}:stale",
|
||||
trade_id=slot.trade_id,
|
||||
slot_id=slot.slot_id,
|
||||
asset=slot.asset,
|
||||
side=slot.side,
|
||||
action=KernelCommandType.RECONCILE,
|
||||
reference_price=100.0,
|
||||
target_size=max(1.0, slot.size or 1.0),
|
||||
leverage=max(1.0, slot.leverage or 1.0),
|
||||
exit_leg_ratios=slot.exit_leg_ratios,
|
||||
reason="stale-reconcile",
|
||||
)
|
||||
)
|
||||
assert stale_intent.diagnostic_code == KernelDiagnosticCode.STALE_STATE_RECONCILE
|
||||
assert slot.fsm_state == TradeStage.STALE_STATE_RECONCILING
|
||||
event = VenueEvent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
event_id=f"{case.name}-reconcile",
|
||||
trade_id=slot.trade_id,
|
||||
slot_id=slot.slot_id,
|
||||
kind=KernelEventKind.RECONCILE,
|
||||
status=VenueEventStatus.ACKED,
|
||||
venue_order_id=slot.active_exit_order.venue_order_id if slot.active_exit_order else slot.active_entry_order.venue_order_id if slot.active_entry_order else "V-ORDER",
|
||||
venue_client_id=slot.active_exit_order.venue_client_id if slot.active_exit_order else slot.active_entry_order.venue_client_id if slot.active_entry_order else "V-CLIENT",
|
||||
side=slot.side if slot.side != TradeSide.FLAT else TradeSide.SHORT,
|
||||
asset=slot.asset or "BTCUSDT",
|
||||
price=100.0,
|
||||
size=max(1.0, slot.size or 1.0),
|
||||
filled_size=0.0,
|
||||
remaining_size=max(0.0, slot.size),
|
||||
)
|
||||
stale = restarted.on_venue_event(event)
|
||||
assert stale.diagnostic_code == KernelDiagnosticCode.STALE_STATE_RECONCILE
|
||||
assert restarted.slot(target_slot_id).fsm_state == TradeStage.STALE_STATE_RECONCILING
|
||||
126
prod/tests/test_dita_v2_launcher.py
Normal file
126
prod/tests/test_dita_v2_launcher.py
Normal file
@@ -0,0 +1,126 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from prod.clean_arch.dita_v2 import (
|
||||
DITAv2LauncherBundle,
|
||||
LauncherVenueMode,
|
||||
LauncherZincMode,
|
||||
KernelControlSnapshot,
|
||||
MockVenueAdapter,
|
||||
build_launcher_bundle,
|
||||
)
|
||||
from prod.bingx.enums import BingxEnvironment
|
||||
from prod.clean_arch.dita_v2.launcher import _maybe_close
|
||||
from prod.clean_arch.dita_v2.launcher import build_bingx_exec_client_config
|
||||
|
||||
|
||||
class DummyCloseable:
|
||||
def __init__(self) -> None:
|
||||
self.closed = False
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class DummyControlPlane:
|
||||
def __init__(self) -> None:
|
||||
self.snapshot = KernelControlSnapshot()
|
||||
|
||||
def read(self) -> KernelControlSnapshot:
|
||||
return self.snapshot
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class DummyZincPlane:
|
||||
def __init__(self) -> None:
|
||||
self.control_updates: list[KernelControlSnapshot] = []
|
||||
self.slot_writes: list[object] = []
|
||||
|
||||
def update_control(self, snapshot: KernelControlSnapshot) -> None:
|
||||
self.control_updates.append(snapshot)
|
||||
|
||||
def write_slot(self, slot: object) -> None:
|
||||
self.slot_writes.append(slot)
|
||||
|
||||
|
||||
class TestDITAv2Launcher(unittest.TestCase):
|
||||
def test_build_launcher_bundle_defaults_to_mock_and_in_memory(self) -> None:
|
||||
bundle = build_launcher_bundle(prefix=f"dita_v2_{uuid4().hex}")
|
||||
try:
|
||||
self.assertIsInstance(bundle, DITAv2LauncherBundle)
|
||||
self.assertIsInstance(bundle.venue, MockVenueAdapter)
|
||||
self.assertEqual(bundle.kernel.max_slots, 10)
|
||||
self.assertEqual(bundle.kernel.control.mode.value, "NORMAL")
|
||||
finally:
|
||||
bundle.close()
|
||||
|
||||
def test_build_launcher_bundle_can_select_real_components_via_env(self) -> None:
|
||||
prefix = f"dita_v2_{uuid4().hex}"
|
||||
dummy_control = DummyControlPlane()
|
||||
dummy_zinc = DummyZincPlane()
|
||||
with patch("prod.clean_arch.dita_v2.launcher.build_control_plane", return_value=dummy_control), patch(
|
||||
"prod.clean_arch.dita_v2.launcher._build_zinc_plane", return_value=dummy_zinc
|
||||
):
|
||||
bundle = build_launcher_bundle(
|
||||
prefix=prefix,
|
||||
venue_mode=LauncherVenueMode.BINGX,
|
||||
zinc_mode=LauncherZincMode.REAL,
|
||||
bingx_backend=object(),
|
||||
)
|
||||
try:
|
||||
self.assertIs(bundle.control_plane, dummy_control)
|
||||
self.assertIs(bundle.zinc_plane, dummy_zinc)
|
||||
self.assertEqual(bundle.venue.__class__.__name__, "BingxVenueAdapter")
|
||||
finally:
|
||||
bundle.close()
|
||||
|
||||
def test_build_launcher_bundle_respects_explicit_modes(self) -> None:
|
||||
prefix = f"dita_v2_{uuid4().hex}"
|
||||
bundle = build_launcher_bundle(
|
||||
prefix=prefix,
|
||||
venue_mode=LauncherVenueMode.MOCK,
|
||||
zinc_mode=LauncherZincMode.IN_MEMORY,
|
||||
)
|
||||
try:
|
||||
self.assertIsInstance(bundle.venue, MockVenueAdapter)
|
||||
self.assertEqual(bundle.kernel.max_slots, 10)
|
||||
finally:
|
||||
bundle.close()
|
||||
|
||||
def test_bingx_exec_client_config_uses_standard_testnet_credentials(self) -> None:
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"BINGX_API_KEY": "test-api-key",
|
||||
"BINGX_SECRET_KEY": "test-secret-key",
|
||||
"DOLPHIN_BINGX_ENV": "VST",
|
||||
"DOLPHIN_BINGX_ALLOW_MAINNET": "0",
|
||||
"DOLPHIN_BINGX_RECV_WINDOW_MS": "60000",
|
||||
"DOLPHIN_BINGX_DEFAULT_LEVERAGE": "1",
|
||||
"DOLPHIN_BINGX_EXCHANGE_LEVERAGE_CAP": "3",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
cfg = build_bingx_exec_client_config()
|
||||
self.assertEqual(cfg.api_key, "test-api-key")
|
||||
self.assertEqual(cfg.secret_key, "test-secret-key")
|
||||
self.assertIs(cfg.environment, BingxEnvironment.VST)
|
||||
self.assertFalse(cfg.allow_mainnet)
|
||||
self.assertEqual(cfg.recv_window_ms, 60000)
|
||||
self.assertEqual(cfg.default_leverage, 1)
|
||||
self.assertEqual(cfg.exchange_leverage_cap, 3)
|
||||
|
||||
def test_maybe_close_handles_closeable_objects(self) -> None:
|
||||
dummy = DummyCloseable()
|
||||
_maybe_close(dummy)
|
||||
self.assertTrue(dummy.closed)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
820
prod/tests/test_dita_v2_live_bingx_testnet_e2e.py
Normal file
820
prod/tests/test_dita_v2_live_bingx_testnet_e2e.py
Normal file
@@ -0,0 +1,820 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from prod.bingx.config import BingxExecClientConfig
|
||||
from prod.bingx.config import BingxInstrumentProviderConfig
|
||||
from prod.bingx.enums import BingxEnvironment
|
||||
from prod.bingx.schemas import BingxContract
|
||||
from prod.clean_arch.dita_v2 import BackendMode
|
||||
from prod.clean_arch.dita_v2 import BingxVenueAdapter
|
||||
from prod.clean_arch.dita_v2 import ControlUpdate
|
||||
from prod.clean_arch.dita_v2 import ExecutionKernel
|
||||
from prod.clean_arch.dita_v2 import KernelCommandType
|
||||
from prod.clean_arch.dita_v2 import KernelDiagnosticCode
|
||||
from prod.clean_arch.dita_v2 import KernelEventKind
|
||||
from prod.clean_arch.dita_v2 import KernelIntent
|
||||
from prod.clean_arch.dita_v2 import KernelMode
|
||||
from prod.clean_arch.dita_v2 import KernelVerbosity
|
||||
from prod.clean_arch.dita_v2 import RealZincControlPlane
|
||||
from prod.clean_arch.dita_v2 import RealZincPlane
|
||||
from prod.clean_arch.dita_v2 import TradeSide
|
||||
from prod.clean_arch.dita_v2 import TradeStage
|
||||
|
||||
|
||||
DOTENV_PATH = Path("/mnt/dolphinng5_predict/.env")
|
||||
if DOTENV_PATH.exists():
|
||||
load_dotenv(DOTENV_PATH, override=False)
|
||||
|
||||
LIVE_ENABLED = os.getenv("BINGX_SMOKE_LIVE") == "1"
|
||||
LIVE_TRADING_ENABLED = os.getenv("BINGX_SMOKE_ALLOW_TRADE") == "1"
|
||||
LIVE_DITAV2_ENABLED = os.getenv("DITA_V2_LIVE_BINGX") == "1"
|
||||
LIVE_CREDENTIALS_READY = bool(os.getenv("BINGX_API_KEY")) and bool(os.getenv("BINGX_SECRET_KEY"))
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not (LIVE_ENABLED and LIVE_TRADING_ENABLED and LIVE_DITAV2_ENABLED and LIVE_CREDENTIALS_READY),
|
||||
reason=(
|
||||
"DITAv2 live BingX testnet E2E requires BINGX_SMOKE_LIVE=1, "
|
||||
"BINGX_SMOKE_ALLOW_TRADE=1, DITA_V2_LIVE_BINGX=1, and BingX VST credentials"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _norm_symbol(value: str) -> str:
|
||||
return str(value or "").replace("-", "").replace("_", "").upper()
|
||||
|
||||
|
||||
def _contract_rows(payload: Any) -> list[dict[str, Any]]:
|
||||
if isinstance(payload, list):
|
||||
return [row for row in payload if isinstance(row, dict)]
|
||||
if isinstance(payload, dict):
|
||||
for key in ("contracts", "data", "rows"):
|
||||
rows = payload.get(key)
|
||||
if isinstance(rows, list):
|
||||
return [row for row in rows if isinstance(row, dict)]
|
||||
return []
|
||||
|
||||
|
||||
def _reference_price_row(payload: Any) -> dict[str, Any]:
|
||||
if isinstance(payload, list):
|
||||
return payload[0] if payload and isinstance(payload[0], dict) else {}
|
||||
if isinstance(payload, dict):
|
||||
data = payload.get("data")
|
||||
if isinstance(data, list):
|
||||
return data[0] if data and isinstance(data[0], dict) else {}
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
return payload
|
||||
return {}
|
||||
|
||||
|
||||
def _position_qty(row: dict[str, Any]) -> Decimal:
|
||||
raw = row.get("positionAmt") or row.get("positionQty") or row.get("positionSize") or row.get("quantity") or 0
|
||||
try:
|
||||
return abs(Decimal(str(raw)))
|
||||
except Exception:
|
||||
return Decimal("0")
|
||||
|
||||
|
||||
def _position_side(row: dict[str, Any]) -> TradeSide:
|
||||
side_raw = str(row.get("positionSide") or row.get("side") or "").upper()
|
||||
if side_raw in {"SHORT", "SELL"}:
|
||||
return TradeSide.SHORT
|
||||
if side_raw in {"LONG", "BUY"}:
|
||||
return TradeSide.LONG
|
||||
qty = _position_qty(row)
|
||||
signed = str(row.get("positionAmt") or row.get("quantity") or "0")
|
||||
try:
|
||||
return TradeSide.SHORT if Decimal(signed) < 0 else TradeSide.LONG if qty > 0 else TradeSide.FLAT
|
||||
except Exception:
|
||||
return TradeSide.FLAT
|
||||
|
||||
|
||||
def _live_quantity(contract: BingxContract) -> Decimal:
|
||||
base = contract.min_quantity if contract.min_quantity > 0 else contract.step_size
|
||||
if base <= 0:
|
||||
base = Decimal("0.001")
|
||||
qty = base * Decimal("2")
|
||||
step = contract.step_size if contract.step_size > 0 else Decimal("0.001")
|
||||
if qty < step * Decimal("2"):
|
||||
qty = step * Decimal("2")
|
||||
if qty < Decimal("12"):
|
||||
qty = Decimal("12")
|
||||
return qty
|
||||
|
||||
|
||||
def _build_kernel(prefix: str) -> tuple[ExecutionKernel, RealZincControlPlane, RealZincPlane, BingxVenueAdapter]:
|
||||
zinc_plane = RealZincPlane(prefix=prefix, slot_count=1, create=True)
|
||||
control_plane = RealZincControlPlane(prefix=prefix, create=False)
|
||||
venue = BingxVenueAdapter(
|
||||
config=BingxExecClientConfig(
|
||||
api_key=os.environ.get("BINGX_API_KEY", ""),
|
||||
secret_key=os.environ.get("BINGX_SECRET_KEY", ""),
|
||||
environment=BingxEnvironment.VST,
|
||||
allow_mainnet=False,
|
||||
recv_window_ms=int(os.environ.get("DOLPHIN_BINGX_RECV_WINDOW_MS", "60000")),
|
||||
default_leverage=int(os.environ.get("DOLPHIN_BINGX_DEFAULT_LEVERAGE", "1")),
|
||||
exchange_leverage_cap=int(os.environ.get("DOLPHIN_BINGX_EXCHANGE_LEVERAGE_CAP", "3")),
|
||||
prefer_websocket=False,
|
||||
sizing_mode="testnet",
|
||||
journal_strategy="dita_v2_live_testnet",
|
||||
journal_db="dolphin_pink",
|
||||
instrument_provider=BingxInstrumentProviderConfig(load_all=True),
|
||||
)
|
||||
)
|
||||
kernel = ExecutionKernel(
|
||||
max_slots=1,
|
||||
control_plane=control_plane,
|
||||
venue=venue,
|
||||
zinc_plane=zinc_plane,
|
||||
)
|
||||
kernel.update_control(
|
||||
ControlUpdate(
|
||||
mode=KernelMode.DEBUG,
|
||||
verbosity=KernelVerbosity.TRACE,
|
||||
backend_mode=BackendMode.BINGX,
|
||||
trace_transitions=True,
|
||||
debug_clickhouse_enabled=True,
|
||||
mirror_to_hazelcast=True,
|
||||
reconcile_on_restart=True,
|
||||
)
|
||||
)
|
||||
return kernel, control_plane, zinc_plane, venue
|
||||
|
||||
|
||||
def _pick_live_contract(venue: BingxVenueAdapter) -> BingxContract:
|
||||
client = getattr(getattr(venue, "backend", None), "_client", None)
|
||||
if client is None:
|
||||
raise AssertionError("BingxVenueAdapter backend does not expose a live client")
|
||||
|
||||
async def _inner() -> BingxContract:
|
||||
state = await venue.backend.refresh_state(include_history=True)
|
||||
open_symbols = {
|
||||
_norm_symbol(str(row.get("symbol", key)))
|
||||
for key, row in getattr(state, "open_positions", {}).items()
|
||||
if isinstance(row, dict)
|
||||
}
|
||||
contracts_payload = await client.public_get("/openApi/swap/v2/quote/contracts")
|
||||
contracts: list[BingxContract] = []
|
||||
for row in _contract_rows(contracts_payload):
|
||||
try:
|
||||
contracts.append(BingxContract.from_http(row))
|
||||
except Exception:
|
||||
continue
|
||||
if not contracts:
|
||||
raise AssertionError("BingX VST contract loader returned no usable contracts")
|
||||
preferred = [
|
||||
os.getenv("BINGX_SMOKE_SYMBOL", "").strip().upper(),
|
||||
"TRXUSDT",
|
||||
"XLMUSDT",
|
||||
"DOGEUSDT",
|
||||
"ETHUSDT",
|
||||
"BTCUSDT",
|
||||
]
|
||||
by_symbol = {contract.symbol.upper(): contract for contract in contracts}
|
||||
by_venue = {contract.venue_symbol.replace("-", "").upper(): contract for contract in contracts}
|
||||
for candidate in preferred:
|
||||
if not candidate or candidate in open_symbols:
|
||||
continue
|
||||
if candidate in by_symbol:
|
||||
return by_symbol[candidate]
|
||||
if candidate in by_venue:
|
||||
return by_venue[candidate]
|
||||
for contract in contracts:
|
||||
symbol = contract.symbol.upper()
|
||||
venue_symbol = contract.venue_symbol.replace("-", "").upper()
|
||||
if symbol not in open_symbols and venue_symbol not in open_symbols:
|
||||
return contract
|
||||
raise AssertionError("No BingX VST contract available outside the current open set")
|
||||
|
||||
return asyncio.run(_inner())
|
||||
|
||||
|
||||
def _reference_price(venue: BingxVenueAdapter, contract: BingxContract) -> Decimal:
|
||||
client = getattr(getattr(venue, "backend", None), "_client", None)
|
||||
if client is None:
|
||||
raise AssertionError("BingxVenueAdapter backend does not expose a live client")
|
||||
|
||||
async def _inner() -> Decimal:
|
||||
payload = await client.public_get("/openApi/swap/v2/quote/price", {"symbol": contract.venue_symbol})
|
||||
row = _reference_price_row(payload)
|
||||
raw_price = row.get("price") or row.get("lastPrice") or row.get("markPrice") or row.get("last")
|
||||
if raw_price is None:
|
||||
raise AssertionError(f"Unable to resolve BingX price for {contract.venue_symbol}: {payload!r}")
|
||||
return Decimal(str(raw_price))
|
||||
|
||||
return asyncio.run(_inner())
|
||||
|
||||
|
||||
def _live_intent(
|
||||
*,
|
||||
action: KernelCommandType,
|
||||
trade_id: str,
|
||||
side: TradeSide,
|
||||
asset: str,
|
||||
target_size: float,
|
||||
price: float,
|
||||
reason: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"intent_id": f"{trade_id}:{action.value}:{reason}:{uuid.uuid4().hex[:8]}",
|
||||
"trade_id": trade_id,
|
||||
"slot_id": 0,
|
||||
"asset": asset,
|
||||
"side": side.value,
|
||||
"action": action.value,
|
||||
"reference_price": float(price),
|
||||
"target_size": float(target_size),
|
||||
"leverage": 1.0,
|
||||
"exit_leg_ratios": [0.5, 0.5],
|
||||
"reason": reason,
|
||||
"metadata": {"source": "live_bingx_testnet"},
|
||||
"stage": "INTENT_CREATED",
|
||||
}
|
||||
|
||||
|
||||
def _current_exchange_rows(venue: BingxVenueAdapter, symbol: str) -> list[dict[str, Any]]:
|
||||
rows = []
|
||||
for row in venue.open_positions():
|
||||
if _norm_symbol(str(row.get("symbol") or row.get("venueSymbol") or "")) == _norm_symbol(symbol):
|
||||
rows.append(dict(row))
|
||||
return rows
|
||||
|
||||
|
||||
def _current_exchange_qty(venue: BingxVenueAdapter, symbol: str) -> Decimal:
|
||||
rows = _current_exchange_rows(venue, symbol)
|
||||
if not rows:
|
||||
return Decimal("0")
|
||||
return max(_position_qty(row) for row in rows)
|
||||
|
||||
|
||||
def _observed_live_qty(kernel: ExecutionKernel, venue: BingxVenueAdapter, symbol: str) -> Decimal:
|
||||
slot_qty = Decimal(str(getattr(kernel.slot(0), "size", 0.0) or 0.0))
|
||||
exchange_qty = _current_exchange_qty(venue, symbol)
|
||||
return max(slot_qty, exchange_qty)
|
||||
|
||||
|
||||
def _drive_live_reconcile(kernel: ExecutionKernel, venue: BingxVenueAdapter, symbol: str) -> None:
|
||||
snapshot = venue.reconcile()
|
||||
for event in snapshot:
|
||||
if _norm_symbol(str(getattr(event, "asset", ""))) == _norm_symbol(symbol):
|
||||
kernel.on_venue_event(event)
|
||||
|
||||
|
||||
def _wait_for_live_response(kernel: ExecutionKernel, venue: BingxVenueAdapter, symbol: str, *, timeout_s: float = 60.0) -> tuple[TradeStage, Decimal]:
|
||||
def _predicate() -> bool:
|
||||
_drive_live_reconcile(kernel, venue, symbol)
|
||||
slot = kernel.slot(0)
|
||||
return slot.asset == symbol and slot.fsm_state != TradeStage.IDLE
|
||||
|
||||
try:
|
||||
_wait_until(_predicate, timeout_s=timeout_s, interval_s=1.0)
|
||||
except AssertionError:
|
||||
_drive_live_reconcile(kernel, venue, symbol)
|
||||
slot = kernel.slot(0)
|
||||
return slot.fsm_state, _current_exchange_qty(venue, symbol)
|
||||
|
||||
|
||||
def _wait_until(predicate, *, timeout_s: float = 30.0, interval_s: float = 1.0) -> None:
|
||||
deadline = time.monotonic() + timeout_s
|
||||
last_exc: Exception | None = None
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
if predicate():
|
||||
return
|
||||
except Exception as exc: # pragma: no cover - best effort live polling
|
||||
last_exc = exc
|
||||
time.sleep(interval_s)
|
||||
if last_exc is not None:
|
||||
raise AssertionError("timed out while waiting for live BingX state") from last_exc
|
||||
raise AssertionError("timed out while waiting for live BingX state")
|
||||
|
||||
|
||||
def _cleanup_live_position(kernel: ExecutionKernel, venue: BingxVenueAdapter, contract: BingxContract, trade_id: str) -> None:
|
||||
try:
|
||||
for attempt in range(5):
|
||||
qty = _current_exchange_qty(venue, contract.symbol)
|
||||
if qty <= 0:
|
||||
return
|
||||
side = TradeSide.SHORT
|
||||
rows = _current_exchange_rows(venue, contract.symbol)
|
||||
if rows:
|
||||
side = _position_side(rows[0])
|
||||
if side == TradeSide.FLAT:
|
||||
side = TradeSide.SHORT
|
||||
price = float(_reference_price(venue, contract))
|
||||
outcome = kernel.process_intent(
|
||||
KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id=f"{trade_id}:cleanup:{attempt}:{uuid.uuid4().hex[:8]}",
|
||||
trade_id=trade_id,
|
||||
slot_id=0,
|
||||
asset=contract.symbol,
|
||||
side=side,
|
||||
action=KernelCommandType.EXIT,
|
||||
reference_price=price,
|
||||
target_size=float(qty),
|
||||
leverage=1.0,
|
||||
exit_leg_ratios=(1.0,),
|
||||
reason="CLEANUP",
|
||||
metadata={"source": "cleanup"},
|
||||
)
|
||||
)
|
||||
if outcome.diagnostic_code == KernelDiagnosticCode.NO_ACTIVE_EXIT_ORDER:
|
||||
_wait_until(lambda: _current_exchange_qty(venue, contract.symbol) <= 0, timeout_s=20.0, interval_s=1.0)
|
||||
else:
|
||||
_wait_until(lambda: _current_exchange_qty(venue, contract.symbol) <= 0, timeout_s=30.0, interval_s=1.0)
|
||||
finally:
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _LiveCase:
|
||||
name: str
|
||||
side: TradeSide
|
||||
|
||||
|
||||
LIVE_CASES = (
|
||||
_LiveCase("short_cycle", TradeSide.SHORT),
|
||||
_LiveCase("long_cycle", TradeSide.LONG),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", LIVE_CASES, ids=lambda case: case.name)
|
||||
def test_live_bingx_testnet_basic_cycle(case: _LiveCase) -> None:
|
||||
prefix = f"dita_v2_live_{case.name}_{uuid.uuid4().hex[:8]}"
|
||||
kernel, control_plane, zinc_plane, venue = _build_kernel(prefix)
|
||||
contract: BingxContract | None = None
|
||||
trade_id = f"live-{case.name}-{uuid.uuid4().hex[:8]}"
|
||||
try:
|
||||
assert venue.connect() is True
|
||||
contract = _pick_live_contract(venue)
|
||||
size = _live_quantity(contract)
|
||||
price = _reference_price(venue, contract)
|
||||
entry = kernel.process_intent(
|
||||
KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id=f"{trade_id}:entry",
|
||||
trade_id=trade_id,
|
||||
slot_id=0,
|
||||
asset=contract.symbol,
|
||||
side=case.side,
|
||||
action=KernelCommandType.ENTER,
|
||||
reference_price=float(price),
|
||||
target_size=float(size),
|
||||
leverage=1.0,
|
||||
exit_leg_ratios=(0.5, 0.5),
|
||||
reason="LIVE_ENTRY",
|
||||
metadata={"contract": contract.venue_symbol},
|
||||
)
|
||||
)
|
||||
if entry.diagnostic_code == KernelDiagnosticCode.RATE_LIMITED or any(event.kind == KernelEventKind.RATE_LIMITED for event in entry.emitted_events):
|
||||
assert entry.accepted is False
|
||||
assert kernel.slot(0).fsm_state in {
|
||||
TradeStage.IDLE,
|
||||
TradeStage.POSITION_OPEN,
|
||||
TradeStage.POSITION_OPENED,
|
||||
TradeStage.ENTRY_WORKING,
|
||||
TradeStage.EXIT_WORKING,
|
||||
TradeStage.POSITION_PARTIALLY_CLOSED,
|
||||
TradeStage.CLOSED,
|
||||
TradeStage.STALE_STATE_RECONCILING,
|
||||
}
|
||||
return
|
||||
assert entry.accepted is True
|
||||
assert entry.diagnostic_code == KernelDiagnosticCode.OK
|
||||
slot = kernel.slot(0)
|
||||
assert slot.trade_id == trade_id
|
||||
assert slot.asset == contract.symbol
|
||||
state, open_qty = _wait_for_live_response(kernel, venue, contract.symbol, timeout_s=60.0)
|
||||
kernel_qty = Decimal(str(getattr(kernel.slot(0), "size", 0.0) or 0.0))
|
||||
live_qty = max(kernel_qty, open_qty)
|
||||
assert state in {
|
||||
TradeStage.IDLE,
|
||||
TradeStage.POSITION_OPEN,
|
||||
TradeStage.POSITION_OPENED,
|
||||
TradeStage.ENTRY_WORKING,
|
||||
TradeStage.EXIT_WORKING,
|
||||
TradeStage.POSITION_PARTIALLY_CLOSED,
|
||||
TradeStage.CLOSED,
|
||||
TradeStage.STALE_STATE_RECONCILING,
|
||||
}
|
||||
assert kernel.zinc_plane.read_control().backend_mode == BackendMode.BINGX
|
||||
|
||||
if live_qty <= 0:
|
||||
assert entry.emitted_events, "entry should still emit an exchange reaction"
|
||||
assert any(event.kind in {KernelEventKind.ORDER_ACK, KernelEventKind.ORDER_REJECT, KernelEventKind.PARTIAL_FILL, KernelEventKind.RATE_LIMITED} for event in entry.emitted_events)
|
||||
return
|
||||
|
||||
mark = kernel.process_intent(
|
||||
KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id=f"{trade_id}:mark",
|
||||
trade_id=trade_id,
|
||||
slot_id=0,
|
||||
asset=contract.symbol,
|
||||
side=case.side,
|
||||
action=KernelCommandType.MARK_PRICE,
|
||||
reference_price=float(price),
|
||||
target_size=float(size),
|
||||
leverage=1.0,
|
||||
exit_leg_ratios=(0.5, 0.5),
|
||||
reason="LIVE_MARK",
|
||||
metadata={"contract": contract.venue_symbol},
|
||||
)
|
||||
)
|
||||
assert mark.accepted is True
|
||||
assert kernel.slot(0).asset == contract.symbol
|
||||
|
||||
live_qty = max(Decimal(str(getattr(kernel.slot(0), "size", 0.0) or 0.0)), _current_exchange_qty(venue, contract.symbol))
|
||||
assert live_qty > 0
|
||||
partial_target = max(live_qty / Decimal("2"), contract.step_size)
|
||||
partial = kernel.process_intent(
|
||||
KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id=f"{trade_id}:partial_exit",
|
||||
trade_id=trade_id,
|
||||
slot_id=0,
|
||||
asset=contract.symbol,
|
||||
side=case.side,
|
||||
action=KernelCommandType.EXIT,
|
||||
reference_price=float(price),
|
||||
target_size=float(partial_target),
|
||||
leverage=1.0,
|
||||
exit_leg_ratios=(0.5, 0.5),
|
||||
reason="LIVE_PARTIAL_EXIT",
|
||||
metadata={"contract": contract.venue_symbol},
|
||||
)
|
||||
)
|
||||
if partial.diagnostic_code == KernelDiagnosticCode.RATE_LIMITED or any(event.kind == KernelEventKind.RATE_LIMITED for event in partial.emitted_events):
|
||||
assert partial.accepted is False
|
||||
assert kernel.slot(0).fsm_state in {
|
||||
TradeStage.IDLE,
|
||||
TradeStage.POSITION_OPEN,
|
||||
TradeStage.POSITION_OPENED,
|
||||
TradeStage.ENTRY_WORKING,
|
||||
TradeStage.EXIT_WORKING,
|
||||
TradeStage.POSITION_PARTIALLY_CLOSED,
|
||||
TradeStage.CLOSED,
|
||||
TradeStage.STALE_STATE_RECONCILING,
|
||||
}
|
||||
return
|
||||
assert partial.accepted is True
|
||||
if partial.diagnostic_code in {
|
||||
KernelDiagnosticCode.EXIT_ORDER_REJECTED,
|
||||
KernelDiagnosticCode.ORDER_REJECTED,
|
||||
KernelDiagnosticCode.NO_ACTIVE_EXIT_ORDER,
|
||||
KernelDiagnosticCode.CANCEL_REJECTED,
|
||||
KernelDiagnosticCode.RATE_LIMITED,
|
||||
} or any(event.kind in {KernelEventKind.ORDER_REJECT, KernelEventKind.CANCEL_REJECT, KernelEventKind.RATE_LIMITED} for event in partial.emitted_events):
|
||||
assert kernel.slot(0).fsm_state in {
|
||||
TradeStage.IDLE,
|
||||
TradeStage.POSITION_OPEN,
|
||||
TradeStage.POSITION_OPENED,
|
||||
TradeStage.ENTRY_WORKING,
|
||||
TradeStage.EXIT_WORKING,
|
||||
TradeStage.POSITION_PARTIALLY_CLOSED,
|
||||
TradeStage.CLOSED,
|
||||
TradeStage.STALE_STATE_RECONCILING,
|
||||
}
|
||||
return
|
||||
_wait_until(lambda: _current_exchange_qty(venue, contract.symbol) <= live_qty, timeout_s=60.0, interval_s=1.0)
|
||||
reduced_qty = _current_exchange_qty(venue, contract.symbol)
|
||||
assert reduced_qty <= open_qty
|
||||
|
||||
remaining = reduced_qty
|
||||
if remaining > 0:
|
||||
final = kernel.process_intent(
|
||||
KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id=f"{trade_id}:final_exit",
|
||||
trade_id=trade_id,
|
||||
slot_id=0,
|
||||
asset=contract.symbol,
|
||||
side=case.side,
|
||||
action=KernelCommandType.EXIT,
|
||||
reference_price=float(price),
|
||||
target_size=float(remaining),
|
||||
leverage=1.0,
|
||||
exit_leg_ratios=(1.0,),
|
||||
reason="LIVE_FINAL_EXIT",
|
||||
metadata={"contract": contract.venue_symbol},
|
||||
)
|
||||
)
|
||||
if final.diagnostic_code == KernelDiagnosticCode.RATE_LIMITED or any(event.kind == KernelEventKind.RATE_LIMITED for event in final.emitted_events):
|
||||
assert final.accepted is False
|
||||
assert kernel.slot(0).fsm_state in {
|
||||
TradeStage.IDLE,
|
||||
TradeStage.POSITION_OPEN,
|
||||
TradeStage.POSITION_OPENED,
|
||||
TradeStage.ENTRY_WORKING,
|
||||
TradeStage.EXIT_WORKING,
|
||||
TradeStage.POSITION_PARTIALLY_CLOSED,
|
||||
TradeStage.CLOSED,
|
||||
TradeStage.STALE_STATE_RECONCILING,
|
||||
}
|
||||
return
|
||||
assert final.accepted is True
|
||||
if final.diagnostic_code in {
|
||||
KernelDiagnosticCode.EXIT_ORDER_REJECTED,
|
||||
KernelDiagnosticCode.ORDER_REJECTED,
|
||||
KernelDiagnosticCode.NO_ACTIVE_EXIT_ORDER,
|
||||
KernelDiagnosticCode.CANCEL_REJECTED,
|
||||
KernelDiagnosticCode.RATE_LIMITED,
|
||||
} or any(event.kind in {KernelEventKind.ORDER_REJECT, KernelEventKind.CANCEL_REJECT, KernelEventKind.RATE_LIMITED} for event in final.emitted_events):
|
||||
assert kernel.slot(0).fsm_state in {
|
||||
TradeStage.IDLE,
|
||||
TradeStage.POSITION_OPEN,
|
||||
TradeStage.POSITION_OPENED,
|
||||
TradeStage.ENTRY_WORKING,
|
||||
TradeStage.EXIT_WORKING,
|
||||
TradeStage.POSITION_PARTIALLY_CLOSED,
|
||||
TradeStage.CLOSED,
|
||||
TradeStage.STALE_STATE_RECONCILING,
|
||||
}
|
||||
return
|
||||
_wait_until(lambda: _current_exchange_qty(venue, contract.symbol) <= 0, timeout_s=60.0, interval_s=1.0)
|
||||
|
||||
# On the real live path there is no active working exit order after the market close.
|
||||
cancel_diag = kernel.process_intent(
|
||||
KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id=f"{trade_id}:cancel_after_flat",
|
||||
trade_id=trade_id,
|
||||
slot_id=0,
|
||||
asset=contract.symbol,
|
||||
side=case.side,
|
||||
action=KernelCommandType.CANCEL,
|
||||
reference_price=float(price),
|
||||
target_size=float(size),
|
||||
leverage=1.0,
|
||||
exit_leg_ratios=(1.0,),
|
||||
reason="LIVE_CANCEL_AFTER_FLAT",
|
||||
metadata={"contract": contract.venue_symbol},
|
||||
)
|
||||
)
|
||||
assert cancel_diag.diagnostic_code in {
|
||||
KernelDiagnosticCode.NO_ACTIVE_EXIT_ORDER,
|
||||
KernelDiagnosticCode.OK,
|
||||
KernelDiagnosticCode.CANCEL_REJECTED,
|
||||
KernelDiagnosticCode.ORDER_REJECTED,
|
||||
KernelDiagnosticCode.RATE_LIMITED,
|
||||
}
|
||||
_wait_until(lambda: _current_exchange_qty(venue, contract.symbol) <= 0, timeout_s=60.0, interval_s=1.0)
|
||||
assert _current_exchange_qty(venue, contract.symbol) <= 0
|
||||
assert kernel.slot(0).size <= 1e-12
|
||||
finally:
|
||||
if contract is not None:
|
||||
try:
|
||||
_cleanup_live_position(kernel, venue, contract, trade_id)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
disconnect = getattr(getattr(venue, "backend", None), "disconnect", None)
|
||||
if disconnect is not None:
|
||||
asyncio.run(disconnect())
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
zinc_plane.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
control_plane.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.parametrize("seed", range(4), ids=lambda seed: f"seed-{seed}")
|
||||
@pytest.mark.parametrize("side", [TradeSide.SHORT, TradeSide.LONG], ids=lambda side: f"side-{side.value.lower()}")
|
||||
def test_live_bingx_testnet_chaos_fuzz(seed: int, side: TradeSide) -> None:
|
||||
rng = __import__("random").Random(20260527 + seed)
|
||||
prefix = f"dita_v2_live_fuzz_{side.value.lower()}_{seed}_{uuid.uuid4().hex[:8]}"
|
||||
kernel, control_plane, zinc_plane, venue = _build_kernel(prefix)
|
||||
contract: BingxContract | None = None
|
||||
trade_id = f"live-fuzz-{side.value.lower()}-{seed}-{uuid.uuid4().hex[:8]}"
|
||||
try:
|
||||
assert venue.connect() is True
|
||||
contract = _pick_live_contract(venue)
|
||||
size = _live_quantity(contract)
|
||||
price = _reference_price(venue, contract)
|
||||
kernel.process_intent(
|
||||
KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id=f"{trade_id}:entry",
|
||||
trade_id=trade_id,
|
||||
slot_id=0,
|
||||
asset=contract.symbol,
|
||||
side=side,
|
||||
action=KernelCommandType.ENTER,
|
||||
reference_price=float(price),
|
||||
target_size=float(size),
|
||||
leverage=1.0,
|
||||
exit_leg_ratios=(0.5, 0.5),
|
||||
reason="FUZZ_ENTRY",
|
||||
metadata={"contract": contract.venue_symbol},
|
||||
)
|
||||
)
|
||||
state, open_qty = _wait_for_live_response(kernel, venue, contract.symbol, timeout_s=60.0)
|
||||
kernel_qty = Decimal(str(getattr(kernel.slot(0), "size", 0.0) or 0.0))
|
||||
live_qty = max(kernel_qty, open_qty)
|
||||
assert state in {
|
||||
TradeStage.IDLE,
|
||||
TradeStage.POSITION_OPEN,
|
||||
TradeStage.POSITION_OPENED,
|
||||
TradeStage.ENTRY_WORKING,
|
||||
TradeStage.EXIT_WORKING,
|
||||
TradeStage.POSITION_PARTIALLY_CLOSED,
|
||||
TradeStage.CLOSED,
|
||||
TradeStage.STALE_STATE_RECONCILING,
|
||||
}
|
||||
if live_qty <= 0:
|
||||
assert kernel.slot(0).fsm_state in {
|
||||
TradeStage.IDLE,
|
||||
TradeStage.ENTRY_WORKING,
|
||||
TradeStage.POSITION_OPEN,
|
||||
TradeStage.POSITION_OPENED,
|
||||
TradeStage.STALE_STATE_RECONCILING,
|
||||
TradeStage.CLOSED,
|
||||
}
|
||||
return
|
||||
|
||||
for idx in range(rng.randint(4, 7)):
|
||||
slot = kernel.slot(0)
|
||||
action = rng.choice(["mark", "exit_half", "exit_rest", "cancel", "reconcile"])
|
||||
current_price = _reference_price(venue, contract)
|
||||
_drive_live_reconcile(kernel, venue, contract.symbol)
|
||||
slot = kernel.slot(0)
|
||||
if action == "mark":
|
||||
kernel.process_intent(
|
||||
KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id=f"{trade_id}:mark:{idx}",
|
||||
trade_id=trade_id,
|
||||
slot_id=0,
|
||||
asset=contract.symbol,
|
||||
side=side,
|
||||
action=KernelCommandType.MARK_PRICE,
|
||||
reference_price=float(current_price),
|
||||
target_size=float(max(size, slot.size or size)),
|
||||
leverage=1.0,
|
||||
exit_leg_ratios=(0.5, 0.5),
|
||||
reason=f"FUZZ_MARK_{idx}",
|
||||
metadata={"contract": contract.venue_symbol},
|
||||
)
|
||||
)
|
||||
elif action == "exit_half":
|
||||
live_qty = max(Decimal(str(getattr(kernel.slot(0), "size", 0.0) or 0.0)), _current_exchange_qty(venue, contract.symbol))
|
||||
if live_qty <= 0:
|
||||
continue
|
||||
target = max(live_qty / Decimal("2"), contract.step_size)
|
||||
kernel.process_intent(
|
||||
KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id=f"{trade_id}:exit_half:{idx}",
|
||||
trade_id=trade_id,
|
||||
slot_id=0,
|
||||
asset=contract.symbol,
|
||||
side=side,
|
||||
action=KernelCommandType.EXIT,
|
||||
reference_price=float(current_price),
|
||||
target_size=float(target),
|
||||
leverage=1.0,
|
||||
exit_leg_ratios=(0.5, 0.5),
|
||||
reason=f"FUZZ_EXIT_HALF_{idx}",
|
||||
metadata={"contract": contract.venue_symbol},
|
||||
)
|
||||
)
|
||||
elif action == "exit_rest":
|
||||
live_qty = max(Decimal(str(getattr(kernel.slot(0), "size", 0.0) or 0.0)), _current_exchange_qty(venue, contract.symbol))
|
||||
if live_qty <= 0:
|
||||
continue
|
||||
kernel.process_intent(
|
||||
KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id=f"{trade_id}:exit_rest:{idx}",
|
||||
trade_id=trade_id,
|
||||
slot_id=0,
|
||||
asset=contract.symbol,
|
||||
side=side,
|
||||
action=KernelCommandType.EXIT,
|
||||
reference_price=float(current_price),
|
||||
target_size=float(max(Decimal(str(slot.size)), contract.step_size)),
|
||||
leverage=1.0,
|
||||
exit_leg_ratios=(1.0,),
|
||||
reason=f"FUZZ_EXIT_REST_{idx}",
|
||||
metadata={"contract": contract.venue_symbol},
|
||||
)
|
||||
)
|
||||
elif action == "cancel":
|
||||
if kernel.slot(0).active_exit_order is not None or max(Decimal(str(getattr(kernel.slot(0), "size", 0.0) or 0.0)), _current_exchange_qty(venue, contract.symbol)) > 0:
|
||||
outcome = kernel.process_intent(
|
||||
KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id=f"{trade_id}:cancel:{idx}",
|
||||
trade_id=trade_id,
|
||||
slot_id=0,
|
||||
asset=contract.symbol,
|
||||
side=side,
|
||||
action=KernelCommandType.CANCEL,
|
||||
reference_price=float(current_price),
|
||||
target_size=float(max(size, contract.step_size)),
|
||||
leverage=1.0,
|
||||
exit_leg_ratios=(1.0,),
|
||||
reason=f"FUZZ_CANCEL_{idx}",
|
||||
metadata={"contract": contract.venue_symbol},
|
||||
)
|
||||
)
|
||||
assert outcome.diagnostic_code in {
|
||||
KernelDiagnosticCode.NO_ACTIVE_EXIT_ORDER,
|
||||
KernelDiagnosticCode.OK,
|
||||
KernelDiagnosticCode.RATE_LIMITED,
|
||||
}
|
||||
else:
|
||||
kernel.process_intent(
|
||||
KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id=f"{trade_id}:reconcile:{idx}",
|
||||
trade_id=trade_id,
|
||||
slot_id=0,
|
||||
asset=contract.symbol,
|
||||
side=side,
|
||||
action=KernelCommandType.RECONCILE,
|
||||
reference_price=float(current_price),
|
||||
target_size=float(size),
|
||||
leverage=1.0,
|
||||
exit_leg_ratios=(0.5, 0.5),
|
||||
reason=f"FUZZ_RECONCILE_{idx}",
|
||||
metadata={"contract": contract.venue_symbol},
|
||||
)
|
||||
)
|
||||
_drive_live_reconcile(kernel, venue, contract.symbol)
|
||||
_wait_until(lambda: max(Decimal(str(getattr(kernel.slot(0), "size", 0.0) or 0.0)), _current_exchange_qty(venue, contract.symbol)) >= 0, timeout_s=2.0, interval_s=0.2)
|
||||
|
||||
# Hard close-out pass for the fuzz cases.
|
||||
for _ in range(3):
|
||||
qty = max(Decimal(str(getattr(kernel.slot(0), "size", 0.0) or 0.0)), _current_exchange_qty(venue, contract.symbol))
|
||||
if qty <= 0:
|
||||
break
|
||||
kernel.process_intent(
|
||||
KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id=f"{trade_id}:cleanup:{uuid.uuid4().hex[:8]}",
|
||||
trade_id=trade_id,
|
||||
slot_id=0,
|
||||
asset=contract.symbol,
|
||||
side=side,
|
||||
action=KernelCommandType.EXIT,
|
||||
reference_price=float(_reference_price(venue, contract)),
|
||||
target_size=float(qty),
|
||||
leverage=1.0,
|
||||
exit_leg_ratios=(1.0,),
|
||||
reason="FUZZ_CLEANUP",
|
||||
metadata={"contract": contract.venue_symbol},
|
||||
)
|
||||
)
|
||||
_wait_until(lambda: max(Decimal(str(getattr(kernel.slot(0), "size", 0.0) or 0.0)), _current_exchange_qty(venue, contract.symbol)) <= 0, timeout_s=60.0, interval_s=1.0)
|
||||
|
||||
assert max(Decimal(str(getattr(kernel.slot(0), "size", 0.0) or 0.0)), _current_exchange_qty(venue, contract.symbol)) <= 0
|
||||
assert kernel.slot(0).fsm_state in {
|
||||
TradeStage.CLOSED,
|
||||
TradeStage.IDLE,
|
||||
TradeStage.POSITION_OPEN,
|
||||
TradeStage.POSITION_PARTIALLY_CLOSED,
|
||||
TradeStage.STALE_STATE_RECONCILING,
|
||||
}
|
||||
assert kernel.zinc_plane.read_control().mode == KernelMode.DEBUG
|
||||
assert kernel.zinc_plane.read_control().verbosity == KernelVerbosity.TRACE
|
||||
finally:
|
||||
if contract is not None:
|
||||
try:
|
||||
_cleanup_live_position(kernel, venue, contract, trade_id)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
disconnect = getattr(getattr(venue, "backend", None), "disconnect", None)
|
||||
if disconnect is not None:
|
||||
asyncio.run(disconnect())
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
zinc_plane.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
control_plane.close()
|
||||
except Exception:
|
||||
pass
|
||||
54
prod/tests/test_dita_v2_ops.py
Normal file
54
prod/tests/test_dita_v2_ops.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
|
||||
class TestDITAv2Ops(unittest.TestCase):
|
||||
def test_operator_playbook_mentions_supervisor_program(self) -> None:
|
||||
text = Path("/mnt/dolphinng5_predict/prod/docs/DITA_V2_OPERATOR_PLAYBOOK.md").read_text()
|
||||
self.assertIn("dolphin:dita_v2", text)
|
||||
self.assertIn("launch_dita_v2.py", text)
|
||||
self.assertIn("dita_v2_ctl.py", text)
|
||||
|
||||
def test_supervisor_config_contains_dita_v2_program(self) -> None:
|
||||
conf = Path("/mnt/dolphinng5_predict/prod/supervisor/dolphin-supervisord.conf").read_text()
|
||||
self.assertIn("[program:dita_v2]", conf)
|
||||
self.assertIn("launch_dita_v2.py", conf)
|
||||
|
||||
def test_supervisor_migration_doc_mentions_dita_v2_recovery(self) -> None:
|
||||
text = Path("/mnt/dolphinng5_predict/prod/AGENT_READ_Supervisor_migration.md").read_text()
|
||||
self.assertIn("dolphin:dita_v2", text)
|
||||
self.assertIn("dita_v2_ctl.py", text)
|
||||
self.assertIn("Do not use `systemctl` for `dolphin:dita_v2`", text)
|
||||
|
||||
def test_supervisor_wrapper_mentions_dita_v2(self) -> None:
|
||||
text = Path("/mnt/dolphinng5_predict/prod/supervisor/supervisorctl.sh").read_text()
|
||||
self.assertIn("dita_v2", text)
|
||||
self.assertIn("dita_v2_ctl.py", text)
|
||||
|
||||
def test_supervisor_config_has_dita_v2_comment(self) -> None:
|
||||
conf = Path("/mnt/dolphinng5_predict/prod/supervisor/dolphin-supervisord.conf").read_text()
|
||||
self.assertIn("DITAv2 — supervised kernel", conf)
|
||||
|
||||
def test_operational_status_mentions_dita_v2(self) -> None:
|
||||
text = Path("/mnt/dolphinng5_predict/prod/docs/OPERATIONAL_STATUS.md").read_text()
|
||||
self.assertIn("DITAv2 Kernel", text)
|
||||
self.assertIn("dolphin:dita_v2", text)
|
||||
|
||||
def test_live_smoke_wrapper_is_documented_and_wired(self) -> None:
|
||||
script = Path("/mnt/dolphinng5_predict/prod/ops/dita_v2_live_bingx_smoke.py").read_text()
|
||||
self.assertIn("BINGX_SMOKE_LIVE", script)
|
||||
self.assertIn("BINGX_SMOKE_ALLOW_TRADE", script)
|
||||
self.assertIn("DITA_V2_LIVE_BINGX", script)
|
||||
self.assertIn("test_dita_v2_live_bingx_testnet_e2e.py", script)
|
||||
self.assertIn("--dry-run", script)
|
||||
|
||||
playbook = Path("/mnt/dolphinng5_predict/prod/docs/DITA_V2_OPERATOR_PLAYBOOK.md").read_text()
|
||||
self.assertIn("dita_v2_live_bingx_smoke.py", playbook)
|
||||
self.assertIn("--dry-run", playbook)
|
||||
self.assertIn("TRXUSDT", playbook)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
380
prod/tests/test_dita_v2_zinc.py
Normal file
380
prod/tests/test_dita_v2_zinc.py
Normal file
@@ -0,0 +1,380 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import os
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from uuid import uuid4
|
||||
|
||||
from prod.clean_arch.dita_v2 import (
|
||||
ControlUpdate,
|
||||
ExecutionKernel,
|
||||
InMemoryControlPlane,
|
||||
KernelCommandType,
|
||||
KernelDiagnosticCode,
|
||||
KernelControlSnapshot,
|
||||
KernelIntent,
|
||||
KernelMode,
|
||||
KernelVerbosity,
|
||||
MockVenueAdapter,
|
||||
MockVenueScenario,
|
||||
InMemoryZincPlane,
|
||||
RealZincPlane,
|
||||
RealZincControlPlane,
|
||||
RealZincUnavailable,
|
||||
TradeSide,
|
||||
TradeSlot,
|
||||
TradeStage,
|
||||
VenueEvent,
|
||||
VenueEventStatus,
|
||||
KernelEventKind,
|
||||
)
|
||||
from prod.clean_arch.dita_v2.real_zinc_plane import SharedRegion
|
||||
|
||||
|
||||
HAS_REAL_ZINC = SharedRegion is not None
|
||||
|
||||
|
||||
def mk_intent_kwargs(
|
||||
*,
|
||||
slot_id: int,
|
||||
trade_id: str,
|
||||
action: KernelCommandType,
|
||||
size: float = 1.0,
|
||||
leverage: float = 2.0,
|
||||
side: TradeSide = TradeSide.SHORT,
|
||||
price: float = 100.0,
|
||||
reason: str = "FUZZ",
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"timestamp": datetime.now(timezone.utc),
|
||||
"intent_id": f"intent-{trade_id}-{action.value}-{slot_id}",
|
||||
"trade_id": trade_id,
|
||||
"slot_id": slot_id,
|
||||
"asset": "BTCUSDT",
|
||||
"side": side,
|
||||
"action": action,
|
||||
"reference_price": price,
|
||||
"target_size": size,
|
||||
"leverage": leverage,
|
||||
"exit_leg_ratios": (0.5, 0.5) if action == KernelCommandType.EXIT else (1.0,),
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
|
||||
@unittest.skipUnless(HAS_REAL_ZINC, "Real Zinc adapter is unavailable")
|
||||
class TestDITAv2RealZinc(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.prefix = f"dita_v2_{os.getpid()}_{uuid4().hex}"
|
||||
self.writer = RealZincPlane(prefix=self.prefix, slot_count=3, create=True)
|
||||
self.reader = RealZincPlane(prefix=self.prefix, slot_count=3, create=False)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.writer.close()
|
||||
self.reader.close()
|
||||
|
||||
def _slot_dicts(self, plane: RealZincPlane) -> list[dict[str, object]]:
|
||||
return [slot.to_dict() for slot in plane.read_slots()]
|
||||
|
||||
def test_wait_notify_and_roundtrip(self) -> None:
|
||||
waiter_started = threading.Event()
|
||||
waiter_result: dict[str, bool] = {"ok": False}
|
||||
|
||||
def _waiter() -> None:
|
||||
waiter_started.set()
|
||||
waiter_result["ok"] = self.reader.wait_on_state(timeout_ms=3000)
|
||||
|
||||
thread = threading.Thread(target=_waiter, daemon=True)
|
||||
thread.start()
|
||||
self.assertTrue(waiter_started.wait(timeout=2.0))
|
||||
time.sleep(0.05)
|
||||
|
||||
kernel_slot = self.writer.read_slots()
|
||||
self.assertEqual(len(kernel_slot), 3)
|
||||
self.assertTrue(all(slot.fsm_state == TradeStage.IDLE for slot in kernel_slot))
|
||||
self.writer.write_slot(
|
||||
TradeSlot(
|
||||
slot_id=0,
|
||||
trade_id="trade-zinc-1",
|
||||
asset="BTCUSDT",
|
||||
side=TradeSide.SHORT,
|
||||
entry_price=100.0,
|
||||
size=1.0,
|
||||
initial_size=1.0,
|
||||
leverage=2.0,
|
||||
fsm_state=TradeStage.POSITION_OPEN,
|
||||
)
|
||||
)
|
||||
thread.join(timeout=3.0)
|
||||
self.assertFalse(thread.is_alive())
|
||||
self.assertTrue(waiter_result["ok"])
|
||||
slots = self.reader.read_slots()
|
||||
self.assertEqual(len(slots), 3)
|
||||
self.assertEqual(slots[0].trade_id, "trade-zinc-1")
|
||||
self.assertEqual(slots[0].fsm_state, TradeStage.POSITION_OPEN)
|
||||
self.assertTrue(all(slot.fsm_state == TradeStage.IDLE for slot in slots[1:]))
|
||||
|
||||
def test_in_memory_wait_notify_matches_signal_semantics(self) -> None:
|
||||
plane = InMemoryZincPlane()
|
||||
waiter_started = threading.Event()
|
||||
waiter_result: dict[str, bool] = {"ok": False}
|
||||
|
||||
def _waiter() -> None:
|
||||
waiter_started.set()
|
||||
waiter_result["ok"] = plane.wait_on_state(timeout_ms=2000)
|
||||
|
||||
thread = threading.Thread(target=_waiter, daemon=True)
|
||||
thread.start()
|
||||
self.assertTrue(waiter_started.wait(timeout=2.0))
|
||||
time.sleep(0.05)
|
||||
plane.write_slot(
|
||||
TradeSlot(
|
||||
slot_id=0,
|
||||
trade_id="trade-signal",
|
||||
asset="BTCUSDT",
|
||||
side=TradeSide.LONG,
|
||||
entry_price=101.0,
|
||||
size=1.0,
|
||||
initial_size=1.0,
|
||||
leverage=2.0,
|
||||
fsm_state=TradeStage.POSITION_OPEN,
|
||||
)
|
||||
)
|
||||
thread.join(timeout=3.0)
|
||||
self.assertFalse(thread.is_alive())
|
||||
self.assertTrue(waiter_result["ok"])
|
||||
|
||||
def test_real_control_plane_roundtrip_uses_open_existing_region(self) -> None:
|
||||
prefix = f"dita_v2_control_{os.getpid()}_{uuid4().hex}"
|
||||
plane = RealZincPlane(prefix=prefix, slot_count=1, create=True)
|
||||
control = RealZincControlPlane(prefix=prefix, create=False)
|
||||
try:
|
||||
snapshot = control.read()
|
||||
self.assertEqual(getattr(snapshot.mode, "value", snapshot.mode), KernelMode.NORMAL.value)
|
||||
self.assertEqual(getattr(snapshot.verbosity, "value", snapshot.verbosity), KernelVerbosity.QUIET.value)
|
||||
updated = control.update(ControlUpdate(mode=KernelMode.DEBUG, verbosity=KernelVerbosity.TRACE))
|
||||
self.assertEqual(getattr(updated.mode, "value", updated.mode), KernelMode.DEBUG.value)
|
||||
self.assertEqual(getattr(updated.verbosity, "value", updated.verbosity), KernelVerbosity.TRACE.value)
|
||||
mirrored = plane.read_control()
|
||||
self.assertEqual(getattr(mirrored.mode, "value", mirrored.mode), KernelMode.DEBUG.value)
|
||||
self.assertEqual(getattr(mirrored.verbosity, "value", mirrored.verbosity), KernelVerbosity.TRACE.value)
|
||||
finally:
|
||||
control.close()
|
||||
plane.close()
|
||||
|
||||
def test_real_control_plane_create_conflicts_with_existing_zinc_plane(self) -> None:
|
||||
prefix = f"dita_v2_conflict_{os.getpid()}_{uuid4().hex}"
|
||||
plane = RealZincPlane(prefix=prefix, slot_count=1, create=True)
|
||||
try:
|
||||
with self.assertRaises(FileExistsError):
|
||||
RealZincControlPlane(prefix=prefix, create=True)
|
||||
finally:
|
||||
plane.close()
|
||||
|
||||
def test_kernel_accepts_real_control_plane_snapshot_strings(self) -> None:
|
||||
prefix = f"dita_v2_kernel_real_cp_{os.getpid()}_{uuid4().hex}"
|
||||
plane = RealZincPlane(prefix=prefix, slot_count=1, create=True)
|
||||
control = RealZincControlPlane(prefix=prefix, create=False)
|
||||
try:
|
||||
kernel = ExecutionKernel(
|
||||
max_slots=1,
|
||||
control_plane=control,
|
||||
venue=MockVenueAdapter(MockVenueScenario(emit_fill_on_submit=True, partial_fill_ratio=1.0)),
|
||||
zinc_plane=plane,
|
||||
)
|
||||
kernel.update_control(
|
||||
ControlUpdate(
|
||||
mode=KernelMode.DEBUG,
|
||||
verbosity=KernelVerbosity.TRACE,
|
||||
trace_transitions=True,
|
||||
)
|
||||
)
|
||||
outcome = kernel.process_intent(
|
||||
KernelIntent(
|
||||
**mk_intent_kwargs(
|
||||
slot_id=0,
|
||||
trade_id=f"trade-real-cp-{uuid4().hex}",
|
||||
action=KernelCommandType.ENTER,
|
||||
price=100.0,
|
||||
size=1.0,
|
||||
)
|
||||
)
|
||||
)
|
||||
self.assertTrue(outcome.accepted)
|
||||
self.assertEqual(outcome.diagnostic_code, KernelDiagnosticCode.OK)
|
||||
self.assertEqual(kernel.slot(0).fsm_state, TradeStage.POSITION_OPEN)
|
||||
self.assertEqual(getattr(kernel.control.mode, "value", kernel.control.mode), KernelMode.DEBUG.value)
|
||||
self.assertEqual(getattr(kernel.control.verbosity, "value", kernel.control.verbosity), KernelVerbosity.TRACE.value)
|
||||
finally:
|
||||
control.close()
|
||||
plane.close()
|
||||
|
||||
def test_kernel_and_zinc_fuzz_roundtrip_150_checks(self) -> None:
|
||||
control = InMemoryControlPlane(
|
||||
KernelControlSnapshot(mode=KernelMode.DEBUG, verbosity=KernelVerbosity.TRACE)
|
||||
)
|
||||
kernel = ExecutionKernel(
|
||||
max_slots=3,
|
||||
control_plane=control,
|
||||
venue=MockVenueAdapter(MockVenueScenario(emit_fill_on_submit=True, partial_fill_ratio=1.0)),
|
||||
zinc_plane=self.writer,
|
||||
)
|
||||
rng = random.Random(20260526)
|
||||
|
||||
for i in range(150):
|
||||
slot_id = rng.randrange(0, 3)
|
||||
slot = kernel.slot(slot_id)
|
||||
op = rng.choice(
|
||||
[
|
||||
"enter",
|
||||
"exit",
|
||||
"mark",
|
||||
"reconcile",
|
||||
"control",
|
||||
"event",
|
||||
]
|
||||
)
|
||||
|
||||
with self.subTest(iteration=i, slot=slot_id, op=op):
|
||||
if op == "enter":
|
||||
if slot.is_free():
|
||||
kernel.process_intent(
|
||||
KernelIntent(
|
||||
**mk_intent_kwargs(
|
||||
slot_id=slot_id,
|
||||
trade_id=f"trade-{slot_id}-{i}",
|
||||
action=KernelCommandType.ENTER,
|
||||
price=100.0 + rng.random(),
|
||||
size=1.0 + (rng.random() * 0.5),
|
||||
leverage=1.5 + (rng.random() * 2.0),
|
||||
)
|
||||
)
|
||||
)
|
||||
elif op == "exit":
|
||||
if slot.is_open():
|
||||
kernel.process_intent(
|
||||
KernelIntent(
|
||||
**mk_intent_kwargs(
|
||||
slot_id=slot_id,
|
||||
trade_id=slot.trade_id,
|
||||
action=KernelCommandType.EXIT,
|
||||
price=99.0 + rng.random(),
|
||||
size=max(0.1, slot.size or 0.1),
|
||||
leverage=slot.leverage or 2.0,
|
||||
)
|
||||
)
|
||||
)
|
||||
elif op == "mark":
|
||||
kernel.process_intent(
|
||||
KernelIntent(
|
||||
**mk_intent_kwargs(
|
||||
slot_id=slot_id,
|
||||
trade_id=slot.trade_id or f"trade-{slot_id}-{i}",
|
||||
action=KernelCommandType.MARK_PRICE,
|
||||
price=95.0 + rng.random() * 10.0,
|
||||
size=max(slot.size, 1.0) if slot.size > 0 else 1.0,
|
||||
leverage=slot.leverage or 2.0,
|
||||
)
|
||||
)
|
||||
)
|
||||
elif op == "reconcile":
|
||||
kernel.process_intent(
|
||||
KernelIntent(
|
||||
**mk_intent_kwargs(
|
||||
slot_id=slot_id,
|
||||
trade_id=slot.trade_id or f"trade-{slot_id}-{i}",
|
||||
action=KernelCommandType.RECONCILE,
|
||||
price=100.0,
|
||||
size=max(slot.size, 1.0) if slot.size > 0 else 1.0,
|
||||
leverage=slot.leverage or 2.0,
|
||||
)
|
||||
)
|
||||
)
|
||||
elif op == "control":
|
||||
kernel.update_control(
|
||||
ControlUpdate(
|
||||
mode=KernelMode.DEBUG if rng.random() < 0.7 else KernelMode.NORMAL,
|
||||
verbosity=KernelVerbosity.TRACE if rng.random() < 0.5 else KernelVerbosity.VERBOSE,
|
||||
trace_transitions=rng.random() < 0.5,
|
||||
)
|
||||
)
|
||||
elif op == "event":
|
||||
current = kernel.slot(slot_id)
|
||||
if current.active_entry_order is not None:
|
||||
event = VenueEvent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
event_id=f"evt-{i}-{slot_id}",
|
||||
trade_id=current.trade_id,
|
||||
slot_id=slot_id,
|
||||
kind=rng.choice(
|
||||
[
|
||||
KernelEventKind.ORDER_ACK,
|
||||
KernelEventKind.PARTIAL_FILL,
|
||||
KernelEventKind.FULL_FILL,
|
||||
]
|
||||
),
|
||||
status=rng.choice(
|
||||
[
|
||||
VenueEventStatus.ACKED,
|
||||
VenueEventStatus.PARTIALLY_FILLED,
|
||||
VenueEventStatus.FILLED,
|
||||
]
|
||||
),
|
||||
venue_order_id=current.active_entry_order.venue_order_id,
|
||||
venue_client_id=current.active_entry_order.venue_client_id,
|
||||
side=current.side,
|
||||
asset=current.asset,
|
||||
price=current.entry_price or 100.0,
|
||||
size=current.size or 1.0,
|
||||
filled_size=current.size or 1.0,
|
||||
remaining_size=0.0,
|
||||
)
|
||||
kernel.on_venue_event(event)
|
||||
elif current.active_exit_order is not None:
|
||||
event = VenueEvent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
event_id=f"evt-{i}-{slot_id}",
|
||||
trade_id=current.trade_id,
|
||||
slot_id=slot_id,
|
||||
kind=rng.choice(
|
||||
[
|
||||
KernelEventKind.PARTIAL_FILL,
|
||||
KernelEventKind.FULL_FILL,
|
||||
KernelEventKind.CANCEL_ACK,
|
||||
KernelEventKind.CANCEL_REJECT,
|
||||
]
|
||||
),
|
||||
status=rng.choice(
|
||||
[
|
||||
VenueEventStatus.PARTIALLY_FILLED,
|
||||
VenueEventStatus.FILLED,
|
||||
VenueEventStatus.CANCELED,
|
||||
VenueEventStatus.CANCELED_REJECTED,
|
||||
]
|
||||
),
|
||||
venue_order_id=current.active_exit_order.venue_order_id,
|
||||
venue_client_id=current.active_exit_order.venue_client_id,
|
||||
side=current.side,
|
||||
asset=current.asset,
|
||||
price=current.entry_price or 100.0,
|
||||
size=current.size or 1.0,
|
||||
filled_size=min(current.size or 1.0, 0.5),
|
||||
remaining_size=max(0.0, (current.size or 1.0) - 0.5),
|
||||
)
|
||||
kernel.on_venue_event(event)
|
||||
|
||||
writer_slots = self._slot_dicts(self.writer)
|
||||
reader_slots = self._slot_dicts(self.reader)
|
||||
kernel_slots = [slot.to_dict() for slot in kernel.state.slots]
|
||||
|
||||
self.assertEqual(writer_slots, reader_slots)
|
||||
self.assertEqual(reader_slots, kernel_slots)
|
||||
self.assertEqual(self.reader.read_control().mode, kernel.control.mode)
|
||||
self.assertEqual(self.reader.read_control().verbosity, kernel.control.verbosity)
|
||||
self.assertEqual(len(self.reader.read_intents()), len(self.writer.read_intents()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
313
prod/tests/test_multi_exit_retraction_contract.py
Normal file
313
prod/tests/test_multi_exit_retraction_contract.py
Normal file
@@ -0,0 +1,313 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List
|
||||
import math
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
EPS = 1e-9
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExitLeg:
|
||||
trade_id: str
|
||||
chain_root_trade_id: str
|
||||
exit_seq: int
|
||||
exit_leg_id: str
|
||||
chain_prev_leg_id: str
|
||||
chain_head_leg_id: str
|
||||
command_id: str
|
||||
fraction: float
|
||||
qty: float
|
||||
exit_price: float
|
||||
fee: float
|
||||
net_pnl: float
|
||||
remaining_after: float
|
||||
reason: str
|
||||
chain_token: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParentTrade:
|
||||
trade_id: str
|
||||
side: str # SHORT | LONG
|
||||
entry_price: float
|
||||
entry_qty: float
|
||||
remaining_qty: float
|
||||
realized_pnl_total: float = 0.0
|
||||
realized_fees_total: float = 0.0
|
||||
exit_seq: int = 0
|
||||
status: str = "OPEN" # OPEN | PARTIALLY_CLOSED | CLOSED
|
||||
version: int = 0
|
||||
legs: List[ExitLeg] = field(default_factory=list)
|
||||
chain_root_trade_id: str = ""
|
||||
chain_head_leg_id: str = ""
|
||||
chain_prev_leg_id: str = ""
|
||||
chain_token: str = ""
|
||||
|
||||
|
||||
def _chain_token(payload: dict) -> str:
|
||||
return hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str).encode()).hexdigest()
|
||||
|
||||
|
||||
class MiniRetractionRuntime:
|
||||
"""
|
||||
Contract-reference runtime:
|
||||
- all partial exits route through one handler
|
||||
- idempotent by command_id
|
||||
- financial accumulation by executed leg
|
||||
"""
|
||||
|
||||
def __init__(self, fee_rate: float = 0.00055):
|
||||
self.fee_rate = fee_rate
|
||||
self.capital = 25_000.0
|
||||
self.trades: Dict[str, ParentTrade] = {}
|
||||
self.applied_commands: Dict[str, ExitLeg] = {}
|
||||
|
||||
def open_trade(self, trade_id: str, side: str, entry_price: float, qty: float) -> None:
|
||||
assert trade_id not in self.trades
|
||||
t = ParentTrade(
|
||||
trade_id=trade_id,
|
||||
side=side,
|
||||
entry_price=entry_price,
|
||||
entry_qty=qty,
|
||||
remaining_qty=qty,
|
||||
)
|
||||
t.chain_root_trade_id = trade_id
|
||||
t.chain_head_leg_id = f"{trade_id}:open"
|
||||
t.chain_prev_leg_id = ""
|
||||
t.chain_token = _chain_token({
|
||||
"trade_id": trade_id,
|
||||
"chain_root_trade_id": trade_id,
|
||||
"chain_head_leg_id": t.chain_head_leg_id,
|
||||
"chain_prev_leg_id": "",
|
||||
"chain_seq": 0,
|
||||
"side": side,
|
||||
"entry_price": entry_price,
|
||||
"entry_qty": qty,
|
||||
"remaining_qty": qty,
|
||||
"realized_pnl_total": 0.0,
|
||||
"realized_fees_total": 0.0,
|
||||
})
|
||||
self.trades[trade_id] = t
|
||||
|
||||
def retract(
|
||||
self,
|
||||
trade_id: str,
|
||||
*,
|
||||
command_id: str,
|
||||
fraction: float,
|
||||
exit_price: float,
|
||||
reason: str,
|
||||
) -> ExitLeg | None:
|
||||
if command_id in self.applied_commands:
|
||||
return self.applied_commands[command_id]
|
||||
if not (0 < fraction <= 1.0):
|
||||
return None
|
||||
t = self.trades.get(trade_id)
|
||||
if not t or t.status == "CLOSED":
|
||||
return None
|
||||
expected = _chain_token({
|
||||
"trade_id": t.trade_id,
|
||||
"chain_root_trade_id": t.chain_root_trade_id or t.trade_id,
|
||||
"chain_head_leg_id": t.chain_head_leg_id or f"{t.trade_id}:open",
|
||||
"chain_prev_leg_id": t.chain_prev_leg_id or "",
|
||||
"chain_seq": t.exit_seq,
|
||||
"side": t.side,
|
||||
"entry_price": t.entry_price,
|
||||
"entry_qty": t.entry_qty,
|
||||
"remaining_qty": t.remaining_qty,
|
||||
"realized_pnl_total": t.realized_pnl_total,
|
||||
"realized_fees_total": t.realized_fees_total,
|
||||
})
|
||||
if t.chain_token and t.chain_token != expected:
|
||||
return None
|
||||
requested_qty = t.remaining_qty * fraction
|
||||
qty = min(max(requested_qty, 0.0), t.remaining_qty)
|
||||
if qty <= EPS:
|
||||
return None
|
||||
|
||||
sign = -1.0 if t.side == "SHORT" else 1.0
|
||||
gross = sign * (exit_price - t.entry_price) * qty
|
||||
fee = self.fee_rate * (t.entry_price * qty + exit_price * qty)
|
||||
net = gross - fee
|
||||
|
||||
t.exit_seq += 1
|
||||
t.version += 1
|
||||
t.remaining_qty = max(0.0, t.remaining_qty - qty)
|
||||
t.realized_pnl_total += net
|
||||
t.realized_fees_total += fee
|
||||
self.capital += net
|
||||
t.status = "CLOSED" if t.remaining_qty <= EPS else "PARTIALLY_CLOSED"
|
||||
prev_head = t.chain_head_leg_id or f"{t.trade_id}:open"
|
||||
t.chain_prev_leg_id = prev_head
|
||||
t.chain_head_leg_id = f"{t.trade_id}:x{t.exit_seq:03d}"
|
||||
t.chain_token = _chain_token({
|
||||
"trade_id": t.trade_id,
|
||||
"chain_root_trade_id": t.chain_root_trade_id or t.trade_id,
|
||||
"chain_head_leg_id": t.chain_head_leg_id,
|
||||
"chain_prev_leg_id": prev_head,
|
||||
"chain_seq": t.exit_seq,
|
||||
"side": t.side,
|
||||
"entry_price": t.entry_price,
|
||||
"entry_qty": t.entry_qty,
|
||||
"remaining_qty": t.remaining_qty,
|
||||
"realized_pnl_total": t.realized_pnl_total,
|
||||
"realized_fees_total": t.realized_fees_total,
|
||||
})
|
||||
|
||||
leg = ExitLeg(
|
||||
trade_id=t.trade_id,
|
||||
chain_root_trade_id=t.chain_root_trade_id or t.trade_id,
|
||||
exit_seq=t.exit_seq,
|
||||
exit_leg_id=f"{t.trade_id}:x{t.exit_seq:03d}",
|
||||
chain_prev_leg_id=prev_head,
|
||||
chain_head_leg_id=t.chain_head_leg_id,
|
||||
command_id=command_id,
|
||||
fraction=fraction,
|
||||
qty=qty,
|
||||
exit_price=exit_price,
|
||||
fee=fee,
|
||||
net_pnl=net,
|
||||
remaining_after=t.remaining_qty,
|
||||
reason=reason,
|
||||
chain_token=t.chain_token,
|
||||
)
|
||||
t.legs.append(leg)
|
||||
self.applied_commands[command_id] = leg
|
||||
return leg
|
||||
|
||||
|
||||
def _assert_parent_invariants(t: ParentTrade) -> None:
|
||||
total_qty = sum(l.qty for l in t.legs)
|
||||
assert total_qty <= t.entry_qty + EPS
|
||||
assert math.isclose(t.remaining_qty, max(0.0, t.entry_qty - total_qty), abs_tol=1e-8)
|
||||
assert math.isclose(t.realized_pnl_total, sum(l.net_pnl for l in t.legs), rel_tol=0, abs_tol=1e-8)
|
||||
assert math.isclose(t.realized_fees_total, sum(l.fee for l in t.legs), rel_tol=0, abs_tol=1e-8)
|
||||
if t.legs:
|
||||
assert t.chain_head_leg_id == t.legs[-1].exit_leg_id
|
||||
assert t.chain_token == t.legs[-1].chain_token
|
||||
if t.remaining_qty <= EPS:
|
||||
assert t.status == "CLOSED"
|
||||
elif t.legs:
|
||||
assert t.status == "PARTIALLY_CLOSED"
|
||||
else:
|
||||
assert t.status == "OPEN"
|
||||
|
||||
|
||||
def test_retract_default_half_then_close_preserves_lineage_and_math() -> None:
|
||||
rt = MiniRetractionRuntime()
|
||||
rt.open_trade("T1", "SHORT", 100.0, 10.0)
|
||||
|
||||
l1 = rt.retract("T1", command_id="c1", fraction=0.5, exit_price=99.0, reason="HOTKEY_RETRACT")
|
||||
assert l1 is not None
|
||||
assert l1.exit_leg_id == "T1:x001"
|
||||
assert l1.qty == 5.0
|
||||
assert l1.chain_prev_leg_id == "T1:open"
|
||||
assert l1.chain_head_leg_id == "T1:x001"
|
||||
|
||||
l2 = rt.retract("T1", command_id="c2", fraction=1.0, exit_price=98.5, reason="HOTKEY_RETRACT")
|
||||
assert l2 is not None
|
||||
assert l2.exit_leg_id == "T1:x002"
|
||||
assert l2.qty == 5.0
|
||||
assert l2.chain_prev_leg_id == "T1:x001"
|
||||
assert l2.chain_head_leg_id == "T1:x002"
|
||||
|
||||
t = rt.trades["T1"]
|
||||
_assert_parent_invariants(t)
|
||||
assert t.status == "CLOSED"
|
||||
assert t.exit_seq == 2
|
||||
|
||||
|
||||
def test_idempotent_command_does_not_double_execute() -> None:
|
||||
rt = MiniRetractionRuntime()
|
||||
rt.open_trade("T2", "SHORT", 50.0, 20.0)
|
||||
first = rt.retract("T2", command_id="dup", fraction=0.5, exit_price=49.8, reason="V7_RETRACT")
|
||||
second = rt.retract("T2", command_id="dup", fraction=0.5, exit_price=49.7, reason="V7_RETRACT")
|
||||
assert first is not None
|
||||
assert second is not None
|
||||
assert first.exit_leg_id == second.exit_leg_id
|
||||
assert len(rt.trades["T2"].legs) == 1
|
||||
_assert_parent_invariants(rt.trades["T2"])
|
||||
|
||||
|
||||
def test_invalid_fraction_rejected() -> None:
|
||||
rt = MiniRetractionRuntime()
|
||||
rt.open_trade("T3", "SHORT", 10.0, 10.0)
|
||||
assert rt.retract("T3", command_id="a", fraction=0.0, exit_price=9.9, reason="HOTKEY_RETRACT") is None
|
||||
assert rt.retract("T3", command_id="b", fraction=1.5, exit_price=9.9, reason="HOTKEY_RETRACT") is None
|
||||
assert len(rt.trades["T3"].legs) == 0
|
||||
_assert_parent_invariants(rt.trades["T3"])
|
||||
|
||||
|
||||
def test_long_side_accounting_sign_is_correct() -> None:
|
||||
rt = MiniRetractionRuntime()
|
||||
rt.open_trade("T4", "LONG", 100.0, 2.0)
|
||||
leg = rt.retract("T4", command_id="c", fraction=1.0, exit_price=101.0, reason="HOTKEY_RETRACT")
|
||||
assert leg is not None
|
||||
assert leg.net_pnl > 0
|
||||
_assert_parent_invariants(rt.trades["T4"])
|
||||
|
||||
|
||||
def test_over_many_random_partial_exits_invariants_hold() -> None:
|
||||
rng = random.Random(1337)
|
||||
rt = MiniRetractionRuntime()
|
||||
rt.open_trade("T5", "SHORT", 120.0, 30.0)
|
||||
|
||||
for i in range(200):
|
||||
t = rt.trades["T5"]
|
||||
if t.status == "CLOSED":
|
||||
break
|
||||
# Biased toward smaller retractions; occasionally force full close
|
||||
frac = 1.0 if i % 37 == 0 else max(0.01, min(0.99, rng.random() * 0.6))
|
||||
px = 120.0 - rng.random() * 2.0
|
||||
rt.retract("T5", command_id=f"cmd-{i}", fraction=frac, exit_price=px, reason="V7_RETRACT")
|
||||
_assert_parent_invariants(rt.trades["T5"])
|
||||
|
||||
t = rt.trades["T5"]
|
||||
# Must eventually close under forced 1.0 fractions
|
||||
assert t.status == "CLOSED"
|
||||
_assert_parent_invariants(t)
|
||||
|
||||
|
||||
def test_capital_updates_are_leg_immediate() -> None:
|
||||
rt = MiniRetractionRuntime()
|
||||
start = rt.capital
|
||||
rt.open_trade("T6", "SHORT", 200.0, 4.0)
|
||||
l1 = rt.retract("T6", command_id="r1", fraction=0.5, exit_price=199.0, reason="HOTKEY_RETRACT")
|
||||
assert l1 is not None
|
||||
mid = rt.capital
|
||||
assert not math.isclose(mid, start, abs_tol=1e-12)
|
||||
l2 = rt.retract("T6", command_id="r2", fraction=1.0, exit_price=198.5, reason="HOTKEY_RETRACT")
|
||||
assert l2 is not None
|
||||
assert math.isclose(rt.capital, start + l1.net_pnl + l2.net_pnl, rel_tol=0, abs_tol=1e-8)
|
||||
|
||||
|
||||
def test_command_on_closed_trade_is_noop() -> None:
|
||||
rt = MiniRetractionRuntime()
|
||||
rt.open_trade("T7", "SHORT", 100.0, 1.0)
|
||||
rt.retract("T7", command_id="x1", fraction=1.0, exit_price=99.7, reason="HOTKEY_RETRACT")
|
||||
t = rt.trades["T7"]
|
||||
assert t.status == "CLOSED"
|
||||
n = len(t.legs)
|
||||
out = rt.retract("T7", command_id="x2", fraction=0.5, exit_price=99.6, reason="HOTKEY_RETRACT")
|
||||
assert out is None
|
||||
assert len(t.legs) == n
|
||||
_assert_parent_invariants(t)
|
||||
|
||||
|
||||
def test_tampered_chain_head_is_rejected() -> None:
|
||||
rt = MiniRetractionRuntime()
|
||||
rt.open_trade("T8", "SHORT", 75.0, 8.0)
|
||||
first = rt.retract("T8", command_id="c1", fraction=0.5, exit_price=74.5, reason="HOTKEY_RETRACT")
|
||||
assert first is not None
|
||||
rt.trades["T8"].chain_head_leg_id = "T8:x999"
|
||||
second = rt.retract("T8", command_id="c2", fraction=0.5, exit_price=74.0, reason="HOTKEY_RETRACT")
|
||||
assert second is None
|
||||
with pytest.raises(AssertionError):
|
||||
_assert_parent_invariants(rt.trades["T8"])
|
||||
202
prod/tests/test_multi_exit_retraction_fuzz.py
Normal file
202
prod/tests/test_multi_exit_retraction_fuzz.py
Normal file
@@ -0,0 +1,202 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import random
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_MOD_PATH = Path("/mnt/dolphinng5_predict/prod/nautilus_event_trader.py")
|
||||
_SPEC = importlib.util.spec_from_file_location("nautilus_event_trader_mod", _MOD_PATH)
|
||||
assert _SPEC and _SPEC.loader
|
||||
mod = importlib.util.module_from_spec(_SPEC)
|
||||
_SPEC.loader.exec_module(mod) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Pos:
|
||||
trade_id: str
|
||||
asset: str
|
||||
entry_price: float
|
||||
notional: float
|
||||
current_price: float = 0.0
|
||||
pnl_pct: float = 0.0
|
||||
|
||||
|
||||
class _ExitMgr:
|
||||
def __init__(self):
|
||||
self._positions: dict[str, dict] = {}
|
||||
|
||||
|
||||
class _Eng:
|
||||
def __init__(self, pos: _Pos | None):
|
||||
self.position = pos
|
||||
self.capital = 25_000.0
|
||||
self.exit_manager = _ExitMgr()
|
||||
if pos:
|
||||
self.exit_manager._positions[pos.trade_id] = {"dummy": True}
|
||||
|
||||
|
||||
class _Map:
|
||||
def __init__(self):
|
||||
self._d = {"blue_runtime_commands": "[]"}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def blocking(self):
|
||||
return self
|
||||
|
||||
def get(self, key):
|
||||
with self._lock:
|
||||
return self._d.get(key)
|
||||
|
||||
def put(self, key, val):
|
||||
with self._lock:
|
||||
self._d[key] = val
|
||||
class _F:
|
||||
def add_done_callback(self, _cb):
|
||||
return None
|
||||
return _F()
|
||||
|
||||
|
||||
def _mk_trader():
|
||||
t = object.__new__(mod.DolphinLiveTrader)
|
||||
t.eng_lock = threading.Lock()
|
||||
t.control_map = _Map()
|
||||
t._processed_retract_commands = mod.deque(maxlen=5000)
|
||||
t._processed_retract_set = set()
|
||||
t._pending_entries = {}
|
||||
t.current_day = "2026-05-12"
|
||||
t.bar_idx = 100
|
||||
return t
|
||||
|
||||
|
||||
def _install_open_position(t, *, trade_id="T", asset="STXUSDT", entry_price=1.0, notional=1000.0):
|
||||
p = _Pos(trade_id, asset, entry_price, notional, current_price=entry_price)
|
||||
t.eng = _Eng(p)
|
||||
t._pending_entries[trade_id] = {
|
||||
"trade_id": trade_id,
|
||||
"asset": asset,
|
||||
"side": "SHORT",
|
||||
"entry_price": entry_price,
|
||||
"entry_bar": 90,
|
||||
"entry_date": "2026-05-12",
|
||||
"notional": notional,
|
||||
"notional_entry": notional,
|
||||
"retraction_legs": 0,
|
||||
"realized_pnl_legs_total": 0.0,
|
||||
}
|
||||
t._pending_entries[trade_id].update(t._chain_state_for_pending(
|
||||
trade_id,
|
||||
t._pending_entries[trade_id],
|
||||
chain_mode="LIVE",
|
||||
chain_head_leg_id=f"{trade_id}:open",
|
||||
chain_prev_leg_id="",
|
||||
chain_seq=0,
|
||||
))
|
||||
|
||||
|
||||
def test_fuzz_retraction_invariants_hold_under_random_command_stream(monkeypatch):
|
||||
monkeypatch.setattr(mod, "ch_put", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(mod, "_ch_ts_us", lambda: 123)
|
||||
|
||||
rng = random.Random(20260512)
|
||||
t = _mk_trader()
|
||||
_install_open_position(t, trade_id="T-FUZZ", asset="STXUSDT", entry_price=1.0, notional=10_000.0)
|
||||
|
||||
seen_ids: set[str] = set()
|
||||
baseline_cap = t.eng.capital
|
||||
|
||||
for i in range(2500):
|
||||
if t.eng.position is None:
|
||||
break
|
||||
px = max(0.00001, 1.0 + rng.uniform(-0.25, 0.25))
|
||||
# Mix valid and invalid commands.
|
||||
frac_choice = rng.choice([
|
||||
rng.uniform(0.01, 1.0), # valid
|
||||
0.0, # invalid
|
||||
-0.1, # invalid
|
||||
1.2, # invalid
|
||||
])
|
||||
# inject duplicate ids often
|
||||
if i > 0 and rng.random() < 0.2:
|
||||
cid = rng.choice(tuple(seen_ids)) if seen_ids else f"c-{i}"
|
||||
else:
|
||||
cid = f"c-{i}-{rng.randint(0, 999)}"
|
||||
seen_ids.add(cid)
|
||||
# wrong trade ids sometimes
|
||||
tid = "T-FUZZ" if rng.random() < 0.8 else f"OTHER-{i}"
|
||||
pending = t._pending_entries["T-FUZZ"]
|
||||
cmd = {
|
||||
"command_id": cid,
|
||||
"trade_id": tid,
|
||||
"action": "RETRACT",
|
||||
"fraction": frac_choice,
|
||||
"reason": "HOTKEY_RETRACT",
|
||||
"source": "fuzz",
|
||||
"chain_root_trade_id": pending["chain_root_trade_id"],
|
||||
"chain_head_leg_id": pending["chain_head_leg_id"],
|
||||
"chain_prev_leg_id": pending["chain_prev_leg_id"],
|
||||
"chain_seq": pending["chain_seq"],
|
||||
"chain_token": pending["chain_token"],
|
||||
}
|
||||
t.control_map.put("blue_runtime_commands", json.dumps([cmd]))
|
||||
t._process_runtime_commands({"STXUSDT": px})
|
||||
|
||||
if t.eng.position is not None:
|
||||
n = float(t.eng.position.notional)
|
||||
assert n >= -1e-8
|
||||
# never exceed original notional
|
||||
assert n <= 10_000.0 + 1e-8
|
||||
p = t._pending_entries["T-FUZZ"]
|
||||
assert int(p.get("retraction_legs", 0) or 0) >= 0
|
||||
|
||||
# Capital must stay finite and deterministic.
|
||||
assert t.eng.capital == pytest.approx(float(t.eng.capital))
|
||||
assert abs(t.eng.capital - baseline_cap) < 1e7
|
||||
|
||||
|
||||
def test_fuzz_concurrent_queue_submission_and_drain(monkeypatch):
|
||||
monkeypatch.setattr(mod, "ch_put", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(mod, "_ch_ts_us", lambda: 123)
|
||||
rng = random.Random(777)
|
||||
t = _mk_trader()
|
||||
_install_open_position(t, trade_id="T-RACE", asset="DASHUSDT", entry_price=10.0, notional=5000.0)
|
||||
|
||||
def producer(start: int, count: int):
|
||||
for i in range(start, start + count):
|
||||
with t.control_map._lock:
|
||||
raw = t.control_map._d.get("blue_runtime_commands", "[]")
|
||||
q = json.loads(raw) if raw else []
|
||||
q.append({
|
||||
"command_id": f"p-{i}",
|
||||
"trade_id": "T-RACE" if rng.random() < 0.9 else "OTHER",
|
||||
"action": "RETRACT",
|
||||
"fraction": rng.uniform(0.01, 1.0),
|
||||
"reason": "HOTKEY_RETRACT",
|
||||
"source": "race",
|
||||
"chain_root_trade_id": t._pending_entries["T-RACE"]["chain_root_trade_id"],
|
||||
"chain_head_leg_id": t._pending_entries["T-RACE"]["chain_head_leg_id"],
|
||||
"chain_prev_leg_id": t._pending_entries["T-RACE"]["chain_prev_leg_id"],
|
||||
"chain_seq": t._pending_entries["T-RACE"]["chain_seq"],
|
||||
"chain_token": t._pending_entries["T-RACE"]["chain_token"],
|
||||
})
|
||||
t.control_map._d["blue_runtime_commands"] = json.dumps(q[-200:])
|
||||
|
||||
threads = [threading.Thread(target=producer, args=(k * 120, 120)) for k in range(4)]
|
||||
for th in threads:
|
||||
th.start()
|
||||
for th in threads:
|
||||
th.join()
|
||||
|
||||
# Drain repeatedly; must not throw and must preserve invariants.
|
||||
for _ in range(50):
|
||||
if t.eng.position is None:
|
||||
break
|
||||
t._process_runtime_commands({"DASHUSDT": rng.uniform(8.0, 12.0)})
|
||||
|
||||
if t.eng.position is not None:
|
||||
assert t.eng.position.notional >= -1e-8
|
||||
assert t.eng.position.notional <= 5000.0 + 1e-8
|
||||
394
prod/tests/test_multi_exit_retraction_integration.py
Normal file
394
prod/tests/test_multi_exit_retraction_integration.py
Normal file
@@ -0,0 +1,394 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_MOD_PATH = Path("/mnt/dolphinng5_predict/prod/nautilus_event_trader.py")
|
||||
_SPEC = importlib.util.spec_from_file_location("nautilus_event_trader_mod", _MOD_PATH)
|
||||
assert _SPEC and _SPEC.loader
|
||||
mod = importlib.util.module_from_spec(_SPEC)
|
||||
_SPEC.loader.exec_module(mod) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Pos:
|
||||
trade_id: str
|
||||
asset: str
|
||||
entry_price: float
|
||||
notional: float
|
||||
current_price: float = 0.0
|
||||
pnl_pct: float = 0.0
|
||||
|
||||
|
||||
class _ExitMgr:
|
||||
def __init__(self) -> None:
|
||||
self._positions: dict[str, dict] = {}
|
||||
|
||||
|
||||
class _Eng:
|
||||
def __init__(self, pos: _Pos | None, capital: float = 25_000.0) -> None:
|
||||
self.position = pos
|
||||
self.capital = capital
|
||||
self.exit_manager = _ExitMgr()
|
||||
if pos is not None:
|
||||
self.exit_manager._positions[pos.trade_id] = {"dummy": True}
|
||||
|
||||
|
||||
class _Map:
|
||||
def __init__(self, initial: dict | None = None) -> None:
|
||||
self._d = dict(initial or {})
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def blocking(self):
|
||||
return self
|
||||
|
||||
def get(self, key):
|
||||
with self._lock:
|
||||
return self._d.get(key)
|
||||
|
||||
def put(self, key, val):
|
||||
with self._lock:
|
||||
self._d[key] = val
|
||||
class _F:
|
||||
def add_done_callback(self, _cb):
|
||||
return None
|
||||
return _F()
|
||||
|
||||
|
||||
def _mk_trader() -> mod.DolphinLiveTrader:
|
||||
t = object.__new__(mod.DolphinLiveTrader)
|
||||
tmpdir = Path(tempfile.mkdtemp(prefix="dolphin_retract_test_"))
|
||||
mod.CAPITAL_DISK_CHECKPOINT = tmpdir / "capital_checkpoint.json"
|
||||
mod.CAPITAL_CORRECTIVE_REPLAY = tmpdir / "capital_replay.json"
|
||||
mod.CAPITAL_UPDATE_LEDGER = tmpdir / "capital_update_ledger.json"
|
||||
t.eng_lock = threading.Lock()
|
||||
t.state_map = _Map({})
|
||||
t.pnl_map = _Map({})
|
||||
t.control_map = _Map({"blue_runtime_commands": "[]"})
|
||||
t._processed_retract_commands = mod.deque(maxlen=5000)
|
||||
t._processed_retract_set = set()
|
||||
t._pending_entries = {}
|
||||
t.current_day = "2026-05-12"
|
||||
t.bar_idx = 100
|
||||
return t
|
||||
|
||||
|
||||
def _seed_chain(t: mod.DolphinLiveTrader, trade_id: str) -> None:
|
||||
pending = t._pending_entries[trade_id]
|
||||
pending.update(t._chain_state_for_pending(
|
||||
trade_id,
|
||||
pending,
|
||||
chain_mode="LIVE",
|
||||
chain_head_leg_id=f"{trade_id}:open",
|
||||
chain_prev_leg_id="",
|
||||
chain_seq=0,
|
||||
))
|
||||
|
||||
|
||||
def _retract_cmd(t: mod.DolphinLiveTrader, trade_id: str, *, command_id: str, fraction: float, reason: str) -> dict:
|
||||
pending = t._pending_entries[trade_id]
|
||||
return {
|
||||
"command_id": command_id,
|
||||
"trade_id": trade_id,
|
||||
"action": "RETRACT",
|
||||
"fraction": fraction,
|
||||
"reason": reason,
|
||||
"source": "tui_hotkey",
|
||||
"chain_root_trade_id": pending["chain_root_trade_id"],
|
||||
"chain_head_leg_id": pending["chain_head_leg_id"],
|
||||
"chain_prev_leg_id": pending["chain_prev_leg_id"],
|
||||
"chain_seq": pending["chain_seq"],
|
||||
"chain_token": pending["chain_token"],
|
||||
}
|
||||
|
||||
|
||||
def test_runtime_command_partial_exit_updates_position_and_capital(monkeypatch):
|
||||
rows = []
|
||||
monkeypatch.setattr(mod, "ch_put", lambda table, row: rows.append((table, row)))
|
||||
monkeypatch.setattr(mod, "_ch_ts_us", lambda: 123456789)
|
||||
|
||||
t = _mk_trader()
|
||||
pos = _Pos("T-1", "STXUSDT", 1.0, 1000.0, current_price=0.95)
|
||||
t.eng = _Eng(pos, capital=25000.0)
|
||||
t._pending_entries["T-1"] = {
|
||||
"asset": "STXUSDT",
|
||||
"side": "SHORT",
|
||||
"entry_price": 1.0,
|
||||
"entry_bar": 90,
|
||||
"entry_date": "2026-05-12",
|
||||
"notional": 1000.0,
|
||||
"notional_entry": 1000.0,
|
||||
"retraction_legs": 0,
|
||||
"realized_pnl_legs_total": 0.0,
|
||||
}
|
||||
_seed_chain(t, "T-1")
|
||||
cmd = _retract_cmd(t, "T-1", command_id="c-1", fraction=0.5, reason="HOTKEY_RETRACT")
|
||||
t.control_map.put("blue_runtime_commands", json.dumps([cmd]))
|
||||
forced = t._process_runtime_commands({"STXUSDT": 0.95})
|
||||
|
||||
assert forced is None
|
||||
assert t.eng.position is not None
|
||||
assert pytest.approx(t.eng.position.notional, abs=1e-9) == 500.0
|
||||
assert t._pending_entries["T-1"]["retraction_legs"] == 1
|
||||
assert pytest.approx(t._pending_entries["T-1"]["realized_pnl_legs_total"], abs=1e-9) == 25.0
|
||||
assert pytest.approx(t.eng.capital, abs=1e-9) == 25025.0
|
||||
assert any(tbl == "trade_exit_legs" for tbl, _ in rows)
|
||||
recon_rows = [r for tbl, r in rows if tbl == "trade_reconstruction"]
|
||||
assert recon_rows
|
||||
assert any(json.loads(r["payload_json"]).get("chain", {}).get("chain_token") for r in recon_rows)
|
||||
assert any(tbl == "hotkey_audit" and r["result"] == "PARTIAL_OK" for tbl, r in rows)
|
||||
|
||||
stale_cmd = dict(cmd)
|
||||
stale_cmd["command_id"] = "c-1-stale"
|
||||
t.control_map.put("blue_runtime_commands", json.dumps([stale_cmd]))
|
||||
t._process_runtime_commands({"STXUSDT": 0.94})
|
||||
assert pytest.approx(t.eng.position.notional, abs=1e-9) == 500.0
|
||||
assert any(tbl == "hotkey_audit" and "CHAIN_MISMATCH" in r["result"] for tbl, r in rows)
|
||||
|
||||
|
||||
def test_runtime_command_full_close_returns_forced_exit(monkeypatch):
|
||||
monkeypatch.setattr(mod, "ch_put", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(mod, "_ch_ts_us", lambda: 123)
|
||||
|
||||
t = _mk_trader()
|
||||
pos = _Pos("T-2", "FETUSDT", 2.0, 200.0, current_price=1.9)
|
||||
t.eng = _Eng(pos, capital=1000.0)
|
||||
t._pending_entries["T-2"] = {
|
||||
"asset": "FETUSDT",
|
||||
"side": "SHORT",
|
||||
"entry_price": 2.0,
|
||||
"entry_bar": 95,
|
||||
"entry_date": "2026-05-12",
|
||||
"notional": 200.0,
|
||||
"notional_entry": 200.0,
|
||||
"retraction_legs": 0,
|
||||
"realized_pnl_legs_total": 0.0,
|
||||
}
|
||||
_seed_chain(t, "T-2")
|
||||
cmd = _retract_cmd(t, "T-2", command_id="c-2", fraction=1.0, reason="HOTKEY_RETRACT")
|
||||
t.control_map.put("blue_runtime_commands", json.dumps([cmd]))
|
||||
forced = t._process_runtime_commands({"FETUSDT": 1.9})
|
||||
|
||||
assert forced is not None
|
||||
assert forced["trade_id"] == "T-2"
|
||||
assert forced["reason"] == "HOTKEY_RETRACT"
|
||||
assert forced["capital_already_realized"] is True
|
||||
assert forced["economic_pnl"] == pytest.approx(forced["net_pnl"], abs=1e-12)
|
||||
assert forced["economic_pnl_pct"] == pytest.approx(forced["pnl_pct"], abs=1e-12)
|
||||
assert t.eng.position is None
|
||||
assert "T-2" not in t.eng.exit_manager._positions
|
||||
|
||||
|
||||
def test_full_retract_close_path_does_not_double_apply_capital(monkeypatch):
|
||||
monkeypatch.setattr(mod, "ch_put", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(mod, "_ch_ts_us", lambda: 123)
|
||||
|
||||
t = _mk_trader()
|
||||
t.state_map = _Map({})
|
||||
t.pnl_map = _Map({})
|
||||
pos = _Pos("T-2B", "FETUSDT", 2.0, 200.0, current_price=1.9)
|
||||
t.eng = _Eng(pos, capital=1000.0)
|
||||
t._pending_entries["T-2B"] = {
|
||||
"asset": "FETUSDT",
|
||||
"side": "SHORT",
|
||||
"entry_price": 2.0,
|
||||
"entry_bar": 95,
|
||||
"entry_date": "2026-05-12",
|
||||
"notional": 200.0,
|
||||
"notional_entry": 200.0,
|
||||
"quantity": 100.0,
|
||||
"retraction_legs": 0,
|
||||
"realized_pnl_legs_total": 0.0,
|
||||
}
|
||||
_seed_chain(t, "T-2B")
|
||||
cmd = _retract_cmd(t, "T-2B", command_id="c-2b", fraction=1.0, reason="HOTKEY_RETRACT")
|
||||
t.control_map.put("blue_runtime_commands", json.dumps([cmd]))
|
||||
forced = t._process_runtime_commands({"FETUSDT": 1.9})
|
||||
assert forced is not None
|
||||
|
||||
# First accounting application happened in retract leg.
|
||||
assert t.eng.capital == pytest.approx(1010.0, abs=1e-9)
|
||||
pending = t._pending_entries["T-2B"]
|
||||
realized_pnl, realized_source = t._resolved_realized_trade_pnl(pending, forced, exit_price=1.9)
|
||||
assert realized_source == "net_pnl"
|
||||
assert realized_pnl == pytest.approx(10.0, abs=1e-9)
|
||||
|
||||
# Close-path accounting must be suppressed because leg accounting already realized pnl.
|
||||
cap_delta, cap_source = t._resolved_capital_apply_pnl(forced, realized_pnl)
|
||||
assert cap_source == "already_realized"
|
||||
assert cap_delta == pytest.approx(0.0, abs=1e-12)
|
||||
cap_before, cap_after = t._apply_trade_capital_update(
|
||||
cap_delta,
|
||||
reason="HOTKEY_RETRACT",
|
||||
source="trade_close",
|
||||
trade_id="T-2B",
|
||||
asset="FETUSDT",
|
||||
)
|
||||
assert cap_before == pytest.approx(1010.0, abs=1e-9)
|
||||
assert cap_after == pytest.approx(1010.0, abs=1e-9)
|
||||
assert t.eng.capital == pytest.approx(1010.0, abs=1e-9)
|
||||
|
||||
|
||||
def test_idempotent_replay_is_noop(monkeypatch):
|
||||
rows = []
|
||||
monkeypatch.setattr(mod, "ch_put", lambda table, row: rows.append((table, row)))
|
||||
monkeypatch.setattr(mod, "_ch_ts_us", lambda: 123)
|
||||
|
||||
t = _mk_trader()
|
||||
pos = _Pos("T-3", "DASHUSDT", 10.0, 1000.0, current_price=9.5)
|
||||
t.eng = _Eng(pos, capital=5000.0)
|
||||
t._pending_entries["T-3"] = {
|
||||
"asset": "DASHUSDT",
|
||||
"side": "SHORT",
|
||||
"entry_price": 10.0,
|
||||
"entry_bar": 90,
|
||||
"entry_date": "2026-05-12",
|
||||
"notional": 1000.0,
|
||||
"notional_entry": 1000.0,
|
||||
"retraction_legs": 0,
|
||||
"realized_pnl_legs_total": 0.0,
|
||||
}
|
||||
_seed_chain(t, "T-3")
|
||||
cmd = _retract_cmd(t, "T-3", command_id="dup", fraction=0.5, reason="HOTKEY_RETRACT")
|
||||
t.control_map.put("blue_runtime_commands", json.dumps([cmd, cmd]))
|
||||
t._process_runtime_commands({"DASHUSDT": 9.5})
|
||||
assert pytest.approx(t.eng.position.notional, abs=1e-9) == 500.0
|
||||
replays = [r for tbl, r in rows if tbl == "hotkey_audit" and r.get("result") == "IDEMPOTENT_REPLAY"]
|
||||
assert replays
|
||||
|
||||
|
||||
def test_idempotent_replay_does_not_change_capital(monkeypatch):
|
||||
monkeypatch.setattr(mod, "ch_put", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(mod, "_ch_ts_us", lambda: 123)
|
||||
|
||||
t = _mk_trader()
|
||||
pos = _Pos("T-3B", "DASHUSDT", 10.0, 1000.0, current_price=9.5)
|
||||
t.eng = _Eng(pos, capital=5000.0)
|
||||
t._pending_entries["T-3B"] = {
|
||||
"asset": "DASHUSDT",
|
||||
"side": "SHORT",
|
||||
"entry_price": 10.0,
|
||||
"entry_bar": 90,
|
||||
"entry_date": "2026-05-12",
|
||||
"notional": 1000.0,
|
||||
"notional_entry": 1000.0,
|
||||
"retraction_legs": 0,
|
||||
"realized_pnl_legs_total": 0.0,
|
||||
}
|
||||
_seed_chain(t, "T-3B")
|
||||
cmd = _retract_cmd(t, "T-3B", command_id="dup-2", fraction=0.5, reason="HOTKEY_RETRACT")
|
||||
t.control_map.put("blue_runtime_commands", json.dumps([cmd, cmd]))
|
||||
t._process_runtime_commands({"DASHUSDT": 9.5})
|
||||
cap_after_first = t.eng.capital
|
||||
|
||||
t.control_map.put("blue_runtime_commands", json.dumps([cmd]))
|
||||
t._process_runtime_commands({"DASHUSDT": 9.5})
|
||||
assert t.eng.capital == pytest.approx(cap_after_first, abs=1e-9)
|
||||
|
||||
|
||||
def test_trade_id_mismatch_is_rejected_and_position_unchanged(monkeypatch):
|
||||
rows = []
|
||||
monkeypatch.setattr(mod, "ch_put", lambda table, row: rows.append((table, row)))
|
||||
monkeypatch.setattr(mod, "_ch_ts_us", lambda: 123)
|
||||
|
||||
t = _mk_trader()
|
||||
pos = _Pos("T-4", "STXUSDT", 1.0, 1000.0, current_price=1.01)
|
||||
t.eng = _Eng(pos, capital=1000.0)
|
||||
t._pending_entries["T-4"] = {"asset": "STXUSDT", "side": "SHORT", "entry_price": 1.0, "entry_bar": 80, "entry_date": "2026-05-12"}
|
||||
_seed_chain(t, "T-4")
|
||||
cmd = {"command_id": "bad", "trade_id": "OTHER", "action": "RETRACT", "fraction": 0.5, "reason": "HOTKEY_RETRACT", "chain_root_trade_id": "OTHER", "chain_head_leg_id": "OTHER:open", "chain_prev_leg_id": "", "chain_seq": 0, "chain_token": "stale"}
|
||||
t.control_map.put("blue_runtime_commands", json.dumps([cmd]))
|
||||
|
||||
t._process_runtime_commands({"STXUSDT": 1.01})
|
||||
assert pytest.approx(t.eng.position.notional, abs=1e-9) == 1000.0
|
||||
assert any(tbl == "hotkey_audit" and "TRADE_MISMATCH" in r["result"] for tbl, r in rows)
|
||||
|
||||
|
||||
def test_command_queue_drained_atomically(monkeypatch):
|
||||
monkeypatch.setattr(mod, "ch_put", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(mod, "_ch_ts_us", lambda: 123)
|
||||
|
||||
t = _mk_trader()
|
||||
pos = _Pos("T-5", "LINKUSDT", 10.0, 1000.0, current_price=9.8)
|
||||
t.eng = _Eng(pos, capital=500.0)
|
||||
t._pending_entries["T-5"] = {"asset": "LINKUSDT", "side": "SHORT", "entry_price": 10.0, "entry_bar": 88, "entry_date": "2026-05-12"}
|
||||
_seed_chain(t, "T-5")
|
||||
cmds = [
|
||||
_retract_cmd(t, "T-5", command_id="a", fraction=0.25, reason="HOTKEY_RETRACT"),
|
||||
_retract_cmd(t, "T-5", command_id="b", fraction=0.25, reason="HOTKEY_RETRACT"),
|
||||
]
|
||||
t.control_map.put("blue_runtime_commands", json.dumps(cmds))
|
||||
t._process_runtime_commands({"LINKUSDT": 9.8})
|
||||
assert t.control_map.get("blue_runtime_commands") == "[]"
|
||||
|
||||
|
||||
def test_bad_fraction_rejected(monkeypatch):
|
||||
rows = []
|
||||
monkeypatch.setattr(mod, "ch_put", lambda table, row: rows.append((table, row)))
|
||||
monkeypatch.setattr(mod, "_ch_ts_us", lambda: 123)
|
||||
|
||||
t = _mk_trader()
|
||||
pos = _Pos("T-6", "SOLUSDT", 100.0, 1000.0, current_price=95.0)
|
||||
t.eng = _Eng(pos, capital=1000.0)
|
||||
t._pending_entries["T-6"] = {"asset": "SOLUSDT", "side": "SHORT", "entry_price": 100.0, "entry_bar": 80, "entry_date": "2026-05-12"}
|
||||
_seed_chain(t, "T-6")
|
||||
cmd = {"command_id": "badfrac", "trade_id": "T-6", "action": "RETRACT", "fraction": 0.0, "reason": "HOTKEY_RETRACT", "chain_root_trade_id": t._pending_entries["T-6"]["chain_root_trade_id"], "chain_head_leg_id": t._pending_entries["T-6"]["chain_head_leg_id"], "chain_prev_leg_id": t._pending_entries["T-6"]["chain_prev_leg_id"], "chain_seq": t._pending_entries["T-6"]["chain_seq"], "chain_token": t._pending_entries["T-6"]["chain_token"]}
|
||||
t.control_map.put("blue_runtime_commands", json.dumps([cmd]))
|
||||
t._process_runtime_commands({"SOLUSDT": 95.0})
|
||||
assert pytest.approx(t.eng.position.notional, abs=1e-9) == 1000.0
|
||||
assert any(tbl == "hotkey_audit" and r["result"] == "BAD_FRACTION" for tbl, r in rows)
|
||||
|
||||
|
||||
def test_retract_with_missing_price_falls_back_to_entry_and_keeps_capital(monkeypatch):
|
||||
monkeypatch.setattr(mod, "ch_put", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(mod, "_ch_ts_us", lambda: 123)
|
||||
|
||||
t = _mk_trader()
|
||||
pos = _Pos("T-6B", "SOLUSDT", 100.0, 1000.0, current_price=0.0)
|
||||
t.eng = _Eng(pos, capital=1000.0)
|
||||
t._pending_entries["T-6B"] = {
|
||||
"asset": "SOLUSDT",
|
||||
"side": "SHORT",
|
||||
"entry_price": 100.0,
|
||||
"entry_bar": 80,
|
||||
"entry_date": "2026-05-12",
|
||||
"notional": 1000.0,
|
||||
"notional_entry": 1000.0,
|
||||
"retraction_legs": 0,
|
||||
"realized_pnl_legs_total": 0.0,
|
||||
}
|
||||
_seed_chain(t, "T-6B")
|
||||
cmd = _retract_cmd(t, "T-6B", command_id="c-6b", fraction=0.5, reason="HOTKEY_RETRACT")
|
||||
t.control_map.put("blue_runtime_commands", json.dumps([cmd]))
|
||||
t._process_runtime_commands({})
|
||||
assert t.eng.capital == pytest.approx(1000.0, abs=1e-9)
|
||||
|
||||
|
||||
def test_multi_slot_future_safety_non_target_commands_do_not_mutate_open_slot(monkeypatch):
|
||||
"""
|
||||
Future-proof guard: if multiple slot commands exist, only matching trade_id may mutate current open position.
|
||||
"""
|
||||
rows = []
|
||||
monkeypatch.setattr(mod, "ch_put", lambda table, row: rows.append((table, row)))
|
||||
monkeypatch.setattr(mod, "_ch_ts_us", lambda: 123)
|
||||
|
||||
t = _mk_trader()
|
||||
pos = _Pos("ACTIVE", "ATOMUSDT", 5.0, 500.0, current_price=4.9)
|
||||
t.eng = _Eng(pos, capital=1000.0)
|
||||
t._pending_entries["ACTIVE"] = {"asset": "ATOMUSDT", "side": "SHORT", "entry_price": 5.0, "entry_bar": 99, "entry_date": "2026-05-12"}
|
||||
_seed_chain(t, "ACTIVE")
|
||||
cmds = [
|
||||
{"command_id": "x1", "trade_id": "INACTIVE", "action": "RETRACT", "fraction": 1.0, "reason": "HOTKEY_RETRACT", "chain_root_trade_id": "INACTIVE", "chain_head_leg_id": "INACTIVE:open", "chain_prev_leg_id": "", "chain_seq": 0, "chain_token": "stale"},
|
||||
_retract_cmd(t, "ACTIVE", command_id="x2", fraction=0.5, reason="HOTKEY_RETRACT"),
|
||||
]
|
||||
t.control_map.put("blue_runtime_commands", json.dumps(cmds))
|
||||
t._process_runtime_commands({"ATOMUSDT": 4.9})
|
||||
assert pytest.approx(t.eng.position.notional, abs=1e-9) == 250.0
|
||||
assert any(tbl == "hotkey_audit" and "TRADE_MISMATCH" in r["result"] for tbl, r in rows)
|
||||
assert any(tbl == "hotkey_audit" and r["result"] == "PARTIAL_OK" for tbl, r in rows)
|
||||
1571
prod/tests/test_pink_bingx_dita_live_e2e.py
Normal file
1571
prod/tests/test_pink_bingx_dita_live_e2e.py
Normal file
File diff suppressed because it is too large
Load Diff
326
prod/tests/test_pink_clickhouse_persistence.py
Normal file
326
prod/tests/test_pink_clickhouse_persistence.py
Normal file
@@ -0,0 +1,326 @@
|
||||
"""PINK ClickHouse persistence tests — DITAv2 outcome + slot_dict API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
from prod.clean_arch.dita_v2.contracts import TradeStage as DitaTradeStage
|
||||
from prod.clean_arch.dita import (
|
||||
AccountProjection,
|
||||
AccountSnapshot,
|
||||
Decision,
|
||||
DecisionAction,
|
||||
Intent,
|
||||
TradeSide,
|
||||
TradeStage,
|
||||
)
|
||||
from prod.clean_arch.dita_v2.contracts import (
|
||||
KernelDiagnosticCode,
|
||||
KernelOutcome,
|
||||
KernelSeverity,
|
||||
KernelTransition,
|
||||
)
|
||||
from prod.clean_arch.persistence.pink_clickhouse import PinkClickHousePersistence
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Sink:
|
||||
calls: list[tuple[str, dict]] = field(default_factory=list)
|
||||
|
||||
def __call__(self, table: str, row: dict) -> None:
|
||||
self.calls.append((table, row))
|
||||
|
||||
|
||||
def _make_snapshot():
|
||||
return SimpleNamespace(
|
||||
timestamp=datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc),
|
||||
symbol="BTCUSDT",
|
||||
price=100.0,
|
||||
)
|
||||
|
||||
|
||||
def _make_decision(action: DecisionAction) -> Decision:
|
||||
return Decision(
|
||||
timestamp=datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc),
|
||||
decision_id="BTCUSDT-D-000000000001",
|
||||
asset="BTCUSDT",
|
||||
action=action,
|
||||
side=TradeSide.SHORT,
|
||||
reason="STRUCTURAL_DISLOCATION" if action == DecisionAction.ENTER else "TAKE_PROFIT",
|
||||
confidence=0.9,
|
||||
velocity_divergence=-0.12,
|
||||
irp_alignment=0.8,
|
||||
reference_price=100.0,
|
||||
target_size=1.0,
|
||||
leverage=2.0,
|
||||
bars_held=0,
|
||||
stage=TradeStage.ORDER_REQUESTED,
|
||||
metadata={},
|
||||
)
|
||||
|
||||
|
||||
def _make_intent(action: DecisionAction) -> Intent:
|
||||
return Intent(
|
||||
timestamp=datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc),
|
||||
trade_id="BTCUSDT-T-000000000001",
|
||||
decision_id="BTCUSDT-D-000000000001",
|
||||
asset="BTCUSDT",
|
||||
action=action,
|
||||
side=TradeSide.SHORT,
|
||||
reason="STRUCTURAL_DISLOCATION" if action == DecisionAction.ENTER else "TAKE_PROFIT",
|
||||
target_size=1.0,
|
||||
leverage=2.0,
|
||||
reference_price=100.0,
|
||||
confidence=0.9,
|
||||
bars_held=0,
|
||||
exit_leg_ratios=(0.5, 1.0),
|
||||
metadata={"exit_ratio": 0.5},
|
||||
)
|
||||
|
||||
|
||||
def _make_account() -> AccountProjection:
|
||||
return AccountProjection(
|
||||
runtime_namespace="pink",
|
||||
strategy_namespace="pink",
|
||||
event_namespace="pink",
|
||||
actor_name="PinkDirectRuntime",
|
||||
exec_venue="bingx",
|
||||
data_venue="binance",
|
||||
ledger_authority="exchange",
|
||||
snapshot=AccountSnapshot(capital=25_000.0, equity=25_000.0),
|
||||
)
|
||||
|
||||
|
||||
def _make_outcome(
|
||||
accepted: bool = True,
|
||||
code: KernelDiagnosticCode = KernelDiagnosticCode.OK,
|
||||
) -> KernelOutcome:
|
||||
return KernelOutcome(
|
||||
accepted=accepted,
|
||||
slot_id=0,
|
||||
trade_id="BTCUSDT-T-000000000001",
|
||||
state=DitaTradeStage.POSITION_OPEN,
|
||||
diagnostic_code=code,
|
||||
severity=KernelSeverity.INFO,
|
||||
transitions=(),
|
||||
emitted_events=(),
|
||||
details={},
|
||||
)
|
||||
|
||||
|
||||
def _make_slot_dict(
|
||||
closed: bool = False,
|
||||
size: float = 1.0,
|
||||
pnl: float = 0.0,
|
||||
) -> dict:
|
||||
return {
|
||||
"slot_id": 0,
|
||||
"trade_id": "BTCUSDT-T-000000000001",
|
||||
"asset": "BTCUSDT",
|
||||
"side": "SHORT",
|
||||
"entry_price": 100.0,
|
||||
"size": size,
|
||||
"initial_size": 1.0,
|
||||
"leverage": 2.0,
|
||||
"realized_pnl": pnl,
|
||||
"unrealized_pnl": 0.0,
|
||||
"closed": closed,
|
||||
"close_reason": "TAKE_PROFIT" if closed else "",
|
||||
"fsm_state": "CLOSED" if closed else "POSITION_OPEN",
|
||||
"exit_leg_ratios": [0.5, 1.0],
|
||||
"active_leg_index": 0,
|
||||
"active_exit_order": None,
|
||||
"active_entry_order": None,
|
||||
}
|
||||
|
||||
|
||||
def _make_acc_dict(capital: float = 25120.0) -> dict:
|
||||
return {
|
||||
"capital": capital,
|
||||
"equity": capital,
|
||||
"realized_pnl": 120.0,
|
||||
"unrealized_pnl": 0.0,
|
||||
"open_positions": 0,
|
||||
"open_notional": 0.0,
|
||||
"leverage": 0.0,
|
||||
}
|
||||
|
||||
|
||||
def test_persistence_mirrors_policy_account_and_position_rows() -> None:
|
||||
"""ENTER phase: policy_events, account_events, position_state, trade_reconstruction."""
|
||||
sink = _Sink()
|
||||
account = _make_account()
|
||||
persistence = PinkClickHousePersistence(account, sink=sink, v7_sink=sink)
|
||||
snapshot = _make_snapshot()
|
||||
decision = _make_decision(DecisionAction.ENTER)
|
||||
intent = _make_intent(DecisionAction.ENTER)
|
||||
outcome = _make_outcome()
|
||||
slot_dict = _make_slot_dict(closed=False, size=1.0)
|
||||
acc_dict = _make_acc_dict(25000.0)
|
||||
market_state = {
|
||||
"market_fingerprint_choppiness_strength": 0.2,
|
||||
"market_fingerprint_trend_persistence": 0.4,
|
||||
"market_state_top_asset_target": "ETHUSDT",
|
||||
}
|
||||
|
||||
persistence.persist_step(
|
||||
snapshot=snapshot,
|
||||
decision=decision,
|
||||
intent=intent,
|
||||
outcome=outcome,
|
||||
slot_dict=slot_dict,
|
||||
acc_dict=acc_dict,
|
||||
phase="execution",
|
||||
market_state=market_state,
|
||||
)
|
||||
|
||||
tables = [t for t, _ in sink.calls]
|
||||
assert "policy_events" in tables, f"Missing policy_events, got {tables}"
|
||||
assert "v7_decision_events" in tables
|
||||
assert "account_events" in tables
|
||||
assert "position_state" in tables
|
||||
assert "status_snapshots" in tables
|
||||
assert "trade_reconstruction" in tables
|
||||
assert "trade_events" not in tables, "No trade_events on ENTER"
|
||||
|
||||
policy = next(row for t, row in sink.calls if t == "policy_events")
|
||||
v7 = next(row for t, row in sink.calls if t == "v7_decision_events")
|
||||
position_row = next(row for t, row in sink.calls if t == "position_state")
|
||||
recon_row = next(row for t, row in sink.calls if t == "trade_reconstruction")
|
||||
|
||||
assert policy["trade_id"] == intent.trade_id
|
||||
assert policy["action"] == "ENTER"
|
||||
assert policy == v7
|
||||
assert "market_state_bundle_json" in position_row
|
||||
assert position_row["tp_base_pct"] == 0.0
|
||||
assert recon_row["market_state_bundle_json"]
|
||||
assert "market_fingerprint_choppiness_strength" in recon_row["market_state_bundle_json"]
|
||||
|
||||
|
||||
def test_persistence_writes_anomaly_for_diagnostic() -> None:
|
||||
"""Non-OK diagnostic_code emits anomaly_events row."""
|
||||
sink = _Sink()
|
||||
account = _make_account()
|
||||
persistence = PinkClickHousePersistence(account, sink=sink, v7_sink=sink)
|
||||
snapshot = _make_snapshot()
|
||||
decision = _make_decision(DecisionAction.ENTER)
|
||||
intent = _make_intent(DecisionAction.ENTER)
|
||||
outcome = _make_outcome(accepted=False, code=KernelDiagnosticCode.ORDER_REJECTED)
|
||||
slot_dict = _make_slot_dict(closed=False, size=0.0)
|
||||
acc_dict = _make_acc_dict(25000.0)
|
||||
|
||||
persistence.persist_step(
|
||||
snapshot=snapshot,
|
||||
decision=decision,
|
||||
intent=intent,
|
||||
outcome=outcome,
|
||||
slot_dict=slot_dict,
|
||||
acc_dict=acc_dict,
|
||||
phase="execution",
|
||||
)
|
||||
|
||||
tables = [t for t, _ in sink.calls]
|
||||
assert "anomaly_events" in tables, f"Missing anomaly_events, got {tables}"
|
||||
anomaly = next(row for t, row in sink.calls if t == "anomaly_events")
|
||||
assert anomaly["anomaly"] == "ORDER_REJECTED"
|
||||
|
||||
|
||||
def test_persistence_writes_terminal_trade_event_on_close() -> None:
|
||||
"""EXIT with slot_dict.closed=True writes trade_events."""
|
||||
sink = _Sink()
|
||||
account = _make_account()
|
||||
account.snapshot.capital = 25120.0
|
||||
persistence = PinkClickHousePersistence(account, sink=sink, v7_sink=sink)
|
||||
snapshot = _make_snapshot()
|
||||
decision = _make_decision(DecisionAction.EXIT)
|
||||
intent = _make_intent(DecisionAction.EXIT)
|
||||
outcome = _make_outcome()
|
||||
slot_dict = _make_slot_dict(closed=True, size=0.0, pnl=120.0)
|
||||
acc_dict = _make_acc_dict(25120.0)
|
||||
market_state = {"market_fingerprint_mean_reversion_strength": 0.3}
|
||||
|
||||
persistence.persist_step(
|
||||
snapshot=snapshot,
|
||||
decision=decision,
|
||||
intent=intent,
|
||||
outcome=outcome,
|
||||
slot_dict=slot_dict,
|
||||
acc_dict=acc_dict,
|
||||
phase="execution",
|
||||
market_state=market_state,
|
||||
)
|
||||
|
||||
tables = [t for t, _ in sink.calls]
|
||||
assert "trade_events" in tables, f"Missing trade_events, got {tables}"
|
||||
trade = next(row for t, row in sink.calls if t == "trade_events")
|
||||
assert trade["exit_reason"] == "TAKE_PROFIT"
|
||||
assert trade["trade_id"] == intent.trade_id
|
||||
assert "market_state_bundle_json" in trade
|
||||
assert "market_fingerprint_mean_reversion_strength" in trade["market_state_bundle_json"]
|
||||
|
||||
|
||||
def test_persistence_writes_anomaly_and_recovery_rows() -> None:
|
||||
"""record_anomaly() + persist_recovery_state() write correct rows."""
|
||||
sink = _Sink()
|
||||
account = _make_account()
|
||||
persistence = PinkClickHousePersistence(account, sink=sink, v7_sink=sink)
|
||||
snapshot = _make_snapshot()
|
||||
decision = _make_decision(DecisionAction.HOLD)
|
||||
intent = _make_intent(DecisionAction.HOLD)
|
||||
|
||||
persistence.record_anomaly(
|
||||
snapshot=snapshot,
|
||||
decision=decision,
|
||||
intent=intent,
|
||||
anomaly="hung_exit",
|
||||
origin="injected",
|
||||
sensor="m8_execution_integrity",
|
||||
detail="forced drop",
|
||||
rm_meta=0.42,
|
||||
)
|
||||
persistence.persist_recovery_state(
|
||||
snapshot=snapshot,
|
||||
acc_dict={},
|
||||
market_state={"market_fingerprint_dd_pressure": 0.2},
|
||||
)
|
||||
|
||||
tables = [t for t, _ in sink.calls]
|
||||
assert "anomaly_events" in tables
|
||||
anomaly = next(row for t, row in sink.calls if t == "anomaly_events")
|
||||
assert anomaly["anomaly"] == "hung_exit"
|
||||
assert anomaly["sensor"] == "m8_execution_integrity"
|
||||
assert "status_snapshots" in tables
|
||||
assert "account_events" in tables
|
||||
assert "position_state" in tables
|
||||
|
||||
|
||||
def test_persistence_writes_account_reconcile_rows() -> None:
|
||||
"""persist_recovery_state with account_reconcile phase writes correct rows."""
|
||||
sink = _Sink()
|
||||
account = _make_account()
|
||||
persistence = PinkClickHousePersistence(account, sink=sink, v7_sink=sink)
|
||||
snapshot = _make_snapshot()
|
||||
|
||||
persistence.persist_recovery_state(
|
||||
snapshot=snapshot,
|
||||
acc_dict={},
|
||||
phase="account_reconcile",
|
||||
event_type="ACCOUNT_RECONCILE",
|
||||
market_state={"market_fingerprint_return_entropy": 0.1},
|
||||
)
|
||||
|
||||
tables = [t for t, _ in sink.calls]
|
||||
assert "status_snapshots" in tables
|
||||
assert "account_events" in tables
|
||||
assert "position_state" in tables
|
||||
assert "trade_reconstruction" in tables
|
||||
account_row = next(row for t, row in sink.calls if t == "account_events")
|
||||
status_row = next(row for t, row in sink.calls if t == "status_snapshots")
|
||||
recon_row = next(row for t, row in sink.calls if t == "trade_reconstruction")
|
||||
assert account_row["event_type"] == "ACCOUNT_RECONCILE"
|
||||
assert status_row["phase"] == "account_reconcile"
|
||||
assert recon_row["event_type"] == "ACCOUNT_RECONCILE"
|
||||
assert "market_state_bundle_json" in recon_row
|
||||
442
prod/tests/test_pink_direct_runtime.py
Normal file
442
prod/tests/test_pink_direct_runtime.py
Normal file
@@ -0,0 +1,442 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional, List, Dict
|
||||
|
||||
from prod.clean_arch.dita import (
|
||||
Decision,
|
||||
Intent,
|
||||
DecisionConfig,
|
||||
DecisionEngine,
|
||||
IntentEngine,
|
||||
TradeSide as LegacyTradeSide,
|
||||
)
|
||||
from prod.clean_arch.ports.data_feed import MarketSnapshot
|
||||
from prod.clean_arch.runtime.pink_direct import PinkDirectRuntime, _decision_to_kernel_intent
|
||||
from prod.clean_arch.dita_v2.contracts import (
|
||||
KernelCommandType,
|
||||
KernelDiagnosticCode,
|
||||
KernelIntent,
|
||||
KernelOutcome,
|
||||
KernelSeverity,
|
||||
KernelTransition,
|
||||
TradeSide,
|
||||
TradeSlot,
|
||||
TradeStage,
|
||||
VenueEvent,
|
||||
VenueEventStatus,
|
||||
VenueOrder,
|
||||
VenueOrderStatus,
|
||||
KernelEventKind,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeFeed:
|
||||
"""Fake Hazelcast data feed — returns canned snapshots."""
|
||||
|
||||
connected: bool = False
|
||||
_snapshots: list[MarketSnapshot | None] = field(default_factory=list)
|
||||
|
||||
async def connect(self) -> bool:
|
||||
self.connected = True
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
self.connected = False
|
||||
|
||||
async def get_latest_snapshot(self, symbol: str) -> MarketSnapshot | None:
|
||||
if self._snapshots:
|
||||
return self._snapshots.pop(0)
|
||||
return None
|
||||
|
||||
|
||||
class _FakeMarketStateRuntime:
|
||||
"""Fake market state runtime — records calls, returns canned bundle."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
self.latest_bundle_dict: dict[str, Any] = {
|
||||
"market_fingerprint_choppiness_strength": 0.2,
|
||||
"market_fingerprint_trend_persistence": 0.4,
|
||||
"market_state_top_asset_target": "BTCUSDT",
|
||||
}
|
||||
|
||||
def update_scan_state(self, **kwargs):
|
||||
self.calls.append(dict(kwargs))
|
||||
return type("Bundle", (), {"as_dict": lambda self: dict(kwargs)})()
|
||||
|
||||
|
||||
class _FakeKernelAccount:
|
||||
"""Minimal kernel account projection stand-in."""
|
||||
|
||||
def __init__(self, capital: float = 25000.0):
|
||||
self.snapshot = type("Snap", (), {
|
||||
"capital": capital,
|
||||
"equity": capital,
|
||||
"peak_capital": capital,
|
||||
"realized_pnl": 0.0,
|
||||
"unrealized_pnl": 0.0,
|
||||
"open_positions": 0,
|
||||
"open_notional": 0.0,
|
||||
"leverage": 0.0,
|
||||
"trade_seq": 0,
|
||||
})()
|
||||
|
||||
|
||||
class _FakeSlotView:
|
||||
"""Minimal slot view stand-in."""
|
||||
|
||||
def __init__(self, slot_dict: dict | None = None):
|
||||
d = slot_dict or {
|
||||
"slot_id": 0, "trade_id": "", "asset": "", "side": "FLAT",
|
||||
"entry_price": 0.0, "size": 0.0, "initial_size": 0.0,
|
||||
"leverage": 0.0, "realized_pnl": 0.0, "unrealized_pnl": 0.0,
|
||||
"closed": False, "close_reason": "", "fsm_state": "IDLE",
|
||||
"exit_leg_ratios": [], "active_leg_index": 0,
|
||||
"active_exit_order": None, "active_entry_order": None,
|
||||
"entry_velocity_divergence": 0.0, "entry_irp_alignment": 0.0,
|
||||
}
|
||||
self._d = d
|
||||
state_str = d.get("fsm_state", "IDLE")
|
||||
# Map string to enum
|
||||
for s in TradeStage:
|
||||
if s.value == state_str:
|
||||
self.fsm_state = s
|
||||
break
|
||||
else:
|
||||
self.fsm_state = TradeStage.IDLE
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return dict(self._d)
|
||||
|
||||
def is_free(self) -> bool:
|
||||
return self.fsm_state in {TradeStage.IDLE, TradeStage.CLOSED}
|
||||
|
||||
def is_open(self) -> bool:
|
||||
return self.fsm_state in {
|
||||
TradeStage.ENTRY_WORKING, TradeStage.POSITION_OPENED,
|
||||
TradeStage.POSITION_OPEN, TradeStage.EXIT_WORKING,
|
||||
}
|
||||
|
||||
def mark_price(self, price: float) -> None:
|
||||
self._d["entry_price"] = price
|
||||
|
||||
|
||||
class _FakeVenue:
|
||||
"""Fake venue for runtime tests — simulates position lifecycle."""
|
||||
|
||||
def __init__(self):
|
||||
self._capital = 25000.0
|
||||
self._position: dict | None = None
|
||||
self._trade_seq = 0
|
||||
self._connected = False
|
||||
|
||||
async def connect(self):
|
||||
self._connected = True
|
||||
|
||||
async def disconnect(self):
|
||||
self._connected = False
|
||||
|
||||
async def reconcile(self) -> dict:
|
||||
return {
|
||||
"capital": self._capital,
|
||||
"equity": self._capital,
|
||||
"open_positions": {} if self._position is None else {self._position["trade_id"]: self._position},
|
||||
"open_orders": [],
|
||||
}
|
||||
|
||||
def open_positions(self) -> list[dict]:
|
||||
return [dict(self._position)] if self._position else []
|
||||
|
||||
|
||||
class _FakeKernel:
|
||||
"""Fake DITAv2 ExecutionKernel for runtime tests.
|
||||
|
||||
Tracks an internal position lifecycle matching the _FakeVenue.
|
||||
"""
|
||||
|
||||
def __init__(self, capital: float = 25000.0):
|
||||
self.max_slots = 1
|
||||
self.account = _FakeKernelAccount(capital)
|
||||
self.venue = _FakeVenue()
|
||||
self._slots: dict[int, _FakeSlotView] = {0: _FakeSlotView()}
|
||||
self._capital = capital
|
||||
self._position: dict | None = None
|
||||
|
||||
def slot(self, slot_id: int) -> _FakeSlotView:
|
||||
return self._slots.get(slot_id, _FakeSlotView())
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
return {
|
||||
"account": {
|
||||
"capital": self.account.snapshot.capital,
|
||||
"equity": self.account.snapshot.equity,
|
||||
"realized_pnl": self.account.snapshot.realized_pnl,
|
||||
"unrealized_pnl": self.account.snapshot.unrealized_pnl,
|
||||
"open_positions": self.account.snapshot.open_positions,
|
||||
"open_notional": self.account.snapshot.open_notional,
|
||||
"leverage": self.account.snapshot.leverage,
|
||||
"trade_seq": self.account.snapshot.trade_seq,
|
||||
},
|
||||
"slots": [self.slot(0).to_dict()],
|
||||
}
|
||||
|
||||
def process_intent(self, intent: KernelIntent) -> KernelOutcome:
|
||||
"""Simulate entry/exit lifecycle matching old _FakeExecution logic."""
|
||||
price = float(intent.reference_price or 0.0)
|
||||
qty = float(intent.target_size or 0.0)
|
||||
|
||||
if intent.action == KernelCommandType.ENTER:
|
||||
self._position = {
|
||||
"trade_id": intent.trade_id,
|
||||
"asset": intent.asset,
|
||||
"side": "SHORT" if intent.side == TradeSide.SHORT else "LONG",
|
||||
"entry_price": price,
|
||||
"size": qty,
|
||||
"leverage": float(intent.leverage or 1.0),
|
||||
}
|
||||
self._slots[0] = _FakeSlotView({
|
||||
"slot_id": 0, "trade_id": intent.trade_id, "asset": intent.asset,
|
||||
"side": self._position["side"], "entry_price": price,
|
||||
"size": qty, "initial_size": qty,
|
||||
"leverage": float(intent.leverage or 1.0),
|
||||
"realized_pnl": 0.0, "unrealized_pnl": 0.0,
|
||||
"closed": False, "close_reason": "", "fsm_state": "POSITION_OPEN",
|
||||
"exit_leg_ratios": list(intent.exit_leg_ratios), "active_leg_index": 0,
|
||||
"active_exit_order": None, "active_entry_order": None,
|
||||
})
|
||||
self.account.snapshot.open_positions = 1
|
||||
self.account.snapshot.open_notional = qty * price
|
||||
self.account.snapshot.trade_seq += 1
|
||||
|
||||
elif intent.action == KernelCommandType.EXIT and self._position is not None:
|
||||
current_qty = float(self._position["size"])
|
||||
remaining = max(0.0, current_qty - qty)
|
||||
entry_price = float(self._position["entry_price"])
|
||||
leverage = float(self._position.get("leverage", 1.0))
|
||||
pnl_pct = (entry_price - price) / entry_price # short profit
|
||||
realized = pnl_pct * qty * entry_price * leverage
|
||||
self._capital += realized
|
||||
self.account.snapshot.capital = self._capital
|
||||
self.account.snapshot.realized_pnl += realized
|
||||
self.account.snapshot.peak_capital = max(self.account.snapshot.peak_capital, self._capital)
|
||||
self.account.snapshot.equity = self._capital
|
||||
|
||||
if remaining <= 1e-12:
|
||||
self._position = None
|
||||
self._slots[0] = _FakeSlotView({
|
||||
"slot_id": 0, "trade_id": intent.trade_id, "asset": intent.asset,
|
||||
"side": "FLAT", "entry_price": 0.0, "size": 0.0, "initial_size": 0.0,
|
||||
"leverage": 0.0, "realized_pnl": realized, "unrealized_pnl": 0.0,
|
||||
"closed": True, "close_reason": intent.reason, "fsm_state": "CLOSED",
|
||||
"exit_leg_ratios": [], "active_leg_index": 1,
|
||||
"active_exit_order": None, "active_entry_order": None,
|
||||
})
|
||||
self.account.snapshot.open_positions = 0
|
||||
self.account.snapshot.open_notional = 0.0
|
||||
else:
|
||||
self._position["size"] = remaining
|
||||
self._slots[0] = _FakeSlotView({
|
||||
"slot_id": 0, "trade_id": intent.trade_id, "asset": intent.asset,
|
||||
"side": "SHORT", "entry_price": entry_price, "size": remaining,
|
||||
"initial_size": qty, "leverage": leverage,
|
||||
"realized_pnl": realized, "unrealized_pnl": 0.0,
|
||||
"closed": False, "close_reason": "", "fsm_state": "POSITION_OPEN",
|
||||
"exit_leg_ratios": list(intent.exit_leg_ratios), "active_leg_index": 1,
|
||||
"active_exit_order": None, "active_entry_order": None,
|
||||
})
|
||||
self.account.snapshot.open_positions = 1
|
||||
self.account.snapshot.open_notional = remaining * entry_price
|
||||
|
||||
elif intent.action == KernelCommandType.MARK_PRICE:
|
||||
if self._position:
|
||||
self._position["entry_price"] = price
|
||||
|
||||
return KernelOutcome(
|
||||
accepted=True,
|
||||
slot_id=0,
|
||||
trade_id=intent.trade_id,
|
||||
state=TradeStage.POSITION_OPEN if self._position else TradeStage.IDLE,
|
||||
diagnostic_code=KernelDiagnosticCode.OK,
|
||||
severity=KernelSeverity.INFO,
|
||||
transitions=(),
|
||||
emitted_events=(),
|
||||
details={},
|
||||
)
|
||||
|
||||
def mark_price(self, asset: str, price: float) -> None:
|
||||
self.slot(0).mark_price(price)
|
||||
|
||||
def reconcile_from_slots(self, slots: list) -> KernelOutcome:
|
||||
# Populate slot from venue position if present
|
||||
if self.venue._position is not None:
|
||||
p = self.venue._position
|
||||
self._position = dict(p)
|
||||
self.venue._capital = self._capital
|
||||
self._slots[0] = _FakeSlotView({
|
||||
"slot_id": 0,
|
||||
"trade_id": p.get("trade_id", ""),
|
||||
"asset": p.get("asset", ""),
|
||||
"side": p.get("side", "FLAT"),
|
||||
"entry_price": float(p.get("entry_price", 0.0)),
|
||||
"size": float(p.get("size", 0.0)),
|
||||
"initial_size": float(p.get("size", 0.0)),
|
||||
"leverage": float(p.get("leverage", 1.0)),
|
||||
"realized_pnl": 0.0, "unrealized_pnl": 0.0,
|
||||
"closed": False, "close_reason": "",
|
||||
"fsm_state": "POSITION_OPEN",
|
||||
"exit_leg_ratios": [1.0], "active_leg_index": 0,
|
||||
"active_exit_order": None, "active_entry_order": None,
|
||||
"entry_velocity_divergence": 0.0,
|
||||
"entry_irp_alignment": 0.0,
|
||||
})
|
||||
self.account.snapshot.open_positions = 1
|
||||
self.account.snapshot.open_notional = float(p.get("size", 0)) * float(p.get("entry_price", 0))
|
||||
return KernelOutcome(
|
||||
accepted=True, slot_id=0, trade_id="",
|
||||
state=TradeStage.IDLE, diagnostic_code=KernelDiagnosticCode.OK,
|
||||
)
|
||||
|
||||
|
||||
def _snapshot(price: float, vdiv: float, *, symbol: str = "BTCUSDT") -> MarketSnapshot:
|
||||
return MarketSnapshot(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
symbol=symbol,
|
||||
price=price,
|
||||
bid=price * 0.9995,
|
||||
ask=price * 1.0005,
|
||||
eigenvalues=[1.0, 0.9, 0.8],
|
||||
eigenvectors=None,
|
||||
velocity_divergence=vdiv,
|
||||
irp_alignment=0.5,
|
||||
scan_number=int(datetime.now(timezone.utc).timestamp()),
|
||||
source="pink_direct_runtime_test",
|
||||
scan_payload={
|
||||
"version": "NG7",
|
||||
"scan_number": int(datetime.now(timezone.utc).timestamp()),
|
||||
"vel_div": vdiv,
|
||||
"w50_velocity": 0.01,
|
||||
"w750_velocity": 0.02,
|
||||
"posture": "APEX",
|
||||
"assets": [symbol],
|
||||
"asset_prices": [price],
|
||||
"market_fingerprint_choppiness_strength": 0.2,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_runtime_handles_open_partial_close_and_terminal_close() -> None:
|
||||
"""Full lifecycle: entry → partial exit → terminal exit via DITAv2 kernel."""
|
||||
feed = _FakeFeed()
|
||||
kernel = _FakeKernel(capital=25000.0)
|
||||
market_state_runtime = _FakeMarketStateRuntime()
|
||||
cfg = DecisionConfig(
|
||||
vel_div_threshold=-0.02,
|
||||
fixed_tp_pct=0.002,
|
||||
capital_fraction=0.01,
|
||||
max_leverage=1.0,
|
||||
exit_leg_ratios=(0.5, 1.0),
|
||||
policy_version="pink_direct_test",
|
||||
)
|
||||
runtime = PinkDirectRuntime(
|
||||
data_feed=feed,
|
||||
kernel=kernel,
|
||||
decision_engine=DecisionEngine(cfg),
|
||||
intent_engine=IntentEngine(cfg),
|
||||
market_state_runtime=market_state_runtime,
|
||||
)
|
||||
|
||||
asyncio.run(runtime.connect(initial_capital=25000.0))
|
||||
asyncio.run(runtime.step(_snapshot(100.0, -0.1)))
|
||||
slot = kernel.slot(0)
|
||||
assert slot.is_open(), f"Expected open slot after entry, got {slot.fsm_state}"
|
||||
assert slot.to_dict().get("size", 0) > 0
|
||||
assert market_state_runtime.calls
|
||||
|
||||
asyncio.run(runtime.step(_snapshot(99.5, 0.05)))
|
||||
slot = kernel.slot(0)
|
||||
remaining = slot.to_dict().get("size", 0)
|
||||
assert remaining > 0, "Should still have position after partial exit"
|
||||
|
||||
asyncio.run(runtime.step(_snapshot(99.3, 0.05)))
|
||||
slot = kernel.slot(0)
|
||||
# The decision engine decides whether to exit; what matters is that
|
||||
# capital was not corrupted (logic should be profitable).
|
||||
assert kernel.account.snapshot.capital > 25000.0, \
|
||||
f"Expected capital > 25000 after profitable trades, got {kernel.account.snapshot.capital}"
|
||||
|
||||
asyncio.run(runtime.disconnect())
|
||||
assert feed.connected is False
|
||||
|
||||
|
||||
def test_runtime_enter_maps_correct_kernel_intent() -> None:
|
||||
"""Verify the runtime's decision-to-intent translation is correct."""
|
||||
from prod.clean_arch.dita import DecisionAction as DAction, TradeStage as TStage
|
||||
cfg = DecisionConfig(policy_version="pink_direct_test")
|
||||
runtime = PinkDirectRuntime(
|
||||
data_feed=_FakeFeed(),
|
||||
kernel=_FakeKernel(),
|
||||
decision_engine=DecisionEngine(cfg),
|
||||
intent_engine=IntentEngine(cfg),
|
||||
)
|
||||
decision = Decision(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
decision_id="d-001", asset="BTCUSDT",
|
||||
action=DAction.ENTER,
|
||||
side=LegacyTradeSide.SHORT,
|
||||
reason="test", confidence=0.8,
|
||||
velocity_divergence=-0.03, irp_alignment=0.5,
|
||||
reference_price=65000.0, target_size=0.01,
|
||||
leverage=2.0, bars_held=0,
|
||||
stage=TStage.ORDER_REQUESTED,
|
||||
metadata={},
|
||||
)
|
||||
intent = Intent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
trade_id="t-001", decision_id="d-001",
|
||||
asset="BTCUSDT",
|
||||
action=DAction.ENTER,
|
||||
side=LegacyTradeSide.SHORT,
|
||||
reason="test", target_size=0.01,
|
||||
leverage=2.0, reference_price=65000.0,
|
||||
confidence=0.8, bars_held=0,
|
||||
stage=TStage.INTENT_CREATED,
|
||||
exit_leg_ratios=(0.5, 1.0),
|
||||
metadata={},
|
||||
)
|
||||
ki = _decision_to_kernel_intent(decision, intent, slot_id=0)
|
||||
assert ki.action == KernelCommandType.ENTER
|
||||
assert ki.target_size == 0.01
|
||||
assert ki.side == TradeSide.SHORT
|
||||
|
||||
|
||||
def test_runtime_recovers_from_exchange_state() -> None:
|
||||
"""Startup recovery seeds slot from existing exchange position."""
|
||||
feed = _FakeFeed()
|
||||
kernel = _FakeKernel(capital=25000.0)
|
||||
# Pre-seed a position in the kernel's venue
|
||||
kernel.venue._position = {
|
||||
"trade_id": "BTCUSDT",
|
||||
"asset": "BTCUSDT",
|
||||
"side": "SHORT",
|
||||
"entry_price": 100.0,
|
||||
"size": 1.5,
|
||||
"leverage": 1.0,
|
||||
}
|
||||
cfg = DecisionConfig(policy_version="pink_direct_test")
|
||||
runtime = PinkDirectRuntime(
|
||||
data_feed=feed,
|
||||
kernel=kernel,
|
||||
decision_engine=DecisionEngine(cfg),
|
||||
intent_engine=IntentEngine(cfg),
|
||||
market_state_runtime=_FakeMarketStateRuntime(),
|
||||
)
|
||||
|
||||
asyncio.run(runtime.connect(initial_capital=25000.0))
|
||||
slot = kernel.slot(0)
|
||||
assert slot.is_open(), f"Expected open slot after recovery, got {slot.fsm_state}"
|
||||
assert slot.to_dict().get("size", 0) == 1.5, \
|
||||
f"Expected size 1.5, got {slot.to_dict().get('size')}"
|
||||
94
prod/tests/test_pink_ditav2_accounting_invariants.py
Normal file
94
prod/tests/test_pink_ditav2_accounting_invariants.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""Multi-leg non-double-book accounting invariant tests for PINK → DITAv2."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import unittest
|
||||
|
||||
from prod.clean_arch.dita_v2 import (
|
||||
ExecutionKernel,
|
||||
InMemoryControlPlane,
|
||||
InMemoryZincPlane,
|
||||
KernelCommandType,
|
||||
KernelIntent,
|
||||
MockVenueAdapter,
|
||||
MockVenueScenario,
|
||||
TradeSide,
|
||||
TradeStage,
|
||||
)
|
||||
|
||||
|
||||
class TestAccountingInvariants(unittest.TestCase):
|
||||
"""Verify single-application of capital deltas across multi-leg exits."""
|
||||
|
||||
def setUp(self):
|
||||
self.control = InMemoryControlPlane()
|
||||
self.venue = MockVenueAdapter(
|
||||
MockVenueScenario(
|
||||
reject_entries=False,
|
||||
reject_exits=False,
|
||||
partial_fill_ratio=0.5,
|
||||
cancel_reject=False,
|
||||
)
|
||||
)
|
||||
self.kernel = ExecutionKernel(
|
||||
max_slots=1,
|
||||
control_plane=self.control,
|
||||
venue=self.venue,
|
||||
zinc_plane=InMemoryZincPlane(),
|
||||
)
|
||||
|
||||
def _enter(self) -> None:
|
||||
intent = KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id="acct-entry-001",
|
||||
trade_id="acct-trade-001",
|
||||
slot_id=0,
|
||||
asset="BTCUSDT",
|
||||
side=TradeSide.SHORT,
|
||||
action=KernelCommandType.ENTER,
|
||||
reference_price=65000.0,
|
||||
target_size=0.01,
|
||||
leverage=2.0,
|
||||
reason="acct_test_entry",
|
||||
exit_leg_ratios=(0.5, 1.0),
|
||||
)
|
||||
self.kernel.process_intent(intent)
|
||||
|
||||
def _exit(self) -> None:
|
||||
intent = KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id="acct-exit-001",
|
||||
trade_id="acct-trade-001",
|
||||
slot_id=0,
|
||||
asset="BTCUSDT",
|
||||
side=TradeSide.SHORT,
|
||||
action=KernelCommandType.EXIT,
|
||||
reference_price=64500.0,
|
||||
target_size=0.005,
|
||||
leverage=2.0,
|
||||
reason="acct_test_exit",
|
||||
exit_leg_ratios=(0.5, 1.0),
|
||||
)
|
||||
self.kernel.process_intent(intent)
|
||||
|
||||
def test_capital_unchanged_after_entry(self):
|
||||
capital_before = self.kernel.account.snapshot.capital
|
||||
self._enter()
|
||||
capital_after = self.kernel.account.snapshot.capital
|
||||
self.assertEqual(capital_after, capital_before,
|
||||
"Entry should not change capital (no realized PnL)")
|
||||
|
||||
def test_full_cycle_does_not_crash(self):
|
||||
"""Run a full entry→partial exit lifecycle without errors."""
|
||||
self._enter()
|
||||
slot_before = self.kernel.slot(0)
|
||||
self.assertTrue(slot_before.is_open(), "Slot should be open after entry")
|
||||
self._exit()
|
||||
# After partial exit, slot may still be open or closed depending on mock behavior
|
||||
slot_after = self.kernel.slot(0)
|
||||
self.assertIsNotNone(slot_after, "Slot should still exist after partial exit")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
680
prod/tests/test_pink_ditav2_chaos_harness.py
Normal file
680
prod/tests/test_pink_ditav2_chaos_harness.py
Normal file
@@ -0,0 +1,680 @@
|
||||
"""Live chaos orchestrator + event sequencer + state-invariant checker.
|
||||
|
||||
This module implements three coordinated layers:
|
||||
|
||||
1. **ChaosOrchestrator** — submits adversarial intent sequences (rapid
|
||||
flips, competing cancels, size-at-boundary, cross-book) against a
|
||||
target venue (mock or live BingX) and the DITAv2 kernel in lockstep.
|
||||
|
||||
2. **EventSequencer** — captures every VenueEvent the kernel emitted
|
||||
during a chaos run, records the order they arrived, and can replay
|
||||
them against a fresh kernel to verify deterministic convergence.
|
||||
|
||||
3. **StateInvariantChecker** — given a kernel snapshot after a chaos run,
|
||||
asserts that slot and account state satisfy invariant rules regardless
|
||||
of the event ordering that produced them.
|
||||
|
||||
All three layers work with both MockVenueAdapter (fast iteration) and
|
||||
BingxVenueAdapter (live exchange) through the VenueAdapter protocol.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import itertools
|
||||
import math
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
||||
from unittest import mock
|
||||
|
||||
from prod.clean_arch.dita_v2.contracts import (
|
||||
KernelCommandType,
|
||||
KernelDiagnosticCode,
|
||||
KernelEventKind,
|
||||
KernelIntent,
|
||||
KernelOutcome,
|
||||
KernelSeverity,
|
||||
TradeSide,
|
||||
TradeSlot,
|
||||
TradeStage,
|
||||
VenueEvent,
|
||||
VenueEventStatus,
|
||||
VenueOrder,
|
||||
VenueOrderStatus,
|
||||
)
|
||||
from prod.clean_arch.dita_v2.rust_backend import ExecutionKernel
|
||||
from prod.clean_arch.dita_v2.venue import VenueAdapter
|
||||
from prod.clean_arch.dita_v2.mock_venue import MockVenueAdapter, MockVenueScenario
|
||||
from prod.clean_arch.dita_v2.control import (
|
||||
ControlUpdate,
|
||||
InMemoryControlPlane,
|
||||
KernelMode,
|
||||
KernelVerbosity,
|
||||
)
|
||||
from prod.clean_arch.dita_v2.zinc_plane import InMemoryZincPlane
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 1. Chaos Scenarios
|
||||
# =========================================================================
|
||||
|
||||
class ChaosAction(str, Enum):
|
||||
"""Atomic adversarial action in a chaos scenario."""
|
||||
ENTER = "ENTER"
|
||||
EXIT = "EXIT"
|
||||
CANCEL = "CANCEL"
|
||||
MARK_PRICE = "MARK_PRICE"
|
||||
RECONCILE = "RECONCILE"
|
||||
WAIT = "WAIT" # pause for N seconds
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChaosStep:
|
||||
"""A single step in a chaos scenario timeline."""
|
||||
action: ChaosAction
|
||||
delay_before: float = 0.0 # seconds to wait before submitting
|
||||
side: TradeSide = TradeSide.SHORT
|
||||
target_size: float = 0.01
|
||||
reference_price: float = 100.0
|
||||
leverage: float = 1.0
|
||||
exit_leg_ratios: Tuple[float, ...] = (1.0,)
|
||||
reason: str = "chaos"
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChaosScenario:
|
||||
"""A named chaos scenario — a timeline of adversarial intents."""
|
||||
name: str
|
||||
steps: Tuple[ChaosStep, ...]
|
||||
description: str = ""
|
||||
|
||||
|
||||
# Pre-built scenarios
|
||||
|
||||
SCENARIO_RAPID_ENTRY_EXIT = ChaosScenario(
|
||||
name="rapid_entry_exit",
|
||||
description="Rapid entry immediately followed by exit — tests race between submit and fill callback",
|
||||
steps=(
|
||||
ChaosStep(ChaosAction.ENTER, delay_before=0.0),
|
||||
ChaosStep(ChaosAction.EXIT, delay_before=0.01),
|
||||
),
|
||||
)
|
||||
|
||||
SCENARIO_TWO_LEG_RAPID = ChaosScenario(
|
||||
name="two_leg_rapid",
|
||||
description="Entry then two rapid exits — tests partial + final close race",
|
||||
steps=(
|
||||
ChaosStep(ChaosAction.ENTER, delay_before=0.0,
|
||||
exit_leg_ratios=(0.5, 1.0)),
|
||||
ChaosStep(ChaosAction.EXIT, delay_before=0.01, target_size=0.005),
|
||||
ChaosStep(ChaosAction.EXIT, delay_before=0.01, target_size=0.005),
|
||||
),
|
||||
)
|
||||
|
||||
SCENARIO_COMPETING_CANCEL = ChaosScenario(
|
||||
name="competing_cancel",
|
||||
description="Entry, then cancel immediately — tests cancel-after-submit race",
|
||||
steps=(
|
||||
ChaosStep(ChaosAction.ENTER, delay_before=0.0),
|
||||
ChaosStep(ChaosAction.CANCEL, delay_before=0.01),
|
||||
),
|
||||
)
|
||||
|
||||
SCENARIO_CANCEL_AFTER_FILL = ChaosScenario(
|
||||
name="cancel_after_fill",
|
||||
description="Entry with immediate fill, then cancel — tests cancel-on-closed-slot idempotency",
|
||||
steps=(
|
||||
ChaosStep(ChaosAction.ENTER, delay_before=0.0),
|
||||
ChaosStep(ChaosAction.CANCEL, delay_before=0.001),
|
||||
ChaosStep(ChaosAction.EXIT, delay_before=0.001),
|
||||
),
|
||||
)
|
||||
|
||||
SCENARIO_ENTRY_THEN_MARK = ChaosScenario(
|
||||
name="entry_then_mark",
|
||||
description="Entry followed by mark-price update",
|
||||
steps=(
|
||||
ChaosStep(ChaosAction.ENTER, delay_before=0.0),
|
||||
ChaosStep(ChaosAction.MARK_PRICE, delay_before=0.01,
|
||||
reference_price=99.5),
|
||||
),
|
||||
)
|
||||
|
||||
SCENARIO_ENTRY_RECONCILE_EXIT = ChaosScenario(
|
||||
name="entry_reconcile_exit",
|
||||
description="Entry, reconcile (simulate crash recovery), then exit",
|
||||
steps=(
|
||||
ChaosStep(ChaosAction.ENTER, delay_before=0.0),
|
||||
ChaosStep(ChaosAction.RECONCILE, delay_before=0.01),
|
||||
ChaosStep(ChaosAction.EXIT, delay_before=0.01),
|
||||
),
|
||||
)
|
||||
|
||||
SCENARIO_SIZE_AT_LOT_BOUNDARY = ChaosScenario(
|
||||
name="size_at_lot_boundary",
|
||||
description="Entry at lot-size boundary (0.001 BTC) — tests precision edge",
|
||||
steps=(
|
||||
ChaosStep(ChaosAction.ENTER, delay_before=0.0, target_size=0.001),
|
||||
ChaosStep(ChaosAction.EXIT, delay_before=0.01, target_size=0.001),
|
||||
),
|
||||
)
|
||||
|
||||
SCENARIO_ZERO_SIZE_ENTRY = ChaosScenario(
|
||||
name="zero_size_entry",
|
||||
description="Entry with target_size=0 — tests kernel edge guard",
|
||||
steps=(
|
||||
ChaosStep(ChaosAction.ENTER, delay_before=0.0, target_size=0.0),
|
||||
),
|
||||
)
|
||||
|
||||
SCENARIO_NEGATIVE_PRICE = ChaosScenario(
|
||||
name="negative_price_entry",
|
||||
description="Entry with negative reference price — tests kernel guard",
|
||||
steps=(
|
||||
ChaosStep(ChaosAction.ENTER, delay_before=0.0, reference_price=-1.0),
|
||||
),
|
||||
)
|
||||
|
||||
SCENARIO_ENTRY_EXIT_LOOP = ChaosScenario(
|
||||
name="entry_exit_10x",
|
||||
description="TEN rapid entry-exit cycles — tests state-machine fatigue",
|
||||
steps=tuple(
|
||||
ChaosStep(ChaosAction.ENTER if i % 2 == 0 else ChaosAction.EXIT,
|
||||
delay_before=0.005,
|
||||
reason=f"chaos_cycle_{i//2}")
|
||||
for i in range(20)
|
||||
),
|
||||
)
|
||||
|
||||
ALL_SCENARIOS: Tuple[ChaosScenario, ...] = (
|
||||
SCENARIO_RAPID_ENTRY_EXIT,
|
||||
SCENARIO_TWO_LEG_RAPID,
|
||||
SCENARIO_ENTRY_THEN_MARK,
|
||||
SCENARIO_SIZE_AT_LOT_BOUNDARY,
|
||||
SCENARIO_ENTRY_EXIT_LOOP,
|
||||
)
|
||||
|
||||
# Scenarios that require special venue configuration.
|
||||
SCENARIO_REJECT_ENTRY = SCENARIO_COMPETING_CANCEL # use reject_entries=True
|
||||
SCENARIO_REJECT_EXIT = SCENARIO_CANCEL_AFTER_FILL # use cancel_reject=True
|
||||
EDGE_CASE_SCENARIOS: Tuple[ChaosScenario, ...] = (
|
||||
SCENARIO_ZERO_SIZE_ENTRY,
|
||||
SCENARIO_NEGATIVE_PRICE,
|
||||
)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 2. Chaos Orchestrator
|
||||
# =========================================================================
|
||||
|
||||
@dataclass
|
||||
class ChaosRunResult:
|
||||
"""Result of executing a chaos scenario against a kernel."""
|
||||
scenario_name: str
|
||||
outcomes: List[KernelOutcome]
|
||||
events: List[VenueEvent] # all events emitted during run
|
||||
slot_states: List[Dict[str, Any]] # slot snapshot after each step
|
||||
account_snapshots: List[Dict[str, Any]] # account after each step
|
||||
final_outcome: Optional[KernelOutcome] # last outcome
|
||||
passed: bool = False
|
||||
failure_reason: str = ""
|
||||
|
||||
|
||||
def _step_to_intent(step: ChaosStep, slot_id: int = 0, trade_seq: int = 0) -> KernelIntent:
|
||||
"""Convert a ChaosStep into a KernelIntent."""
|
||||
action_map = {
|
||||
ChaosAction.ENTER: KernelCommandType.ENTER,
|
||||
ChaosAction.EXIT: KernelCommandType.EXIT,
|
||||
ChaosAction.CANCEL: KernelCommandType.CANCEL,
|
||||
ChaosAction.MARK_PRICE: KernelCommandType.MARK_PRICE,
|
||||
ChaosAction.RECONCILE: KernelCommandType.RECONCILE,
|
||||
}
|
||||
return KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id=f"chaos-{trade_seq}-{step.action.value.lower()}",
|
||||
trade_id=f"chaos-trade-{trade_seq}",
|
||||
slot_id=slot_id,
|
||||
asset="BTCUSDT",
|
||||
side=step.side,
|
||||
action=action_map.get(step.action, KernelCommandType.MARK_PRICE),
|
||||
reference_price=step.reference_price,
|
||||
target_size=step.target_size,
|
||||
leverage=step.leverage,
|
||||
exit_leg_ratios=step.exit_leg_ratios,
|
||||
reason=step.reason,
|
||||
metadata=dict(step.metadata),
|
||||
)
|
||||
|
||||
|
||||
def run_chaos_scenario(
|
||||
kernel: ExecutionKernel,
|
||||
scenario: ChaosScenario,
|
||||
slot_id: int = 0,
|
||||
*,
|
||||
event_capture: Optional[List[VenueEvent]] = None,
|
||||
) -> ChaosRunResult:
|
||||
"""Execute a chaos scenario against a kernel.
|
||||
|
||||
This is the core orchestrator. It:
|
||||
1. Walks the scenario timeline.
|
||||
2. Submits each intent through the kernel.
|
||||
3. Captures all outcomes, events, and state snapshots.
|
||||
4. Returns a ChaosRunResult for the checker.
|
||||
|
||||
If *event_capture* is provided, events are appended to it so an
|
||||
external EventSequencer can capture the full stream.
|
||||
"""
|
||||
outcomes: List[KernelOutcome] = []
|
||||
events: List[VenueEvent] = []
|
||||
slot_states: List[Dict[str, Any]] = []
|
||||
account_snapshots: List[Dict[str, Any]] = []
|
||||
|
||||
trade_seq = 0
|
||||
for step_i, step in enumerate(scenario.steps):
|
||||
if step.delay_before > 0:
|
||||
time.sleep(step.delay_before)
|
||||
|
||||
if step.action == ChaosAction.WAIT:
|
||||
continue
|
||||
|
||||
if step.action == ChaosAction.RECONCILE:
|
||||
slots = [kernel.slot(i) for i in range(kernel.max_slots)]
|
||||
outcome = kernel.reconcile_from_slots(
|
||||
[s._snapshot() if hasattr(s, '_snapshot') else None for s in slots if s]
|
||||
)
|
||||
outcomes.append(outcome)
|
||||
else:
|
||||
trade_seq += 1
|
||||
intent = _step_to_intent(step, slot_id, trade_seq)
|
||||
outcome = kernel.process_intent(intent)
|
||||
outcomes.append(outcome)
|
||||
|
||||
# Collect all emitted events from the outcome
|
||||
for event in outcome.emitted_events:
|
||||
events.append(event)
|
||||
if event_capture is not None:
|
||||
event_capture.append(event)
|
||||
|
||||
# Snapshot state
|
||||
slot = kernel.slot(slot_id) if 0 <= slot_id < kernel.max_slots else None
|
||||
slot_states.append(slot.to_dict() if slot is not None else {})
|
||||
account_snapshots.append(dict(kernel.snapshot().get("account", {})))
|
||||
|
||||
final = outcomes[-1] if outcomes else None
|
||||
return ChaosRunResult(
|
||||
scenario_name=scenario.name,
|
||||
outcomes=outcomes,
|
||||
events=events,
|
||||
slot_states=slot_states,
|
||||
account_snapshots=account_snapshots,
|
||||
final_outcome=final,
|
||||
)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 3. Event Sequencer
|
||||
# =========================================================================
|
||||
|
||||
class EventSequencer:
|
||||
"""Captures, stores, and replays VenueEvent streams.
|
||||
|
||||
The sequencer can replay a captured event stream against a fresh
|
||||
kernel to verify that the kernel converges to the same state
|
||||
regardless of the order events arrived.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.events: List[VenueEvent] = []
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def capture(self, event: VenueEvent) -> None:
|
||||
"""Capture a single event (thread-safe)."""
|
||||
with self._lock:
|
||||
self.events.append(event)
|
||||
|
||||
def capture_many(self, events: Sequence[VenueEvent]) -> None:
|
||||
for event in events:
|
||||
self.capture(event)
|
||||
|
||||
def replay_against(
|
||||
self,
|
||||
kernel: ExecutionKernel,
|
||||
*,
|
||||
shuffle: bool = False,
|
||||
seed: int = 42,
|
||||
) -> List[KernelOutcome]:
|
||||
"""Feed captured events into a fresh kernel.
|
||||
|
||||
Returns the list of outcomes. If *shuffle* is True, events are
|
||||
replayed in random order to test convergence under non-deterministic
|
||||
callback ordering.
|
||||
"""
|
||||
to_replay = list(self.events)
|
||||
if shuffle:
|
||||
rng = random.Random(seed)
|
||||
rng.shuffle(to_replay)
|
||||
|
||||
outcomes: List[KernelOutcome] = []
|
||||
for event in to_replay:
|
||||
outcome = kernel.on_venue_event(event)
|
||||
outcomes.append(outcome)
|
||||
return outcomes
|
||||
|
||||
@property
|
||||
def count(self) -> int:
|
||||
return len(self.events)
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
self.events.clear()
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 4. State Invariant Checker
|
||||
# =========================================================================
|
||||
|
||||
@dataclass
|
||||
class InvariantResult:
|
||||
"""Result of checking a single invariant."""
|
||||
name: str
|
||||
passed: bool
|
||||
detail: str = ""
|
||||
slot_id: int = 0
|
||||
|
||||
|
||||
class StateInvariantChecker:
|
||||
"""Set of invariant rules that must hold after any chaos run.
|
||||
|
||||
Each invariant is a method returning InvariantResult. All invariants
|
||||
must pass for the chaos run to be considered clean.
|
||||
"""
|
||||
|
||||
def __init__(self, kernel: ExecutionKernel):
|
||||
self.kernel = kernel
|
||||
|
||||
def check_all(self, result: ChaosRunResult) -> List[InvariantResult]:
|
||||
"""Run all invariants and return results."""
|
||||
checks: List[InvariantResult] = [
|
||||
self._check_slot_not_stuck_in_reconcile(result),
|
||||
self._check_capital_non_negative(result),
|
||||
self._check_no_unexpected_diagnostics(result),
|
||||
self._check_slot_fsm_consistent(result),
|
||||
self._check_account_equity_consistent(result),
|
||||
self._check_no_leaked_futures(result),
|
||||
]
|
||||
return checks
|
||||
|
||||
def all_pass(self, result: ChaosRunResult) -> bool:
|
||||
return all(c.passed for c in self.check_all(result))
|
||||
|
||||
def _check_slot_not_stuck_in_reconcile(
|
||||
self, result: ChaosRunResult,
|
||||
) -> InvariantResult:
|
||||
"""No slot should be stuck in STALE_STATE_RECONCILING at end."""
|
||||
for slot_id in range(self.kernel.max_slots):
|
||||
slot = self.kernel.slot(slot_id)
|
||||
if slot.fsm_state == TradeStage.STALE_STATE_RECONCILING:
|
||||
return InvariantResult(
|
||||
"slot_not_stuck", False,
|
||||
f"Slot {slot_id} stuck in STALE_STATE_RECONCILING",
|
||||
slot_id,
|
||||
)
|
||||
return InvariantResult("slot_not_stuck", True)
|
||||
|
||||
def _check_capital_non_negative(self, result: ChaosRunResult) -> InvariantResult:
|
||||
"""Capital must never go negative."""
|
||||
for i, snap in enumerate(result.account_snapshots):
|
||||
cap = float(snap.get("capital", 0.0))
|
||||
if cap < 0:
|
||||
return InvariantResult(
|
||||
"capital_non_negative", False,
|
||||
f"Capital went negative at step {i}: {cap}",
|
||||
)
|
||||
return InvariantResult("capital_non_negative", True)
|
||||
|
||||
def _check_no_unexpected_diagnostics(self, result: ChaosRunResult) -> InvariantResult:
|
||||
"""No CRITICAL or unexpected ERROR diagnostics."""
|
||||
unexpected = {
|
||||
KernelDiagnosticCode.INVALID_SLOT_ID,
|
||||
KernelDiagnosticCode.UNSUPPORTED_INTENT,
|
||||
KernelDiagnosticCode.UNKNOWN_EVENT_KIND,
|
||||
KernelDiagnosticCode.INVALID_TRANSITION,
|
||||
KernelDiagnosticCode.TERMINAL_STATE,
|
||||
}
|
||||
for outcome in result.outcomes:
|
||||
if outcome.diagnostic_code in unexpected:
|
||||
return InvariantResult(
|
||||
"no_unexpected_diagnostics", False,
|
||||
f"Unexpected diagnostic: {outcome.diagnostic_code.value} "
|
||||
f"(severity={outcome.severity.value})",
|
||||
)
|
||||
if outcome.severity == KernelSeverity.CRITICAL:
|
||||
return InvariantResult(
|
||||
"no_unexpected_diagnostics", False,
|
||||
f"CRITICAL severity: {outcome.diagnostic_code.value}",
|
||||
)
|
||||
return InvariantResult("no_unexpected_diagnostics", True)
|
||||
|
||||
def _check_slot_fsm_consistent(self, result: ChaosRunResult) -> InvariantResult:
|
||||
"""FSM transitions must be valid (no illegal jumps)."""
|
||||
valid_states = {
|
||||
TradeStage.IDLE,
|
||||
TradeStage.DECISION_CREATED, TradeStage.INTENT_CREATED,
|
||||
TradeStage.ORDER_REQUESTED, TradeStage.ORDER_SENT,
|
||||
TradeStage.ORDER_ACKED, TradeStage.ORDER_REJECTED,
|
||||
TradeStage.ENTRY_WORKING, TradeStage.PARTIAL_FILL,
|
||||
TradeStage.POSITION_OPENED, TradeStage.POSITION_OPEN,
|
||||
TradeStage.EXIT_REQUESTED, TradeStage.EXIT_SENT,
|
||||
TradeStage.EXIT_ACKED, TradeStage.EXIT_REJECTED,
|
||||
TradeStage.EXIT_WORKING,
|
||||
TradeStage.POSITION_PARTIALLY_CLOSED, TradeStage.POSITION_CLOSED,
|
||||
TradeStage.CLOSED, TradeStage.TRADE_TERMINAL_WRITTEN,
|
||||
TradeStage.STALE_STATE_RECONCILING,
|
||||
}
|
||||
for slot_dict in result.slot_states:
|
||||
fsm = slot_dict.get("fsm_state", "IDLE")
|
||||
if fsm not in [s.value for s in valid_states]:
|
||||
return InvariantResult(
|
||||
"fsm_consistent", False,
|
||||
f"Unknown FSM state: {fsm}",
|
||||
)
|
||||
return InvariantResult("fsm_consistent", True)
|
||||
|
||||
def _check_account_equity_consistent(self, result: ChaosRunResult) -> InvariantResult:
|
||||
"""Equity must be positive (non-negative) throughout the run."""
|
||||
for i, snap in enumerate(result.account_snapshots):
|
||||
equity = float(snap.get("equity", 0.0))
|
||||
if not math.isfinite(equity):
|
||||
return InvariantResult(
|
||||
"equity_consistent", False,
|
||||
f"Step {i}: non-finite equity={equity}",
|
||||
)
|
||||
return InvariantResult("equity_consistent", True)
|
||||
|
||||
def _check_no_leaked_futures(self, result: ChaosRunResult) -> InvariantResult:
|
||||
"""No futures leaked from thread pool (our own seam check)."""
|
||||
# The _run() method creates transient ThreadPoolExecutors.
|
||||
# If any leaked, the system would accumulate threads.
|
||||
# We check that the common thread pool patterns are not growing.
|
||||
import concurrent.futures
|
||||
# Not a perfect check, but a hygiene assertion
|
||||
return InvariantResult("no_leaked_futures", True)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 5. High-level runners
|
||||
# =========================================================================
|
||||
|
||||
def build_test_kernel(
|
||||
*,
|
||||
reject_entries: bool = False,
|
||||
reject_exits: bool = False,
|
||||
partial_fill_ratio: float = 1.0,
|
||||
cancel_reject: bool = False,
|
||||
) -> ExecutionKernel:
|
||||
"""Build a test kernel with the given mock venue scenario."""
|
||||
control = InMemoryControlPlane()
|
||||
control.update(ControlUpdate(
|
||||
mode=KernelMode.DEBUG, trace_transitions=True,
|
||||
))
|
||||
venue = MockVenueAdapter(MockVenueScenario(
|
||||
reject_entries=reject_entries,
|
||||
reject_exits=reject_exits,
|
||||
partial_fill_ratio=partial_fill_ratio,
|
||||
cancel_reject=cancel_reject,
|
||||
))
|
||||
return ExecutionKernel(
|
||||
max_slots=2,
|
||||
control_plane=control,
|
||||
venue=venue,
|
||||
zinc_plane=InMemoryZincPlane(),
|
||||
)
|
||||
|
||||
|
||||
def run_scenario_and_check(
|
||||
scenario: ChaosScenario,
|
||||
**venue_kwargs,
|
||||
) -> Tuple[ChaosRunResult, List[InvariantResult]]:
|
||||
"""Run a chaos scenario and check invariants.
|
||||
|
||||
Returns (result, checks).
|
||||
"""
|
||||
kernel = build_test_kernel(**venue_kwargs)
|
||||
sequencer = EventSequencer()
|
||||
result = run_chaos_scenario(kernel, scenario, event_capture=sequencer.events)
|
||||
checker = StateInvariantChecker(kernel)
|
||||
checks = checker.check_all(result)
|
||||
result.passed = all(c.passed for c in checks)
|
||||
if not result.passed:
|
||||
failures = [c for c in checks if not c.passed]
|
||||
result.failure_reason = "; ".join(f"{f.name}: {f.detail}" for f in failures)
|
||||
return result, checks
|
||||
|
||||
|
||||
def run_scenario_twice_compare(
|
||||
scenario: ChaosScenario,
|
||||
**venue_kwargs,
|
||||
) -> Tuple[ChaosRunResult, ChaosRunResult, bool]:
|
||||
"""Run the same scenario twice on fresh kernels and compare final state.
|
||||
|
||||
Returns (result1, result2, states_match). Both kernels should
|
||||
converge to the same terminal state for the same input sequence.
|
||||
"""
|
||||
k1 = build_test_kernel(**venue_kwargs)
|
||||
k2 = build_test_kernel(**venue_kwargs)
|
||||
|
||||
s1 = EventSequencer()
|
||||
s2 = EventSequencer()
|
||||
|
||||
r1 = run_chaos_scenario(k1, scenario, event_capture=s1.events)
|
||||
r2 = run_chaos_scenario(k2, scenario, event_capture=s2.events)
|
||||
|
||||
# Compare final slot states
|
||||
slot1 = k1.slot(0).to_dict() if k1.max_slots > 0 else {}
|
||||
slot2 = k2.slot(0).to_dict() if k2.max_slots > 0 else {}
|
||||
|
||||
def _compare_key(sd: Dict) -> str:
|
||||
return json.dumps({
|
||||
k: sd.get(k) for k in (
|
||||
"fsm_state", "size", "trade_id", "closed",
|
||||
"realized_pnl", "active_leg_index"
|
||||
)
|
||||
}, sort_keys=True)
|
||||
|
||||
match = bool(_compare_key(slot1) == _compare_key(slot2))
|
||||
return r1, r2, match
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 6. pytest fixtures
|
||||
# =========================================================================
|
||||
import json
|
||||
import pytest
|
||||
|
||||
|
||||
def _scenario_id(scenario: ChaosScenario) -> str:
|
||||
return scenario.name
|
||||
|
||||
|
||||
def _venue_for_scenario(scenario: ChaosScenario) -> dict:
|
||||
"""Return venue kwargs appropriate for the scenario."""
|
||||
if scenario is SCENARIO_COMPETING_CANCEL:
|
||||
return {"partial_fill_ratio": 0.5}
|
||||
if scenario is SCENARIO_CANCEL_AFTER_FILL:
|
||||
return {"partial_fill_ratio": 0.5}
|
||||
if scenario is SCENARIO_ENTRY_RECONCILE_EXIT:
|
||||
return {"partial_fill_ratio": 0.5}
|
||||
return {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scenario", ALL_SCENARIOS, ids=_scenario_id)
|
||||
def test_chaos_scenario_basic(scenario: ChaosScenario) -> None:
|
||||
"""Every chaos scenario must complete without crash or invariant violation."""
|
||||
result, checks = run_scenario_and_check(scenario)
|
||||
failures = [c for c in checks if not c.passed]
|
||||
assert not failures, \
|
||||
f"Scenario '{scenario.name}' failed invariants: " + "; ".join(
|
||||
f"{f.name}: {f.detail}" for f in failures
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scenario", EDGE_CASE_SCENARIOS, ids=_scenario_id)
|
||||
def test_chaos_scenario_edge_cases(scenario: ChaosScenario) -> None:
|
||||
"""Edge case scenarios must not crash the kernel."""
|
||||
result, checks = run_scenario_and_check(scenario)
|
||||
for outcome in result.outcomes:
|
||||
if outcome.diagnostic_code == KernelDiagnosticCode.INVALID_SLOT_ID:
|
||||
pytest.fail(f"Edge case caused INVALID_SLOT_ID: {outcome.details}")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scenario", [
|
||||
s for s in ALL_SCENARIOS
|
||||
if s.name not in ("zero_size_entry", "negative_price_entry")
|
||||
], ids=_scenario_id)
|
||||
def test_chaos_scenario_deterministic(scenario: ChaosScenario) -> None:
|
||||
"""Running the same scenario twice must produce valid final state both times."""
|
||||
r1, r2, match = run_scenario_twice_compare(scenario)
|
||||
for label, r in [("run1", r1), ("run2", r2)]:
|
||||
if r.final_outcome is not None:
|
||||
assert r.final_outcome.diagnostic_code in {
|
||||
KernelDiagnosticCode.OK, KernelDiagnosticCode.ORDER_REJECTED,
|
||||
}, f"{label} ended with unexpected diagnostic: {r.final_outcome.diagnostic_code}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scenario", ALL_SCENARIOS, ids=_scenario_id)
|
||||
def test_chaos_scenario_replay_ordered(scenario: ChaosScenario) -> None:
|
||||
"""Replaying captured events in original order must not crash."""
|
||||
kernel1 = build_test_kernel()
|
||||
sequencer = EventSequencer()
|
||||
run_chaos_scenario(kernel1, scenario, event_capture=sequencer.events)
|
||||
kernel2 = build_test_kernel()
|
||||
outcomes = sequencer.replay_against(kernel2, shuffle=False)
|
||||
for outcome in outcomes:
|
||||
assert outcome.diagnostic_code != KernelDiagnosticCode.INVALID_SLOT_ID, \
|
||||
f"Replay caused INVALID_SLOT_ID: {outcome.details}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scenario", ALL_SCENARIOS, ids=_scenario_id)
|
||||
def test_chaos_scenario_replay_shuffled(scenario: ChaosScenario) -> None:
|
||||
"""Replaying captured events in random order must not crash."""
|
||||
kernel1 = build_test_kernel()
|
||||
sequencer = EventSequencer()
|
||||
run_chaos_scenario(kernel1, scenario, event_capture=sequencer.events)
|
||||
kernel2 = build_test_kernel()
|
||||
outcomes = sequencer.replay_against(kernel2, shuffle=True, seed=42)
|
||||
for outcome in outcomes:
|
||||
assert outcome.diagnostic_code != KernelDiagnosticCode.INVALID_SLOT_ID, \
|
||||
f"Shuffled replay caused INVALID_SLOT_ID: {outcome.details}"
|
||||
slot = kernel2.slot(0)
|
||||
assert slot.fsm_state != TradeStage.STALE_STATE_RECONCILING, \
|
||||
f"Shuffled replay left slot stuck in STALE_STATE_RECONCILING"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "--tb=short"])
|
||||
139
prod/tests/test_pink_ditav2_kernel_bridge.py
Normal file
139
prod/tests/test_pink_ditav2_kernel_bridge.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""Decision → KernelIntent mapping table tests for PINK → DITAv2 bridge."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import unittest
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Minimal import path — avoid dita_v2.__init__ which pulls in bingx_venue + legacy DITA
|
||||
sys.path.insert(0, "/mnt/dolphinng5_predict/prod")
|
||||
sys.path.insert(0, "/mnt/dolphinng5_predict/prod/clean_arch")
|
||||
|
||||
os.environ.setdefault("HZ_CLUSTER", "dolphin")
|
||||
os.environ.setdefault("HZ_HOST", "localhost:5701")
|
||||
os.environ.setdefault("BINGX_API_KEY", "test")
|
||||
os.environ.setdefault("BINGX_SECRET_KEY", "test")
|
||||
|
||||
from clean_arch.dita import (
|
||||
Decision,
|
||||
DecisionAction,
|
||||
Intent,
|
||||
TradeSide as LegacyTradeSide,
|
||||
TradeStage as LegacyTradeStage,
|
||||
)
|
||||
from clean_arch.dita_v2.contracts import (
|
||||
KernelCommandType,
|
||||
KernelIntent,
|
||||
TradeSide as DitaTradeSide,
|
||||
)
|
||||
from clean_arch.runtime.pink_direct import _decision_to_kernel_intent
|
||||
|
||||
|
||||
def _make_test_decision(
|
||||
action: DecisionAction = DecisionAction.ENTER,
|
||||
side: LegacyTradeSide = LegacyTradeSide.SHORT,
|
||||
) -> Decision:
|
||||
return Decision(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
decision_id="test-decision-001",
|
||||
asset="BTCUSDT",
|
||||
action=action,
|
||||
side=side,
|
||||
reason="test",
|
||||
confidence=0.8,
|
||||
velocity_divergence=-0.03,
|
||||
irp_alignment=0.5,
|
||||
reference_price=65000.0,
|
||||
target_size=0.01,
|
||||
leverage=2.0,
|
||||
bars_held=0,
|
||||
stage=LegacyTradeStage.ORDER_REQUESTED,
|
||||
metadata={},
|
||||
)
|
||||
|
||||
|
||||
def _make_test_intent(
|
||||
action: DecisionAction = DecisionAction.ENTER,
|
||||
side: LegacyTradeSide = LegacyTradeSide.SHORT,
|
||||
) -> Intent:
|
||||
return Intent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
trade_id="test-trade-001",
|
||||
decision_id="test-decision-001",
|
||||
asset="BTCUSDT",
|
||||
action=action,
|
||||
side=side,
|
||||
reason="test",
|
||||
target_size=0.01,
|
||||
leverage=2.0,
|
||||
reference_price=65000.0,
|
||||
confidence=0.8,
|
||||
bars_held=0,
|
||||
stage=LegacyTradeStage.INTENT_CREATED,
|
||||
exit_leg_ratios=(0.5, 1.0),
|
||||
metadata={"entry_velocity_divergence": -0.03},
|
||||
)
|
||||
|
||||
|
||||
class TestDecisionToKernelIntent(unittest.TestCase):
|
||||
"""Verify every DecisionAction maps to the correct KernelCommandType."""
|
||||
|
||||
maxDiff = None
|
||||
|
||||
def test_enter_maps_to_enter(self):
|
||||
decision = _make_test_decision(DecisionAction.ENTER)
|
||||
intent = _make_test_intent(DecisionAction.ENTER)
|
||||
ki = _decision_to_kernel_intent(decision, intent, slot_id=0)
|
||||
self.assertEqual(ki.action, KernelCommandType.ENTER)
|
||||
self.assertEqual(ki.slot_id, 0)
|
||||
self.assertEqual(ki.trade_id, "test-trade-001")
|
||||
self.assertEqual(ki.asset, "BTCUSDT")
|
||||
self.assertEqual(ki.side, DitaTradeSide.SHORT)
|
||||
self.assertEqual(ki.reference_price, 65000.0)
|
||||
self.assertEqual(ki.target_size, 0.01)
|
||||
self.assertEqual(ki.leverage, 2.0)
|
||||
self.assertEqual(ki.exit_leg_ratios, (0.5, 1.0))
|
||||
|
||||
def test_exit_maps_to_exit(self):
|
||||
decision = _make_test_decision(DecisionAction.EXIT)
|
||||
intent = _make_test_intent(DecisionAction.EXIT)
|
||||
ki = _decision_to_kernel_intent(decision, intent, slot_id=0)
|
||||
self.assertEqual(ki.action, KernelCommandType.EXIT)
|
||||
|
||||
def test_hold_maps_to_mark_price(self):
|
||||
decision = _make_test_decision(DecisionAction.HOLD)
|
||||
intent = _make_test_intent(DecisionAction.HOLD)
|
||||
ki = _decision_to_kernel_intent(decision, intent, slot_id=0)
|
||||
self.assertEqual(ki.action, KernelCommandType.MARK_PRICE)
|
||||
|
||||
def test_side_long_maps_correctly(self):
|
||||
decision = _make_test_decision(DecisionAction.ENTER, LegacyTradeSide.LONG)
|
||||
intent = _make_test_intent(DecisionAction.ENTER, LegacyTradeSide.LONG)
|
||||
ki = _decision_to_kernel_intent(decision, intent, slot_id=0)
|
||||
self.assertEqual(ki.side, DitaTradeSide.LONG)
|
||||
|
||||
def test_side_short_maps_correctly(self):
|
||||
decision = _make_test_decision(DecisionAction.ENTER, LegacyTradeSide.SHORT)
|
||||
intent = _make_test_intent(DecisionAction.ENTER, LegacyTradeSide.SHORT)
|
||||
ki = _decision_to_kernel_intent(decision, intent, slot_id=0)
|
||||
self.assertEqual(ki.side, DitaTradeSide.SHORT)
|
||||
|
||||
def test_metadata_is_preserved(self):
|
||||
decision = _make_test_decision()
|
||||
intent = _make_test_intent()
|
||||
intent.metadata["exit_ratio"] = 0.5
|
||||
ki = _decision_to_kernel_intent(decision, intent, slot_id=0)
|
||||
self.assertEqual(ki.metadata.get("exit_ratio"), 0.5)
|
||||
self.assertEqual(ki.metadata.get("entry_velocity_divergence"), -0.03)
|
||||
|
||||
def test_slot_id_passthrough(self):
|
||||
decision = _make_test_decision()
|
||||
intent = _make_test_intent()
|
||||
ki = _decision_to_kernel_intent(decision, intent, slot_id=5)
|
||||
self.assertEqual(ki.slot_id, 5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
71
prod/tests/test_pink_ditav2_rate_limit_contract.py
Normal file
71
prod/tests/test_pink_ditav2_rate_limit_contract.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Rate-limit classification + downstream emission tests for PINK + DITAv2."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import unittest
|
||||
|
||||
from prod.clean_arch.dita_v2 import (
|
||||
ControlUpdate,
|
||||
ExecutionKernel,
|
||||
InMemoryControlPlane,
|
||||
InMemoryZincPlane,
|
||||
KernelCommandType,
|
||||
KernelDiagnosticCode,
|
||||
KernelIntent,
|
||||
KernelMode,
|
||||
MockVenueAdapter,
|
||||
MockVenueScenario,
|
||||
TradeSide,
|
||||
)
|
||||
|
||||
|
||||
class TestRateLimitContract(unittest.TestCase):
|
||||
"""Verify the kernel handles venue rejections without corrupting state."""
|
||||
|
||||
def setUp(self):
|
||||
self.control = InMemoryControlPlane()
|
||||
self.control.update(ControlUpdate(
|
||||
mode=KernelMode.DEBUG, trace_transitions=True,
|
||||
))
|
||||
self.venue = MockVenueAdapter(
|
||||
MockVenueScenario(
|
||||
reject_entries=True,
|
||||
reject_exits=False,
|
||||
partial_fill_ratio=0.0,
|
||||
cancel_reject=False,
|
||||
)
|
||||
)
|
||||
self.kernel = ExecutionKernel(
|
||||
max_slots=1,
|
||||
control_plane=self.control,
|
||||
venue=self.venue,
|
||||
zinc_plane=InMemoryZincPlane(),
|
||||
)
|
||||
|
||||
def _make_intent(self, action: KernelCommandType = KernelCommandType.ENTER) -> KernelIntent:
|
||||
return KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id="rate-test-001",
|
||||
trade_id="rate-trade-001",
|
||||
slot_id=0,
|
||||
asset="BTCUSDT",
|
||||
side=TradeSide.SHORT,
|
||||
action=action,
|
||||
reference_price=65000.0,
|
||||
target_size=0.01,
|
||||
leverage=2.0,
|
||||
reason="rate_limit_test",
|
||||
)
|
||||
|
||||
def test_kernel_state_unaffected_by_rejection(self):
|
||||
"""Slot returns to free/IDLE after venue rejects entry."""
|
||||
intent = self._make_intent(KernelCommandType.ENTER)
|
||||
self.kernel.process_intent(intent)
|
||||
slot = self.kernel.slot(0)
|
||||
self.assertTrue(slot.is_free(),
|
||||
f"Slot should be free after reject, got {slot.fsm_state}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
75
prod/tests/test_pink_ditav2_restart_reconcile.py
Normal file
75
prod/tests/test_pink_ditav2_restart_reconcile.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""Crash/restart reconcile convergence tests for PINK → DITAv2."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import unittest
|
||||
|
||||
from prod.clean_arch.dita_v2 import (
|
||||
ExecutionKernel,
|
||||
InMemoryControlPlane,
|
||||
InMemoryZincPlane,
|
||||
KernelCommandType,
|
||||
KernelIntent,
|
||||
MockVenueAdapter,
|
||||
MockVenueScenario,
|
||||
TradeSide,
|
||||
TradeSlot,
|
||||
TradeStage,
|
||||
)
|
||||
|
||||
|
||||
class TestRestartReconcile(unittest.TestCase):
|
||||
"""Verify exchange-led state convergence after simulated crash/restart."""
|
||||
|
||||
def setUp(self):
|
||||
self.control = InMemoryControlPlane()
|
||||
self.venue = MockVenueAdapter() # deterministic mock
|
||||
self.kernel = ExecutionKernel(
|
||||
max_slots=2,
|
||||
control_plane=self.control,
|
||||
venue=self.venue,
|
||||
zinc_plane=InMemoryZincPlane(),
|
||||
)
|
||||
|
||||
def _enter_position(self) -> None:
|
||||
intent = KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id="entry-001",
|
||||
trade_id="trade-001",
|
||||
slot_id=0,
|
||||
asset="BTCUSDT",
|
||||
side=TradeSide.SHORT,
|
||||
action=KernelCommandType.ENTER,
|
||||
reference_price=65000.0,
|
||||
target_size=0.01,
|
||||
leverage=2.0,
|
||||
reason="restart_test_entry",
|
||||
)
|
||||
self.kernel.process_intent(intent)
|
||||
|
||||
def test_entry_opens_slot(self):
|
||||
self._enter_position()
|
||||
slot = self.kernel.slot(0)
|
||||
self.assertTrue(slot.is_open(),
|
||||
f"Expected open slot after entry, got {slot.fsm_state}")
|
||||
|
||||
def test_reconcile_with_empty_does_not_crash(self):
|
||||
self._enter_position()
|
||||
# Reconcile with empty list — no-op
|
||||
outcome = self.kernel.reconcile_from_slots([])
|
||||
self.assertIsNotNone(outcome,
|
||||
"Reconcile should return an outcome")
|
||||
|
||||
def test_capital_seed_after_reconcile(self):
|
||||
self._enter_position()
|
||||
capital_before = self.kernel.account.snapshot.capital
|
||||
self.assertGreater(capital_before, 0)
|
||||
self.kernel.reconcile_from_slots([])
|
||||
capital_after = self.kernel.account.snapshot.capital
|
||||
self.assertEqual(capital_after, capital_before,
|
||||
"Capital should not change during reconcile")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
848
prod/tests/test_pink_extended.py
Normal file
848
prod/tests/test_pink_extended.py
Normal file
@@ -0,0 +1,848 @@
|
||||
"""
|
||||
PINK system — extended unit + E2E tests.
|
||||
|
||||
Covers namespace isolation, routing, config parity, CH schema, control plane,
|
||||
supervisord config, PINK CTL tool, TUI, VST safety gates, env-driven
|
||||
namespace overrides, data volume controls, and boundary conditions.
|
||||
|
||||
Complements existing test_pink_routing.py (44 tests) and test_dolphin_status_pink.py (15 tests).
|
||||
Total across all PINK test files: 100+ tests.
|
||||
|
||||
Run:
|
||||
python -m pytest prod/tests/test_pink_extended.py -v
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time as _time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch, call
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "nautilus_dolphin"))
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# DATA VOLUME / ACCOUNT EVENT CONTROLS
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestAccountEventRateCap(unittest.TestCase):
|
||||
"""PINK must enforce account_event rate limits per §10."""
|
||||
|
||||
def test_default_rate_cap_5_rows_per_sec(self):
|
||||
from prod.bingx.journal import _ACCOUNT_EVENT_RATE_CAP
|
||||
self.assertEqual(_ACCOUNT_EVENT_RATE_CAP, 5)
|
||||
|
||||
def test_rate_cap_env_override(self):
|
||||
with patch.dict(os.environ, {"PINK_ACCOUNT_EVENT_RATE_CAP": "10"}, clear=False):
|
||||
import prod.bingx.journal as jrn
|
||||
importlib.reload(jrn)
|
||||
self.assertEqual(jrn._ACCOUNT_EVENT_RATE_CAP, 10)
|
||||
importlib.reload(__import__("prod.bingx.journal"))
|
||||
|
||||
def test_rate_cap_clamps_non_positive(self):
|
||||
from prod.bingx.journal import resolve_account_event_rate_cap
|
||||
for bad in ("0", "-5"):
|
||||
cap = resolve_account_event_rate_cap()
|
||||
self.assertGreater(cap, 0)
|
||||
|
||||
def test_rate_cap_returns_default_when_env_missing(self):
|
||||
# The module-level cap uses int(os.environ.get("PINK_ACCOUNT_EVENT_RATE_CAP", "5"))
|
||||
# When env is missing, it just uses the default. Test the function directly.
|
||||
from prod.bingx.journal import resolve_account_event_rate_cap
|
||||
# Set env explicitly to something else, then test the function ignores it
|
||||
with patch.dict(os.environ, {"PINK_ACCOUNT_EVENT_RATE_CAP": "5"}, clear=False):
|
||||
cap = resolve_account_event_rate_cap()
|
||||
self.assertEqual(cap, 5)
|
||||
# The function resolve_account_event_rate_cap reads env dynamically
|
||||
with patch.dict(os.environ, {"PINK_ACCOUNT_EVENT_RATE_CAP": "999"}, clear=False):
|
||||
cap = resolve_account_event_rate_cap()
|
||||
self.assertEqual(cap, 999)
|
||||
|
||||
def test_rate_limiter_allows_under_cap(self):
|
||||
from prod.bingx.journal import _AccountEventRateLimiter
|
||||
limiter = _AccountEventRateLimiter(max_per_sec=100)
|
||||
allowed = sum(1 for _ in range(10) if limiter.allow())
|
||||
self.assertEqual(allowed, 10)
|
||||
|
||||
def test_rate_limiter_blocks_over_cap(self):
|
||||
from prod.bingx.journal import _AccountEventRateLimiter
|
||||
limiter = _AccountEventRateLimiter(max_per_sec=3)
|
||||
allowed = sum(1 for _ in range(10) if limiter.allow())
|
||||
self.assertLessEqual(allowed, 4)
|
||||
|
||||
|
||||
class TestPinkDataVolumeBudget(unittest.TestCase):
|
||||
"""PINK must have budget constants for data volume control."""
|
||||
|
||||
def test_ch_budget_header(self):
|
||||
from prod.ch_writer import PINK_CH_BUDGET_BYTES_DAY
|
||||
self.assertGreater(PINK_CH_BUDGET_BYTES_DAY, 0)
|
||||
self.assertLessEqual(PINK_CH_BUDGET_BYTES_DAY, 50 * 1024 * 1024)
|
||||
|
||||
def test_hz_budget_header(self):
|
||||
from prod.ch_writer import PINK_HZ_BUDGET_BYTES_DAY
|
||||
self.assertGreater(PINK_HZ_BUDGET_BYTES_DAY, 0)
|
||||
self.assertLessEqual(PINK_HZ_BUDGET_BYTES_DAY, 500 * 1024 * 1024)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# BINGX EXECUTION ISOLATION
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestBingxExecutionIsolation(unittest.TestCase):
|
||||
"""PINK execution must use VST only and never contaminate BLUE."""
|
||||
|
||||
def test_execution_default_env_is_vst(self):
|
||||
from prod.bingx.enums import PINK_DEFAULT_ENV, BingxEnvironment
|
||||
self.assertIs(PINK_DEFAULT_ENV, BingxEnvironment.VST)
|
||||
|
||||
def test_execution_config_has_journal_fields(self):
|
||||
from prod.bingx.config import BingxExecClientConfig
|
||||
config = BingxExecClientConfig(
|
||||
journal_strategy="pink",
|
||||
journal_db="dolphin_pink",
|
||||
)
|
||||
self.assertEqual(config.journal_strategy, "pink")
|
||||
self.assertEqual(config.journal_db, "dolphin_pink")
|
||||
|
||||
def test_execution_config_defaults_none(self):
|
||||
from prod.bingx.config import BingxExecClientConfig
|
||||
config = BingxExecClientConfig()
|
||||
self.assertIsNone(config.journal_strategy)
|
||||
self.assertIsNone(config.journal_db)
|
||||
|
||||
def test_execution_config_isolates_pink_journal_strategy(self):
|
||||
from prod.bingx.config import BingxExecClientConfig
|
||||
c_pink = BingxExecClientConfig(journal_strategy="pink", journal_db="dolphin_pink")
|
||||
c_blue = BingxExecClientConfig()
|
||||
self.assertEqual(c_pink.journal_strategy, "pink")
|
||||
self.assertIsNone(c_blue.journal_strategy)
|
||||
|
||||
def test_bingx_data_config_has_environment(self):
|
||||
from prod.bingx.data_config import BingxDataClientConfig
|
||||
cfg = BingxDataClientConfig(environment="VST", allow_mainnet=False)
|
||||
self.assertEqual(cfg.environment, "VST")
|
||||
|
||||
def test_bingx_data_config_live_requires_mainnet(self):
|
||||
from prod.bingx.data_config import BingxDataClientConfig
|
||||
with self.assertRaises(ValueError):
|
||||
BingxDataClientConfig(environment="LIVE", allow_mainnet=False)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# CONTROL PLANE KEYS
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestControlPlaneKeys(unittest.TestCase):
|
||||
"""PINK control-plane keys must be isolated from BLUE."""
|
||||
|
||||
def test_pink_ctl_program_name(self):
|
||||
from prod.ops.pink_ctl import PINK_PROGRAM
|
||||
self.assertEqual(PINK_PROGRAM, "dolphin_pink")
|
||||
|
||||
def test_pink_state_map(self):
|
||||
from prod.ops.pink_ctl import HZ_STATE
|
||||
self.assertEqual(HZ_STATE, "DOLPHIN_STATE_PINK")
|
||||
|
||||
def test_pink_pnl_map(self):
|
||||
from prod.ops.pink_ctl import HZ_PNL
|
||||
self.assertEqual(HZ_PNL, "DOLPHIN_PNL_PINK")
|
||||
|
||||
def test_control_plane_has_no_blue_reference(self):
|
||||
from prod.ops.pink_ctl import HZ_STATE, HZ_PNL
|
||||
self.assertNotIn("BLUE", HZ_STATE)
|
||||
self.assertNotIn("BLUE", HZ_PNL)
|
||||
self.assertNotIn("PRODGREEN", HZ_STATE)
|
||||
|
||||
def test_runtime_command_queue_is_pink_only(self):
|
||||
import launch_dolphin_pink as mod
|
||||
src = Path(mod.__file__).read_text()
|
||||
self.assertNotIn("blue_runtime_commands", src)
|
||||
self.assertIn("DOLPHIN_STATE_PINK", src)
|
||||
|
||||
def test_pink_config_no_blue_maps(self):
|
||||
import yaml
|
||||
cfg = yaml.safe_load(Path("/mnt/dolphinng5_predict/prod/configs/pink.yml").read_text())
|
||||
state_map = cfg["hazelcast"]["state_map"]
|
||||
pnl_map = cfg["hazelcast"]["imap_pnl"]
|
||||
self.assertNotIn("BLUE", state_map)
|
||||
self.assertNotIn("BLUE", pnl_map)
|
||||
self.assertNotIn("PRODGREEN", state_map)
|
||||
self.assertNotIn("PRODGREEN", pnl_map)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# SUPERVISORD CONFIG VALIDATION
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestSupervisordPinkConfig(unittest.TestCase):
|
||||
"""PINK must be registered in supervisord with correct settings."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.conf = Path("/mnt/dolphinng5_predict/prod/supervisor/dolphin-supervisord.conf").read_text()
|
||||
cls.pink_sec = cls.conf.split("[program:dolphin_pink]")[1].split("[")[0]
|
||||
|
||||
def test_supervisor_config_has_pink(self):
|
||||
self.assertIn("[program:dolphin_pink]", self.conf)
|
||||
|
||||
def test_pink_program_autostart(self):
|
||||
self.assertIn("[program:dolphin_pink]", self.conf)
|
||||
|
||||
def test_pink_uses_correct_launcher(self):
|
||||
self.assertIn("launch_dolphin_pink.py", self.pink_sec)
|
||||
|
||||
def test_pink_env_bingx_env(self):
|
||||
self.assertIn("DOLPHIN_BINGX_ENV=", self.pink_sec)
|
||||
|
||||
def test_pink_env_bingx_allow_mainnet(self):
|
||||
self.assertIn("DOLPHIN_BINGX_ALLOW_MAINNET=", self.pink_sec)
|
||||
|
||||
def test_pink_env_trader_id(self):
|
||||
self.assertIn("DOLPHIN_TRADER_ID=", self.pink_sec)
|
||||
|
||||
def test_pink_uses_python3(self):
|
||||
self.assertIn("python3", self.pink_sec)
|
||||
|
||||
def test_pink_not_in_blue_group(self):
|
||||
groups_section = self.conf.split("[group:dolphin]")[1].split("[")[0]
|
||||
self.assertNotIn("dolphin_pink", groups_section)
|
||||
|
||||
def test_pink_env_has_vol_threshold(self):
|
||||
self.assertIn("DOLPHIN_PINK_VOL_P60_THRESHOLD", self.pink_sec)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# PINK CTL TOOL
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestPinkCtlTool(unittest.TestCase):
|
||||
"""PINK ctl tool must operate on PINK namespaces only."""
|
||||
|
||||
def test_ctl_imports(self):
|
||||
import prod.ops.pink_ctl as ctl
|
||||
self.assertTrue(callable(ctl.status))
|
||||
self.assertTrue(callable(ctl.healthcheck))
|
||||
self.assertTrue(callable(ctl.mode_verify))
|
||||
|
||||
def test_ctl_status_checks_pink_ch(self):
|
||||
from prod.ops.pink_ctl import status
|
||||
with patch("prod.ops.pink_ctl._ch", return_value=[{"n": 5}]) as mock_ch:
|
||||
rc = status()
|
||||
self.assertEqual(rc, 0)
|
||||
|
||||
def test_ctl_healthcheck_checks_pink_hz(self):
|
||||
from prod.ops.pink_ctl import healthcheck, HZ_STATE
|
||||
hz_mock = MagicMock()
|
||||
hz_mock.get_map.return_value.blocking.return_value.get.return_value = '{"capital": 25000}'
|
||||
with patch("prod.ops.pink_ctl._ch", return_value=[{"n": 5}]), \
|
||||
patch("prod.ops.pink_ctl._hz_client", return_value=hz_mock):
|
||||
rc = healthcheck()
|
||||
self.assertEqual(rc, 0)
|
||||
hz_mock.get_map.assert_called_with(HZ_STATE)
|
||||
|
||||
def test_ctl_healthcheck_fails_when_ch_empty(self):
|
||||
from prod.ops.pink_ctl import healthcheck
|
||||
hz_mock = MagicMock()
|
||||
hz_mock.get_map.return_value.blocking.return_value.get.return_value = '{"capital": 1}'
|
||||
with patch("prod.ops.pink_ctl._ch", return_value=[{"n": 0}]), \
|
||||
patch("prod.ops.pink_ctl._hz_client", return_value=hz_mock):
|
||||
rc = healthcheck()
|
||||
self.assertEqual(rc, 1)
|
||||
|
||||
def test_ctl_healthcheck_fails_when_hz_missing(self):
|
||||
from prod.ops.pink_ctl import healthcheck
|
||||
with patch("prod.ops.pink_ctl._ch", return_value=[{"n": 1}]), \
|
||||
patch("prod.ops.pink_ctl._hz_client", return_value=None):
|
||||
rc = healthcheck()
|
||||
# CH is present so healthcheck passes; HZ is optional
|
||||
self.assertEqual(rc, 0)
|
||||
|
||||
def test_ctl_mode_verify_checks_contamination(self):
|
||||
from prod.ops.pink_ctl import mode_verify
|
||||
def fake_ch(sql, db="dolphin_pink"):
|
||||
if "where" in sql.lower() or "group" in sql.lower():
|
||||
return [{"n": 0, "strategy": "pink"}] if db == "dolphin_pink" else [{"n": 0}]
|
||||
return [{"n": 0}]
|
||||
with patch("prod.ops.pink_ctl._ch", side_effect=fake_ch), \
|
||||
patch.dict(os.environ, {"DOLPHIN_BINGX_ENV": "VST", "DOLPHIN_BINGX_ALLOW_MAINNET": "0"}):
|
||||
rc = mode_verify()
|
||||
self.assertEqual(rc, 0)
|
||||
|
||||
def test_ctl_mode_verify_detects_contamination(self):
|
||||
from prod.ops.pink_ctl import mode_verify
|
||||
def fake_ch(sql, db="dolphin_pink"):
|
||||
if "strategy" in sql.lower() or "group" in sql.lower():
|
||||
if db == "dolphin_pink":
|
||||
return [{"strategy": "pink", "n": 3}]
|
||||
return [{"n": 5}] # contamination found!
|
||||
return [{"n": 3}]
|
||||
with patch("prod.ops.pink_ctl._ch", side_effect=fake_ch), \
|
||||
patch.dict(os.environ, {"DOLPHIN_BINGX_ENV": "VST", "DOLPHIN_BINGX_ALLOW_MAINNET": "0"}):
|
||||
rc = mode_verify()
|
||||
self.assertEqual(rc, 1)
|
||||
|
||||
def test_ctl_status_ch_exception(self):
|
||||
from prod.ops.pink_ctl import status
|
||||
with patch("prod.ops.pink_ctl._ch", side_effect=Exception("CH down")):
|
||||
rc = status()
|
||||
self.assertEqual(rc, 0)
|
||||
|
||||
def test_ctl_status_hz_exception_handled(self):
|
||||
from prod.ops.pink_ctl import status
|
||||
# hazelcast is imported inside _hz_client, not at module level
|
||||
with patch("prod.ops.pink_ctl._ch", return_value=[{"n": 1}]), \
|
||||
patch("hazelcast.HazelcastClient", side_effect=Exception("HZ down")):
|
||||
rc = status()
|
||||
self.assertEqual(rc, 0)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# V7 DECISION ROUTING
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestV7DecisionEventRouting(unittest.TestCase):
|
||||
"""V7 decision events from PINK must route to dolphin_pink."""
|
||||
|
||||
def test_v7_journal_db_default_is_dolphin_pink(self):
|
||||
from prod.ch_writer import PINK_V7_JOURNAL_DB
|
||||
self.assertEqual(PINK_V7_JOURNAL_DB, "dolphin_pink")
|
||||
|
||||
def test_v7_decision_table_name(self):
|
||||
from prod.ch_writer import V7_DECISION_TABLE
|
||||
self.assertEqual(V7_DECISION_TABLE, "v7_decision_events")
|
||||
|
||||
def test_v7_write_targets_pink_db(self):
|
||||
from prod.ch_writer import ch_put_pink_v7
|
||||
self.assertTrue(callable(ch_put_pink_v7))
|
||||
|
||||
def test_v7_pink_writer_db(self):
|
||||
from prod.ch_writer import _writer_pink_v7
|
||||
self.assertEqual(_writer_pink_v7._db, "dolphin_pink")
|
||||
|
||||
def test_v7_blue_decision_writer_unchanged(self):
|
||||
from prod.ch_writer import _writer, _writer_pink
|
||||
self.assertEqual(_writer._db, "dolphin")
|
||||
self.assertEqual(_writer_pink._db, "dolphin_pink")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# NAMESPACE BOUNDARY / ISOLATION GUARDS
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestNamespaceIsolationGuards(unittest.TestCase):
|
||||
"""PINK must never read or write BLUE namespaces."""
|
||||
|
||||
def test_pink_launcher_no_blue_maps(self):
|
||||
import launch_dolphin_pink as mod
|
||||
src = Path(mod.__file__).read_text()
|
||||
for token in ["DOLPHIN_STATE_BLUE", "DOLPHIN_PNL_BLUE", "blue_runtime_commands"]:
|
||||
self.assertNotIn(token, src)
|
||||
|
||||
def test_pink_ctl_no_blue_refs(self):
|
||||
import prod.ops.pink_ctl as mod
|
||||
src = Path(mod.__file__).read_text()
|
||||
# PINK CTL must not reference BLUE maps or state names
|
||||
for token in ["DOLPHIN_STATE_BLUE", "DOLPHIN_PNL_BLUE", "blue_runtime",
|
||||
"dolphin_green"]:
|
||||
self.assertNotIn(token, src)
|
||||
# dolphine_prodgreen is referenced by mode_verify() for contamination checking
|
||||
# This is intentional: mode_verify queries prodgreen to verify NO pink rows exist there
|
||||
|
||||
def test_pink_tui_no_blue_refs(self):
|
||||
import Observability.dolphin_status_pink as mod
|
||||
src = Path(mod.__file__).read_text()
|
||||
for token in ["DOLPHIN_STATE_BLUE", "blue_runtime_commands"]:
|
||||
self.assertNotIn(token, src)
|
||||
|
||||
def test_sink_map_pink_not_prodgreen(self):
|
||||
from prod.bingx.journal import _STRATEGY_DB_MAP, _STRATEGY_SINK_MAP
|
||||
self.assertIn("pink", _STRATEGY_DB_MAP)
|
||||
self.assertIn("pink", _STRATEGY_SINK_MAP)
|
||||
self.assertNotEqual(_STRATEGY_DB_MAP["pink"], "dolphin_prodgreen")
|
||||
self.assertNotEqual(_STRATEGY_DB_MAP["pink"], "dolphin")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# ENV-DRIVEN NAMESPACE OVERRIDES
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestEnvDrivenNamespaceOverrides(unittest.TestCase):
|
||||
"""PINK must respect env-driven namespace overrides."""
|
||||
|
||||
def test_pink_tui_respects_env_ch_db(self):
|
||||
with patch.dict(os.environ, {"DOLPHIN_TUI_CH_DB": "dolphin_pink_test"}, clear=False):
|
||||
mod = __import__("Observability.dolphin_status_pink", fromlist=["PINK_CH_DB"])
|
||||
importlib.reload(mod)
|
||||
self.assertEqual(mod.PINK_CH_DB, "dolphin_pink_test")
|
||||
importlib.reload(__import__("Observability.dolphin_status_pink"))
|
||||
|
||||
def test_pink_tui_respects_env_state_map(self):
|
||||
with patch.dict(os.environ, {"DOLPHIN_TUI_STATE_MAP": "DOLPHIN_STATE_PINK_TEST"}, clear=False):
|
||||
mod = __import__("Observability.dolphin_status_pink", fromlist=["PINK_STATE_MAP"])
|
||||
importlib.reload(mod)
|
||||
self.assertEqual(mod.PINK_STATE_MAP, "DOLPHIN_STATE_PINK_TEST")
|
||||
importlib.reload(__import__("Observability.dolphin_status_pink"))
|
||||
|
||||
def test_pink_tui_env_defaults_remain_pink(self):
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
mod = __import__("Observability.dolphin_status_pink", fromlist=["PINK_CH_DB"])
|
||||
importlib.reload(mod)
|
||||
self.assertEqual(mod.PINK_CH_DB, "dolphin_pink")
|
||||
self.assertEqual(mod.PINK_STRATEGY, "pink")
|
||||
importlib.reload(__import__("Observability.dolphin_status_pink"))
|
||||
|
||||
def test_launcher_respects_env_vol_threshold(self):
|
||||
from launch_dolphin_pink import _apply_pink_actor_overrides
|
||||
with patch.dict(os.environ, {"DOLPHIN_PINK_VOL_P60_THRESHOLD": "0.00005000"}):
|
||||
cfg = _apply_pink_actor_overrides({"hazelcast": {}, "adaptive_exit": {}})
|
||||
self.assertAlmostEqual(cfg["vol_p60_threshold"], 0.00005000)
|
||||
|
||||
def test_launcher_vol_threshold_fallback_on_bad_env(self):
|
||||
from launch_dolphin_pink import _apply_pink_actor_overrides
|
||||
# Invalid float strings fall back to default
|
||||
for bad_val in ("abc", ""):
|
||||
with patch.dict(os.environ, {"DOLPHIN_PINK_VOL_P60_THRESHOLD": bad_val}):
|
||||
cfg = _apply_pink_actor_overrides({"hazelcast": {}, "adaptive_exit": {}})
|
||||
self.assertAlmostEqual(cfg["vol_p60_threshold"], -1000000000.0)
|
||||
# Negative values remain valid for relaxed-gate debugging mode.
|
||||
with patch.dict(os.environ, {"DOLPHIN_PINK_VOL_P60_THRESHOLD": "-1"}):
|
||||
cfg = _apply_pink_actor_overrides({"hazelcast": {}, "adaptive_exit": {}})
|
||||
self.assertAlmostEqual(cfg["vol_p60_threshold"], -1.0)
|
||||
|
||||
def test_launcher_vol_threshold_default_when_env_missing(self):
|
||||
from launch_dolphin_pink import _apply_pink_actor_overrides
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
cfg = _apply_pink_actor_overrides({"hazelcast": {}, "adaptive_exit": {}})
|
||||
self.assertAlmostEqual(cfg["vol_p60_threshold"], -1000000000.0)
|
||||
|
||||
def test_pink_tui_env_defaults_posture_disabled(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
mod = __import__("Observability.dolphin_status_pink", fromlist=["PINK_ALLOW_GLOBAL_POSTURE_HOTKEYS"])
|
||||
importlib.reload(mod)
|
||||
self.assertFalse(mod.PINK_ALLOW_GLOBAL_POSTURE_HOTKEYS)
|
||||
importlib.reload(__import__("Observability.dolphin_status_pink"))
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# VST SAFETY GATES
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestVstSafetyGates(unittest.TestCase):
|
||||
"""PINK VST safety must prevent accidental mainnet execution."""
|
||||
|
||||
def test_pink_launcher_rejects_live_without_flag(self):
|
||||
from launch_dolphin_pink import build_pink_node
|
||||
with patch.dict(os.environ, {
|
||||
"DOLPHIN_BINGX_ENV": "LIVE",
|
||||
"DOLPHIN_BINGX_ALLOW_MAINNET": "0",
|
||||
"BINANCE_API_KEY": "test",
|
||||
"BINANCE_API_SECRET": "test",
|
||||
}, clear=False):
|
||||
with self.assertRaises(RuntimeError):
|
||||
build_pink_node()
|
||||
|
||||
def test_pink_launcher_accepts_live_with_flag(self):
|
||||
from launch_dolphin_pink import build_pink_node
|
||||
with patch.dict(os.environ, {
|
||||
"DOLPHIN_BINGX_ENV": "LIVE",
|
||||
"DOLPHIN_BINGX_ALLOW_MAINNET": "1",
|
||||
"BINANCE_API_KEY": "test",
|
||||
"BINANCE_API_SECRET": "test",
|
||||
"DOLPHIN_STRATEGY_NAME": "pink",
|
||||
"DOLPHIN_STATE_MAP": "DOLPHIN_STATE_PINK",
|
||||
"DOLPHIN_PNL_MAP": "DOLPHIN_PNL_PINK",
|
||||
}, clear=False):
|
||||
with patch("launch_dolphin_pink.build_actor_config", return_value={
|
||||
"data_venue": "BINANCE", "exec_venue": "BINGX",
|
||||
"hazelcast": {}, "assets": [],
|
||||
}), \
|
||||
patch("launch_dolphin_pink.BinanceDataClientConfig"), \
|
||||
patch("launch_dolphin_pink.build_bingx_exec_client_config"), \
|
||||
patch("launch_dolphin_pink.TradingNode"):
|
||||
try:
|
||||
build_pink_node()
|
||||
except RuntimeError:
|
||||
self.fail("build_pink_node() raised RuntimeError unexpectedly")
|
||||
|
||||
def test_pink_env_forces_vst(self):
|
||||
from launch_dolphin_pink import _apply_pink_namespace_env
|
||||
with patch.dict(os.environ, {"DOLPHIN_BINGX_ENV": "LIVE", "DOLPHIN_BINGX_ALLOW_MAINNET": "1"}):
|
||||
_apply_pink_namespace_env()
|
||||
self.assertEqual(os.environ["DOLPHIN_BINGX_ENV"], "VST")
|
||||
self.assertEqual(os.environ["DOLPHIN_BINGX_ALLOW_MAINNET"], "0")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# E2E SIMULATED SCENARIOS
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class FakeHzBlocking:
|
||||
def __init__(self, store):
|
||||
self._store = store
|
||||
def get(self, k):
|
||||
return self._store.get(k)
|
||||
def put(self, k, v):
|
||||
self._store[k] = v
|
||||
def key_set(self):
|
||||
return list(self._store.keys())
|
||||
|
||||
class FakeHzMapRef:
|
||||
def __init__(self, store):
|
||||
self._store = store
|
||||
def blocking(self):
|
||||
return FakeHzBlocking(self._store)
|
||||
|
||||
class FakeHzClient:
|
||||
def __init__(self):
|
||||
self.maps = {}
|
||||
def get_map(self, name):
|
||||
if name not in self.maps:
|
||||
self.maps[name] = {}
|
||||
return FakeHzMapRef(self.maps[name])
|
||||
def shutdown(self):
|
||||
pass
|
||||
|
||||
|
||||
class TestE2ESimulatedPinkLifecycle(unittest.TestCase):
|
||||
"""End-to-end simulated PINK lifecycle with fake HZ + CH."""
|
||||
|
||||
def setUp(self):
|
||||
self.hz = FakeHzClient()
|
||||
self._seed_health_data()
|
||||
|
||||
def _seed_health_data(self):
|
||||
import json
|
||||
b = lambda d: json.dumps(d)
|
||||
sp = self.hz.get_map("DOLPHIN_STATE_PINK").blocking()
|
||||
sp.put("engine_snapshot", b({
|
||||
"capital": 25000.0, "trades_executed": 3, "scans_processed": 500,
|
||||
"last_scan_number": 500, "bar_idx": 500, "current_leverage": 0.0,
|
||||
"open_notional": 0.0, "open_positions": [], "posture": "APEX",
|
||||
"vol_ok": True, "last_vel_div": -0.03, "vol_gate_threshold": 0.00008,
|
||||
}))
|
||||
sp.put("capital_checkpoint", b({"capital": 25000.0}))
|
||||
self.hz.get_map("DOLPHIN_SAFETY").blocking().put("latest", b({
|
||||
"posture": "APEX", "Rm": 0.95, "breakdown": {"Cat1": 1.0, "Cat2": 1.0, "Cat3": 1.0, "Cat4": 1.0, "Cat5": 0.97}}))
|
||||
self.hz.get_map("DOLPHIN_HEARTBEAT").blocking().put("nautilus_flow_heartbeat", b({
|
||||
"ts": _time.time(), "phase": "trading"}))
|
||||
self.hz.get_map("DOLPHIN_META_HEALTH").blocking().put("latest", b({
|
||||
"status": "GREEN", "rm_meta": 0.95, "service_status": {}, "hz_key_status": {},
|
||||
"m1_data_infra": 1.0, "m1_trader": 1.0, "m2_heartbeat": 1.0,
|
||||
"m3_data_freshness": 1.0, "m4_control_plane": 1.0, "m5_coherence": 1.0}))
|
||||
self.hz.get_map("DOLPHIN_ANNOUNCEMENTS").blocking().put("latest", b({}))
|
||||
fm = self.hz.get_map("DOLPHIN_FEATURES").blocking()
|
||||
fm.put("acb_boost", b({"boost": 1.0, "ready": True}))
|
||||
fm.put("exf_latest", b({}))
|
||||
fm.put("obf_universe_latest", b({}))
|
||||
fm.put("esof_advisor_latest", b({}))
|
||||
fm.put("maras_latest", b({}))
|
||||
|
||||
def test_e2e_pink_status_renders_pink_namespace(self):
|
||||
calls = []
|
||||
def fake_get(hz, map_name, key):
|
||||
calls.append((map_name, key))
|
||||
m = self.hz.get_map(map_name)
|
||||
raw = m.blocking().get(key)
|
||||
import json
|
||||
return json.loads(raw) if isinstance(raw, str) else raw
|
||||
import Observability.dolphin_status_pink as status
|
||||
# Ensure env defaults are set
|
||||
with patch.object(status, "PINK_STATE_MAP", "DOLPHIN_STATE_PINK"), \
|
||||
patch.object(status, "PINK_CH_DB", "dolphin_pink"), \
|
||||
patch.object(status, "PINK_STRATEGY", "pink"), \
|
||||
patch.object(status, "_get", side_effect=fake_get), \
|
||||
patch.object(status, "_last_n_trades", return_value=[]):
|
||||
text = status.render("hz")
|
||||
self.assertIn("DOLPHIN-PINK", text)
|
||||
self.assertIn("APEX", text)
|
||||
self.assertIn(("DOLPHIN_STATE_PINK", "engine_snapshot"), calls)
|
||||
|
||||
def test_e2e_pink_status_no_blue_maps_accessed(self):
|
||||
accessed_maps = set()
|
||||
def fake_get(hz, map_name, key):
|
||||
accessed_maps.add(map_name)
|
||||
m = self.hz.get_map(map_name)
|
||||
raw = m.blocking().get(key)
|
||||
import json
|
||||
return json.loads(raw) if isinstance(raw, str) else raw
|
||||
import Observability.dolphin_status_pink as status
|
||||
with patch.object(status, "_get", side_effect=fake_get), \
|
||||
patch.object(status, "_last_n_trades", return_value=[]):
|
||||
status.render("hz")
|
||||
for m in accessed_maps:
|
||||
self.assertNotIn("BLUE", str(m))
|
||||
|
||||
def test_e2e_ctl_status_reports_pink_only(self):
|
||||
import prod.ops.pink_ctl as ctl
|
||||
calls = []
|
||||
def fake_ch(sql, db="dolphin_pink"):
|
||||
calls.append(db)
|
||||
self.assertEqual(db, "dolphin_pink")
|
||||
return [{"n": 10}]
|
||||
with patch("prod.ops.pink_ctl._ch", side_effect=fake_ch), \
|
||||
patch("prod.ops.pink_ctl._hz_client", return_value=self.hz):
|
||||
rc = ctl.status()
|
||||
self.assertEqual(rc, 0)
|
||||
|
||||
def test_e2e_ctl_mode_verify_no_contamination(self):
|
||||
import prod.ops.pink_ctl as ctl
|
||||
def fake_ch(sql, db="dolphin_pink"):
|
||||
if "count" in sql.lower() or "strategy" in sql.lower():
|
||||
return [{"n": 0}] if "prodgreen" in db or db == "dolphin" else [{"n": 6, "strategy": "pink"}]
|
||||
return [{"n": 0}]
|
||||
with patch("prod.ops.pink_ctl._ch", side_effect=fake_ch), \
|
||||
patch.dict(os.environ, {"DOLPHIN_BINGX_ENV": "VST", "DOLPHIN_BINGX_ALLOW_MAINNET": "0"}):
|
||||
rc = ctl.mode_verify()
|
||||
self.assertEqual(rc, 0)
|
||||
|
||||
def test_e2e_ctl_healthcheck_all_green(self):
|
||||
import prod.ops.pink_ctl as ctl
|
||||
with patch("prod.ops.pink_ctl._ch", return_value=[{"n": 5}]), \
|
||||
patch("prod.ops.pink_ctl._hz_client", return_value=self.hz):
|
||||
rc = ctl.healthcheck()
|
||||
self.assertEqual(rc, 0)
|
||||
|
||||
def test_e2e_pink_actor_overrides_empty_hazelcast(self):
|
||||
from launch_dolphin_pink import _apply_pink_actor_overrides
|
||||
cfg = _apply_pink_actor_overrides({})
|
||||
self.assertEqual(cfg.get("strategy_name"), "pink")
|
||||
self.assertEqual(cfg.get("hazelcast", {}).get("state_map"), "DOLPHIN_STATE_PINK")
|
||||
|
||||
def test_e2e_both_status_and_ctl_agree_on_pink_maps(self):
|
||||
import prod.ops.pink_ctl as ctl
|
||||
self.assertEqual(ctl.HZ_STATE, "DOLPHIN_STATE_PINK")
|
||||
self.assertEqual(ctl.HZ_PNL, "DOLPHIN_PNL_PINK")
|
||||
|
||||
def test_e2e_pink_journal_writes_to_pink_sink(self):
|
||||
from prod.bingx.journal import write_snapshot, BingxJournalSnapshot, _STRATEGY_SINK_MAP
|
||||
captured = {"called": False}
|
||||
def fake_sink(table, row):
|
||||
captured["called"] = True
|
||||
captured["table"] = table
|
||||
captured["strategy"] = row.get("strategy")
|
||||
with patch.dict(_STRATEGY_SINK_MAP, {"pink": fake_sink}, clear=False):
|
||||
snap = BingxJournalSnapshot(
|
||||
ts=2000000, strategy="pink", account_id="BINGX-vst",
|
||||
ledger_authority="exchange",
|
||||
payload={"account": {"balances": [{"asset": "USDT", "total": 26000.0, "free": 25500.0}]}, "positions": {}},
|
||||
fingerprint="pink-fp-001",
|
||||
)
|
||||
write_snapshot(snap)
|
||||
self.assertTrue(captured.get("called"))
|
||||
self.assertEqual(captured.get("strategy"), "pink")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# PINK CONFIG FILE PARITY
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestPinkConfigParity(unittest.TestCase):
|
||||
"""PINK config must have same algorithm structure as BLUE with isolated namespaces."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
import yaml
|
||||
cls.pink = yaml.safe_load(Path("/mnt/dolphinng5_predict/prod/configs/pink.yml").read_text())
|
||||
cls.blue = yaml.safe_load(Path("/mnt/dolphinng5_predict/prod/configs/blue.yml").read_text())
|
||||
|
||||
def test_pink_has_engine_section(self):
|
||||
self.assertIn("engine", self.pink)
|
||||
|
||||
def test_pink_has_paper_trade_section(self):
|
||||
self.assertIn("paper_trade", self.pink)
|
||||
|
||||
def test_pink_has_hazelcast_section(self):
|
||||
self.assertIn("hazelcast", self.pink)
|
||||
|
||||
def test_pink_direction_matches_blue(self):
|
||||
self.assertEqual(self.pink["direction"], self.blue["direction"])
|
||||
|
||||
def test_pink_boost_mode_matches_blue(self):
|
||||
self.assertEqual(self.pink["engine"]["boost_mode"], self.blue["engine"]["boost_mode"])
|
||||
|
||||
def test_pink_vel_div_threshold_matches_blue(self):
|
||||
self.assertEqual(self.pink["engine"]["vel_div_threshold"], self.blue["engine"]["vel_div_threshold"])
|
||||
|
||||
def test_pink_fraction_matches_blue(self):
|
||||
self.assertEqual(self.pink["engine"]["fraction"], self.blue["engine"]["fraction"])
|
||||
|
||||
def test_pink_vel_div_extreme_matches_blue(self):
|
||||
self.assertEqual(self.pink["engine"]["vel_div_extreme"], self.blue["engine"]["vel_div_extreme"])
|
||||
|
||||
def test_pink_use_direction_confirm_matches_blue(self):
|
||||
self.assertEqual(self.pink["engine"]["use_direction_confirm"], self.blue["engine"]["use_direction_confirm"])
|
||||
|
||||
def test_pink_use_asset_selection_matches_blue(self):
|
||||
self.assertEqual(self.pink["engine"]["use_asset_selection"], self.blue["engine"]["use_asset_selection"])
|
||||
|
||||
def test_pink_use_sp_fees_matches_blue(self):
|
||||
self.assertEqual(self.pink["engine"]["use_sp_fees"], self.blue["engine"]["use_sp_fees"])
|
||||
|
||||
def test_pink_use_exit_v7_matches_blue(self):
|
||||
self.assertEqual(self.pink["engine"]["use_exit_v7"], self.blue["engine"]["use_exit_v7"])
|
||||
|
||||
def test_pink_hazelcast_maps_isolated(self):
|
||||
self.assertEqual(self.pink["hazelcast"]["state_map"], "DOLPHIN_STATE_PINK")
|
||||
self.assertEqual(self.pink["hazelcast"]["imap_pnl"], "DOLPHIN_PNL_PINK")
|
||||
self.assertNotEqual(self.pink["hazelcast"]["state_map"], self.blue["hazelcast"]["imap_state"])
|
||||
|
||||
def test_pink_adaptive_exit_points_to_dolphin_pink(self):
|
||||
self.assertEqual(self.pink["adaptive_exit"]["shadow_db"], "dolphin_pink")
|
||||
|
||||
def test_pink_initial_capital_matches_blue(self):
|
||||
self.assertEqual(self.pink["paper_trade"]["initial_capital"], self.blue["paper_trade"]["initial_capital"])
|
||||
|
||||
def test_pink_has_distinct_log_dir(self):
|
||||
self.assertEqual(self.pink["paper_trade"]["log_dir"], "paper_logs/pink")
|
||||
|
||||
def test_pink_isolated_tp_differs_intentionally(self):
|
||||
# PINK uses 0.20% TP (not 0.95%) — intentional for testnet
|
||||
self.assertEqual(self.pink["engine"]["fixed_tp_pct"], 0.0020)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# CH SCHEMA FILE VALIDATION
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestPinkSchemaFileContent(unittest.TestCase):
|
||||
"""PINK CH schema files must target dolphin_pink exclusively."""
|
||||
|
||||
def test_schema_dir_exists(self):
|
||||
self.assertTrue(Path("/mnt/dolphinng5_predict/prod/clickhouse/pink").is_dir())
|
||||
|
||||
def test_create_database_has_if_not_exists(self):
|
||||
ddl = Path("/mnt/dolphinng5_predict/prod/clickhouse/pink/00_create_database.sql").read_text()
|
||||
self.assertIn("IF NOT EXISTS", ddl)
|
||||
self.assertIn("dolphin_pink", ddl)
|
||||
|
||||
def test_all_sql_files_reference_dolphin_pink(self):
|
||||
schema_dir = Path("/mnt/dolphinng5_predict/prod/clickhouse/pink")
|
||||
for sql_file in sorted(schema_dir.glob("*.sql")):
|
||||
content = sql_file.read_text()
|
||||
self.assertIn("dolphin_pink", content)
|
||||
self.assertNotIn("dolphin_prodgreen", content)
|
||||
self.assertNotIn("dolphin_green", content)
|
||||
self.assertNotIn("dolphin.", content)
|
||||
|
||||
def test_schema_files_have_no_blind_copy_errors(self):
|
||||
schema_dir = Path("/mnt/dolphinng5_predict/prod/clickhouse/pink")
|
||||
for sql_file in sorted(schema_dir.glob("*.sql")):
|
||||
content = sql_file.read_text()
|
||||
self.assertNotIn("_BLUE", content)
|
||||
self.assertNotIn("_PRODGREEN", content)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# PINK JOURNAL / ACCOUNTING
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestPinkJournalAccounting(unittest.TestCase):
|
||||
"""PINK journal must route accounting data to dolphin_pink."""
|
||||
|
||||
def test_strategy_db_map_has_pink(self):
|
||||
from prod.bingx.journal import _STRATEGY_DB_MAP
|
||||
self.assertEqual(_STRATEGY_DB_MAP["pink"], "dolphin_pink")
|
||||
|
||||
def test_strategy_sink_map_has_pink(self):
|
||||
from prod.bingx.journal import _STRATEGY_SINK_MAP
|
||||
self.assertIn("pink", _STRATEGY_SINK_MAP)
|
||||
|
||||
def test_strategy_db_map_completeness(self):
|
||||
from prod.bingx.journal import _STRATEGY_DB_MAP
|
||||
for strategy in ("blue", "green", "prodgreen", "pink"):
|
||||
self.assertIn(strategy, _STRATEGY_DB_MAP)
|
||||
|
||||
def test_strategy_sink_map_completeness(self):
|
||||
from prod.bingx.journal import _STRATEGY_SINK_MAP
|
||||
for strategy in ("blue", "green", "prodgreen", "pink"):
|
||||
self.assertIn(strategy, _STRATEGY_SINK_MAP)
|
||||
|
||||
def test_pink_sink_is_ch_put_pink(self):
|
||||
from prod.bingx.journal import _STRATEGY_SINK_MAP
|
||||
import prod.ch_writer as ch
|
||||
self.assertIs(_STRATEGY_SINK_MAP["pink"], ch.ch_put_pink)
|
||||
|
||||
def test_db_for_strategy_pink(self):
|
||||
from prod.bingx.journal import _db_for_strategy
|
||||
self.assertEqual(_db_for_strategy("pink"), "dolphin_pink")
|
||||
|
||||
def test_db_for_strategy_case_insensitive(self):
|
||||
from prod.bingx.journal import _db_for_strategy
|
||||
self.assertEqual(_db_for_strategy("PINK"), "dolphin_pink")
|
||||
self.assertEqual(_db_for_strategy("Pink"), "dolphin_pink")
|
||||
|
||||
def test_db_for_strategy_blue_unchanged(self):
|
||||
from prod.bingx.journal import _db_for_strategy
|
||||
self.assertEqual(_db_for_strategy("blue"), "dolphin")
|
||||
|
||||
def test_db_for_strategy_prodgreen_unchanged(self):
|
||||
from prod.bingx.journal import _db_for_strategy
|
||||
self.assertEqual(_db_for_strategy("prodgreen"), "dolphin_prodgreen")
|
||||
|
||||
def test_db_for_strategy_prodprefix_fallback(self):
|
||||
from prod.bingx.journal import _db_for_strategy
|
||||
self.assertEqual(_db_for_strategy("prodfoo"), "dolphin_prodgreen")
|
||||
|
||||
def test_journal_snapshot_strategy_field(self):
|
||||
from prod.bingx.journal import BingxJournalSnapshot
|
||||
snap = BingxJournalSnapshot(
|
||||
ts=100, strategy="pink", account_id="test", ledger_authority="exchange",
|
||||
payload={"account": {"balances": []}, "positions": {}}, fingerprint="fp")
|
||||
self.assertEqual(snap.strategy, "pink")
|
||||
|
||||
def test_ch_put_pink_exists(self):
|
||||
from prod.ch_writer import ch_put_pink
|
||||
self.assertTrue(callable(ch_put_pink))
|
||||
|
||||
def test_ch_put_pink_calls_pink_writer(self):
|
||||
from prod.ch_writer import ch_put_pink, _writer_pink
|
||||
with patch.object(_writer_pink, 'put') as mock_put:
|
||||
ch_put_pink("test_table", {"key": "value"})
|
||||
mock_put.assert_called_once_with("test_table", {"key": "value"})
|
||||
|
||||
def test_writer_pink_db_is_dolphin_pink(self):
|
||||
from prod.ch_writer import _writer_pink
|
||||
self.assertEqual(_writer_pink._db, "dolphin_pink")
|
||||
|
||||
def test_writer_prodgreen_unchanged(self):
|
||||
from prod.ch_writer import _writer_prodgreen
|
||||
self.assertEqual(_writer_prodgreen._db, "dolphin_prodgreen")
|
||||
|
||||
def test_writer_blue_unchanged(self):
|
||||
from prod.ch_writer import _writer
|
||||
self.assertEqual(_writer._db, "dolphin")
|
||||
|
||||
def test_writer_green_unchanged(self):
|
||||
from prod.ch_writer import _writer_green
|
||||
self.assertEqual(_writer_green._db, "dolphin_green")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# PINK CH SCHEMA REQUIRED FILES
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestPinkClickHouseSchema(unittest.TestCase):
|
||||
"""PINK CH schema files must exist and be complete."""
|
||||
|
||||
def test_schema_dir_exists(self):
|
||||
self.assertTrue(Path("/mnt/dolphinng5_predict/prod/clickhouse/pink").is_dir())
|
||||
|
||||
def test_required_schema_files(self):
|
||||
schema_dir = Path("/mnt/dolphinng5_predict/prod/clickhouse/pink")
|
||||
required = [
|
||||
"00_create_database.sql", "account_events.sql", "trade_events.sql",
|
||||
"status_snapshots.sql", "v7_decision_events.sql", "adaptive_exit_shadow.sql",
|
||||
"02_create_trade_reconstruction.sql", "03_create_trade_exit_legs.sql",
|
||||
]
|
||||
for filename in required:
|
||||
self.assertTrue((schema_dir / filename).exists(), f"Missing: {filename}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
53
prod/tests/test_pink_hazelcast_feed.py
Normal file
53
prod/tests/test_pink_hazelcast_feed.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from prod.clean_arch.adapters.hazelcast_feed import HazelcastDataFeed
|
||||
|
||||
|
||||
class _FakeMap:
|
||||
def __init__(self, payload: str) -> None:
|
||||
self.payload = payload
|
||||
|
||||
def get(self, key: str):
|
||||
if key == "latest_eigen_scan":
|
||||
return self.payload
|
||||
return None
|
||||
|
||||
def size(self) -> int:
|
||||
return 1
|
||||
|
||||
|
||||
def test_single_result_scan_schema_is_accepted() -> None:
|
||||
payload = json.dumps(
|
||||
{
|
||||
"scan_number": 2576,
|
||||
"timestamp": 1779805956.9522693,
|
||||
"target_asset": "BTCUSDT",
|
||||
"result": {
|
||||
"asset": "BTCUSDT",
|
||||
"price": 77599.64,
|
||||
"eigenvalue_tracking": {"lambda_max": 24.6, "lambda_max_velocity": -0.0053},
|
||||
"multi_window_results": {
|
||||
"50": {"tracking_data": {"lambda_max_velocity": -0.19346329413310556}},
|
||||
"750": {"tracking_data": {"lambda_max_velocity": -0.0001833266579540457}},
|
||||
},
|
||||
"confidence": 0.79,
|
||||
},
|
||||
}
|
||||
)
|
||||
feed = HazelcastDataFeed({"hazelcast": {"cluster": "dolphin", "host": "localhost:5701"}})
|
||||
feed.features_map = _FakeMap(payload)
|
||||
|
||||
snapshot = asyncio.run(feed.get_latest_snapshot("BTCUSDT"))
|
||||
|
||||
assert snapshot is not None
|
||||
assert snapshot.symbol == "BTCUSDT"
|
||||
assert snapshot.price == 77599.64
|
||||
assert snapshot.velocity_divergence == pytest.approx(-0.19327996747515153)
|
||||
assert snapshot.irp_alignment == 0.79
|
||||
assert snapshot.scan_number == 2576
|
||||
499
prod/tests/test_pink_routing.py
Normal file
499
prod/tests/test_pink_routing.py
Normal file
@@ -0,0 +1,499 @@
|
||||
"""
|
||||
Unit tests for PINK namespace routing and isolation.
|
||||
|
||||
Validates:
|
||||
- ch_writer ch_put_pink targets dolphin_pink
|
||||
- journal _db_for_strategy routes pink -> dolphin_pink
|
||||
- journal write_snapshot selects pink sink
|
||||
- dolphin_actor ch_put mapping for pink
|
||||
- No cross-contamination between BLUE/PRODGREEN/PINK
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch, call
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "nautilus_dolphin"))
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
|
||||
class TestChWriterPink(unittest.TestCase):
|
||||
"""Test ch_writer ch_put_pink targets dolphin_pink database."""
|
||||
|
||||
@patch("prod.ch_writer._CHWriter")
|
||||
def test_ch_put_pink_targets_dolphin_pink(self, MockWriter):
|
||||
mock_instance = MagicMock()
|
||||
MockWriter.return_value = mock_instance
|
||||
MockWriter.reset_mock()
|
||||
|
||||
# Re-import to pick up the mock
|
||||
import importlib
|
||||
import prod.ch_writer as ch_mod
|
||||
importlib.reload(ch_mod)
|
||||
|
||||
# After reload, the module-level singletons are recreated
|
||||
# We need to verify ch_put_pink calls the right writer
|
||||
# The simplest approach: verify the _writer_pink singleton has db="dolphin_pink"
|
||||
|
||||
def test_writer_pink_db_attribute(self):
|
||||
"""Verify _writer_pink targets dolphin_pink database."""
|
||||
from prod.ch_writer import _writer_pink
|
||||
self.assertEqual(_writer_pink._db, "dolphin_pink")
|
||||
|
||||
def test_writer_prodgreen_unchanged(self):
|
||||
"""Verify PRODGREEN writer is unchanged."""
|
||||
from prod.ch_writer import _writer_prodgreen
|
||||
self.assertEqual(_writer_prodgreen._db, "dolphin_prodgreen")
|
||||
|
||||
def test_writer_blue_unchanged(self):
|
||||
"""Verify BLUE writer is unchanged."""
|
||||
from prod.ch_writer import _writer
|
||||
self.assertEqual(_writer._db, "dolphin")
|
||||
|
||||
def test_writer_green_unchanged(self):
|
||||
"""Verify GREEN writer is unchanged."""
|
||||
from prod.ch_writer import _writer_green
|
||||
self.assertEqual(_writer_green._db, "dolphin_green")
|
||||
|
||||
def test_ch_put_pink_exists(self):
|
||||
"""Verify ch_put_pink function exists and is callable."""
|
||||
from prod.ch_writer import ch_put_pink
|
||||
self.assertTrue(callable(ch_put_pink))
|
||||
|
||||
def test_ch_put_pink_calls_put(self):
|
||||
"""Verify ch_put_pink delegates to _writer_pink.put."""
|
||||
from prod.ch_writer import _writer_pink
|
||||
with patch.object(_writer_pink, 'put') as mock_put:
|
||||
from prod.ch_writer import ch_put_pink
|
||||
ch_put_pink("test_table", {"key": "value"})
|
||||
mock_put.assert_called_once_with("test_table", {"key": "value"})
|
||||
|
||||
|
||||
class TestJournalRouting(unittest.TestCase):
|
||||
"""Test bingx/journal.py strategy->DB routing."""
|
||||
|
||||
def test_db_for_strategy_pink(self):
|
||||
from prod.bingx.journal import _db_for_strategy
|
||||
self.assertEqual(_db_for_strategy("pink"), "dolphin_pink")
|
||||
|
||||
def test_db_for_strategy_pink_case_insensitive(self):
|
||||
from prod.bingx.journal import _db_for_strategy
|
||||
self.assertEqual(_db_for_strategy("PINK"), "dolphin_pink")
|
||||
self.assertEqual(_db_for_strategy("Pink"), "dolphin_pink")
|
||||
|
||||
def test_db_for_strategy_prodgreen_unchanged(self):
|
||||
from prod.bingx.journal import _db_for_strategy
|
||||
self.assertEqual(_db_for_strategy("prodgreen"), "dolphin_prodgreen")
|
||||
|
||||
def test_db_for_strategy_green_unchanged(self):
|
||||
from prod.bingx.journal import _db_for_strategy
|
||||
self.assertEqual(_db_for_strategy("green"), "dolphin_green")
|
||||
|
||||
def test_db_for_strategy_blue_unchanged(self):
|
||||
from prod.bingx.journal import _db_for_strategy
|
||||
self.assertEqual(_db_for_strategy("blue"), "dolphin")
|
||||
|
||||
def test_db_for_strategy_prodprefix_unchanged(self):
|
||||
"""Existing prod* prefix fallback must still work for unknown prod names."""
|
||||
from prod.bingx.journal import _db_for_strategy
|
||||
self.assertEqual(_db_for_strategy("prodfoo"), "dolphin_prodgreen")
|
||||
|
||||
def test_db_for_strategy_unknown_default(self):
|
||||
from prod.bingx.journal import _db_for_strategy
|
||||
self.assertEqual(_db_for_strategy("unknown"), "dolphin")
|
||||
|
||||
def test_strategy_db_map_has_pink(self):
|
||||
from prod.bingx.journal import _STRATEGY_DB_MAP
|
||||
self.assertEqual(_STRATEGY_DB_MAP["pink"], "dolphin_pink")
|
||||
|
||||
def test_strategy_sink_map_has_pink(self):
|
||||
from prod.bingx.journal import _STRATEGY_SINK_MAP
|
||||
sink = _STRATEGY_SINK_MAP["pink"]
|
||||
self.assertTrue(callable(sink))
|
||||
self.assertEqual(getattr(sink, "__name__", ""), "ch_put_pink")
|
||||
|
||||
|
||||
class TestJournalSinkSelection(unittest.TestCase):
|
||||
"""Test that write_snapshot selects the correct sink for pink strategy."""
|
||||
|
||||
@patch("prod.bingx.journal._STRATEGY_SINK_MAP")
|
||||
def test_write_snapshot_uses_pink_sink(self, mock_map):
|
||||
from prod.bingx.journal import write_snapshot, BingxJournalSnapshot
|
||||
|
||||
mock_sink = MagicMock()
|
||||
mock_map.get.return_value = mock_sink
|
||||
|
||||
snapshot = BingxJournalSnapshot(
|
||||
ts=1000000,
|
||||
strategy="pink",
|
||||
account_id="BINGX-vst",
|
||||
ledger_authority="exchange",
|
||||
payload={
|
||||
"account": {"balances": [{"asset": "USDT", "total": 25000.0, "free": 25000.0}]},
|
||||
"positions": {},
|
||||
},
|
||||
fingerprint="abc123",
|
||||
)
|
||||
write_snapshot(snapshot)
|
||||
|
||||
# Verify the sink map was consulted for "pink"
|
||||
mock_map.get.assert_called_with("pink")
|
||||
# Verify the pink sink was called (not prodgreen or green)
|
||||
mock_sink.assert_called_once()
|
||||
|
||||
|
||||
class TestExecutionConfigFields(unittest.TestCase):
|
||||
"""Test that execution.py reads config-driven journal_strategy/journal_db."""
|
||||
|
||||
def test_config_has_journal_fields(self):
|
||||
from prod.bingx.config import BingxExecClientConfig
|
||||
config = BingxExecClientConfig(
|
||||
journal_strategy="pink",
|
||||
journal_db="dolphin_pink",
|
||||
)
|
||||
self.assertEqual(config.journal_strategy, "pink")
|
||||
self.assertEqual(config.journal_db, "dolphin_pink")
|
||||
|
||||
def test_config_defaults_none(self):
|
||||
from prod.bingx.config import BingxExecClientConfig
|
||||
config = BingxExecClientConfig()
|
||||
self.assertIsNone(config.journal_strategy)
|
||||
self.assertIsNone(config.journal_db)
|
||||
|
||||
|
||||
class TestBuildActorConfigOverrides(unittest.TestCase):
|
||||
"""Test launch_dolphin_live actor DB override behavior."""
|
||||
|
||||
def test_v7_journal_db_does_not_overwrite_adaptive_exit_shadow_db(self):
|
||||
from prod.launch_dolphin_live import build_actor_config
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"DOLPHIN_ADAPTIVE_EXIT_DB": "dolphin_pink",
|
||||
"DOLPHIN_V7_JOURNAL_DB": "dolphin_pink_v7",
|
||||
"DOLPHIN_FIXED_TP_PCT": "0.0020",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
cfg = build_actor_config()
|
||||
self.assertEqual(cfg["adaptive_exit"]["shadow_db"], "dolphin_pink")
|
||||
self.assertEqual(cfg["v7_journal_db"], "dolphin_pink_v7")
|
||||
self.assertEqual(cfg["engine"]["fixed_tp_pct"], 0.0020)
|
||||
|
||||
|
||||
class TestPinkLauncherPhases(unittest.TestCase):
|
||||
"""Test the standalone PINK phase gate helpers."""
|
||||
|
||||
def test_single_leg_is_default_phase(self):
|
||||
from prod.launch_dolphin_pink import PinkPhase, _resolve_pink_phase, _resolve_pink_exit_leg_ratios
|
||||
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
self.assertEqual(_resolve_pink_phase(), PinkPhase.SINGLE_LEG)
|
||||
self.assertEqual(_resolve_pink_exit_leg_ratios(PinkPhase.SINGLE_LEG), (1.0,))
|
||||
|
||||
def test_multi_exit_uses_configured_leg_ratios(self):
|
||||
from prod.launch_dolphin_pink import PinkPhase, _resolve_pink_exit_leg_ratios, _resolve_pink_phase
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"DOLPHIN_PINK_PHASE": "multi_exit",
|
||||
"DOLPHIN_PINK_EXIT_LEG_RATIOS": "0.25,0.75,1.0",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
self.assertEqual(_resolve_pink_phase(), PinkPhase.MULTI_EXIT)
|
||||
self.assertEqual(_resolve_pink_exit_leg_ratios(PinkPhase.MULTI_EXIT), (0.25, 0.75, 1.0))
|
||||
|
||||
|
||||
class TestCapitalSourcePriority(unittest.TestCase):
|
||||
"""BingX/PINK must prefer the BingX journal over portfolio fallbacks."""
|
||||
|
||||
def test_bingx_journal_wins_over_portfolio_and_engine(self):
|
||||
from nautilus_dolphin.nautilus.dolphin_actor import DolphinActor
|
||||
|
||||
class Dummy:
|
||||
def __init__(self):
|
||||
self.live_mode = True
|
||||
self.dolphin_config = {"native_mode": False}
|
||||
self._last_portfolio_capital = 777.0
|
||||
self.engine = type("E", (), {"capital": 555.0})()
|
||||
|
||||
def _exec_venue_name(self):
|
||||
return "BINGX"
|
||||
|
||||
def _get_bingx_ledger_capital(self):
|
||||
return 1234.5
|
||||
|
||||
def _get_portfolio_capital(self):
|
||||
return 888.0
|
||||
|
||||
dummy = Dummy()
|
||||
capital = DolphinActor._authoritative_capital(dummy)
|
||||
self.assertEqual(capital, 1234.5)
|
||||
|
||||
|
||||
class TestDolphinActorPinkMapping(unittest.TestCase):
|
||||
"""Test DolphinActor correctly maps pink strategy to pink sink."""
|
||||
|
||||
def test_actor_pink_strategy_uses_pink_sink(self):
|
||||
"""Verify pink strategy in actor config selects ch_put_pink."""
|
||||
# We can't fully instantiate DolphinActor (needs nautilus),
|
||||
# but we can test the mapping logic directly.
|
||||
from ch_writer import ch_put_pink, ch_put_prodgreen, ch_put_green
|
||||
|
||||
_STRATEGY_CH_SINK = {
|
||||
'blue': None,
|
||||
'green': ch_put_green,
|
||||
'prodgreen': ch_put_prodgreen,
|
||||
'pink': ch_put_pink,
|
||||
}
|
||||
|
||||
self.assertIs(_STRATEGY_CH_SINK['pink'], ch_put_pink)
|
||||
self.assertIs(_STRATEGY_CH_SINK['prodgreen'], ch_put_prodgreen)
|
||||
self.assertIs(_STRATEGY_CH_SINK['green'], ch_put_green)
|
||||
|
||||
|
||||
class TestPinkConfigFile(unittest.TestCase):
|
||||
"""Test that pink.yml has correct namespace settings."""
|
||||
|
||||
def test_pink_config_exists(self):
|
||||
config_path = Path("/mnt/dolphinng5_predict/prod/configs/pink.yml")
|
||||
self.assertTrue(config_path.exists(), "pink.yml must exist")
|
||||
|
||||
def test_pink_config_has_correct_strategy(self):
|
||||
import yaml
|
||||
config_path = Path("/mnt/dolphinng5_predict/prod/configs/pink.yml")
|
||||
with open(config_path) as f:
|
||||
cfg = yaml.safe_load(f)
|
||||
self.assertEqual(cfg["strategy_name"], "pink")
|
||||
self.assertEqual(cfg["hazelcast"]["state_map"], "DOLPHIN_STATE_PINK")
|
||||
self.assertEqual(cfg["hazelcast"]["imap_pnl"], "DOLPHIN_PNL_PINK")
|
||||
self.assertEqual(cfg["adaptive_exit"]["shadow_db"], "dolphin_pink")
|
||||
self.assertEqual(cfg["engine"]["fixed_tp_pct"], 0.0020)
|
||||
|
||||
|
||||
class TestPinkLauncher(unittest.TestCase):
|
||||
"""Test PINK launcher defaults."""
|
||||
|
||||
def test_pink_defaults(self):
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from launch_dolphin_pink import PINK_DEFAULTS
|
||||
self.assertEqual(PINK_DEFAULTS["strategy_name"], "pink")
|
||||
self.assertEqual(PINK_DEFAULTS["state_map"], "DOLPHIN_STATE_PINK")
|
||||
self.assertEqual(PINK_DEFAULTS["pnl_map"], "DOLPHIN_PNL_PINK")
|
||||
self.assertEqual(PINK_DEFAULTS["trader_id"], "DOLPHIN-PINK-001")
|
||||
self.assertEqual(PINK_DEFAULTS["journal_strategy"], "pink")
|
||||
self.assertEqual(PINK_DEFAULTS["journal_db"], "dolphin_pink")
|
||||
self.assertEqual(PINK_DEFAULTS["fixed_tp_pct"], 0.0020)
|
||||
self.assertEqual(PINK_DEFAULTS["vol_p60_threshold"], -1000000000.0)
|
||||
|
||||
def test_apply_pink_namespace_env_forces_testnet_namespace(self):
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from launch_dolphin_pink import _apply_pink_namespace_env
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"DOLPHIN_BINGX_ENV": "LIVE",
|
||||
"DOLPHIN_BINGX_ALLOW_MAINNET": "1",
|
||||
"DOLPHIN_STATE_MAP": "DOLPHIN_STATE_PRODGREEN",
|
||||
"DOLPHIN_PNL_MAP": "DOLPHIN_PNL_PRODGREEN",
|
||||
"DOLPHIN_STRATEGY_NAME": "prodgreen",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
_apply_pink_namespace_env()
|
||||
self.assertEqual(os.environ["DOLPHIN_BINGX_ENV"], "VST")
|
||||
self.assertEqual(os.environ["DOLPHIN_BINGX_ALLOW_MAINNET"], "0")
|
||||
self.assertEqual(os.environ["DOLPHIN_STRATEGY_NAME"], "pink")
|
||||
self.assertEqual(os.environ["DOLPHIN_STATE_MAP"], "DOLPHIN_STATE_PINK")
|
||||
self.assertEqual(os.environ["DOLPHIN_PNL_MAP"], "DOLPHIN_PNL_PINK")
|
||||
self.assertEqual(os.environ["DOLPHIN_FIXED_TP_PCT"], "0.0020")
|
||||
|
||||
def test_apply_pink_actor_overrides_forces_alias_and_blue_sync_isolation(self):
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from launch_dolphin_pink import _apply_pink_actor_overrides
|
||||
|
||||
actor_cfg = {
|
||||
"strategy_name": "prodgreen",
|
||||
"hazelcast": {
|
||||
"state_map": "DOLPHIN_STATE_PRODGREEN",
|
||||
"imap_pnl": "DOLPHIN_PNL_PRODGREEN",
|
||||
"state_map_aliases": ["DOLPHIN_STATE_GREEN"],
|
||||
"imap_pnl_aliases": ["DOLPHIN_PNL_GREEN"],
|
||||
},
|
||||
"adaptive_exit": {"shadow_db": "dolphin_prodgreen"},
|
||||
"v7_journal_db": "dolphin_prodgreen",
|
||||
"sync_bar_idx_from_blue": True,
|
||||
}
|
||||
updated = _apply_pink_actor_overrides(actor_cfg)
|
||||
self.assertEqual(updated["strategy_name"], "pink")
|
||||
self.assertEqual(updated["hazelcast"]["state_map"], "DOLPHIN_STATE_PINK")
|
||||
self.assertEqual(updated["hazelcast"]["imap_pnl"], "DOLPHIN_PNL_PINK")
|
||||
self.assertEqual(updated["hazelcast"]["state_map_aliases"], [])
|
||||
self.assertEqual(updated["hazelcast"]["imap_pnl_aliases"], [])
|
||||
self.assertEqual(updated["adaptive_exit"]["shadow_db"], "dolphin_pink")
|
||||
self.assertEqual(updated["v7_journal_db"], "dolphin_pink")
|
||||
self.assertEqual(updated["vol_p60_threshold"], -1000000000.0)
|
||||
self.assertEqual(updated["paper_trade"]["vol_p60"], -1000000000.0)
|
||||
self.assertFalse(updated["sync_bar_idx_from_blue"])
|
||||
|
||||
def test_apply_pink_actor_overrides_respects_env_vol_threshold(self):
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from launch_dolphin_pink import _apply_pink_actor_overrides
|
||||
|
||||
with patch.dict(os.environ, {"DOLPHIN_PINK_VOL_P60_THRESHOLD": "0.00007000"}, clear=False):
|
||||
updated = _apply_pink_actor_overrides({"hazelcast": {}, "adaptive_exit": {}})
|
||||
self.assertEqual(updated["vol_p60_threshold"], 0.00007000)
|
||||
|
||||
|
||||
class TestIsolationGuards(unittest.TestCase):
|
||||
"""Verify PINK never aliases to BLUE namespaces."""
|
||||
|
||||
def test_pink_config_no_blue_maps(self):
|
||||
import yaml
|
||||
config_path = Path("/mnt/dolphinng5_predict/prod/configs/pink.yml")
|
||||
with open(config_path) as f:
|
||||
cfg = yaml.safe_load(f)
|
||||
state_map = cfg["hazelcast"]["state_map"]
|
||||
pnl_map = cfg["hazelcast"]["imap_pnl"]
|
||||
self.assertNotIn("BLUE", state_map)
|
||||
self.assertNotIn("BLUE", pnl_map)
|
||||
self.assertNotIn("PRODGREEN", state_map)
|
||||
self.assertNotIn("PRODGREEN", pnl_map)
|
||||
|
||||
def test_pink_aliases_empty(self):
|
||||
import yaml
|
||||
config_path = Path("/mnt/dolphinng5_predict/prod/configs/pink.yml")
|
||||
with open(config_path) as f:
|
||||
cfg = yaml.safe_load(f)
|
||||
aliases = cfg["hazelcast"].get("state_map_aliases", [])
|
||||
pnl_aliases = cfg["hazelcast"].get("imap_pnl_aliases", [])
|
||||
self.assertEqual(aliases, [])
|
||||
self.assertEqual(pnl_aliases, [])
|
||||
|
||||
|
||||
class TestPinkClickHouseSchema(unittest.TestCase):
|
||||
"""Test that PINK CH schema files exist."""
|
||||
|
||||
def test_schema_dir_exists(self):
|
||||
schema_dir = Path("/mnt/dolphinng5_predict/prod/clickhouse/pink")
|
||||
self.assertTrue(schema_dir.is_dir())
|
||||
|
||||
def test_pink_schema_files_include_namespace_tags(self):
|
||||
schema_dir = Path("/mnt/dolphinng5_predict/prod/clickhouse/pink")
|
||||
for file_name in [
|
||||
"account_events.sql",
|
||||
"trade_events.sql",
|
||||
"v7_decision_events.sql",
|
||||
"adaptive_exit_shadow.sql",
|
||||
]:
|
||||
text = (schema_dir / file_name).read_text()
|
||||
self.assertIn("runtime_namespace", text)
|
||||
self.assertIn("strategy_namespace", text)
|
||||
self.assertIn("event_namespace", text)
|
||||
self.assertIn("actor_name", text)
|
||||
self.assertIn("exec_venue", text)
|
||||
self.assertIn("data_venue", text)
|
||||
|
||||
|
||||
class TestPinkRowTagging(unittest.TestCase):
|
||||
"""Test PINK writes carry standalone namespace tags."""
|
||||
|
||||
def test_dolphin_actor_tagged_ch_put_injects_pink_namespace(self):
|
||||
from nautilus_dolphin.nautilus.dolphin_actor import DolphinActor
|
||||
|
||||
actor = DolphinActor.__new__(DolphinActor)
|
||||
actor._strategy_name = "pink"
|
||||
actor._pink_row_tags = {
|
||||
"runtime_namespace": "pink",
|
||||
"strategy_namespace": "pink",
|
||||
"event_namespace": "pink",
|
||||
"actor_name": "DolphinActor",
|
||||
"exec_venue": "BINGX",
|
||||
"data_venue": "BINANCE",
|
||||
}
|
||||
actor._ch_put_base = MagicMock()
|
||||
|
||||
DolphinActor._pink_tagged_ch_put(actor, "trade_events", {"ts": 1, "strategy": "pink"})
|
||||
|
||||
actor._ch_put_base.assert_called_once()
|
||||
table, row = actor._ch_put_base.call_args.args
|
||||
self.assertEqual(table, "trade_events")
|
||||
self.assertEqual(row["strategy"], "pink")
|
||||
self.assertEqual(row["runtime_namespace"], "pink")
|
||||
self.assertEqual(row["strategy_namespace"], "pink")
|
||||
self.assertEqual(row["event_namespace"], "pink")
|
||||
self.assertEqual(row["actor_name"], "DolphinActor")
|
||||
self.assertEqual(row["exec_venue"], "BINGX")
|
||||
self.assertEqual(row["data_venue"], "BINANCE")
|
||||
|
||||
def test_bingx_execution_client_tag_helper_returns_pink_tags(self):
|
||||
from prod.bingx.execution import BingxExecutionClient
|
||||
|
||||
client = BingxExecutionClient.__new__(BingxExecutionClient)
|
||||
client._journal_strategy = "pink"
|
||||
tags = BingxExecutionClient._pink_observability_tags(client)
|
||||
self.assertEqual(tags["runtime_namespace"], "pink")
|
||||
self.assertEqual(tags["strategy_namespace"], "pink")
|
||||
self.assertEqual(tags["event_namespace"], "pink")
|
||||
self.assertEqual(tags["actor_name"], "BingxExecutionClient")
|
||||
self.assertEqual(tags["exec_venue"], "BINGX")
|
||||
self.assertEqual(tags["data_venue"], "BINGX")
|
||||
|
||||
def test_adaptive_exit_engine_tag_helper_returns_pink_tags(self):
|
||||
from adaptive_exit.adaptive_exit_engine import AdaptiveExitEngine
|
||||
|
||||
engine = object.__new__(AdaptiveExitEngine)
|
||||
engine._strategy_name = "pink"
|
||||
tags = AdaptiveExitEngine._row_tags(engine)
|
||||
self.assertEqual(tags["runtime_namespace"], "pink")
|
||||
self.assertEqual(tags["strategy_namespace"], "pink")
|
||||
self.assertEqual(tags["event_namespace"], "pink")
|
||||
self.assertEqual(tags["actor_name"], "AdaptiveExitEngine")
|
||||
self.assertEqual(tags["exec_venue"], "BINGX")
|
||||
self.assertEqual(tags["data_venue"], "BINGX")
|
||||
|
||||
def test_required_schema_files(self):
|
||||
schema_dir = Path("/mnt/dolphinng5_predict/prod/clickhouse/pink")
|
||||
required = [
|
||||
"00_create_database.sql",
|
||||
"account_events.sql",
|
||||
"trade_events.sql",
|
||||
"status_snapshots.sql",
|
||||
"v7_decision_events.sql",
|
||||
"adaptive_exit_shadow.sql",
|
||||
"02_create_trade_reconstruction.sql",
|
||||
"03_create_trade_exit_legs.sql",
|
||||
]
|
||||
for filename in required:
|
||||
self.assertTrue(
|
||||
(schema_dir / filename).exists(),
|
||||
f"Missing PINK schema file: {filename}",
|
||||
)
|
||||
|
||||
def test_schema_targets_dolphin_pink(self):
|
||||
schema_dir = Path("/mnt/dolphinng5_predict/prod/clickhouse/pink")
|
||||
for sql_file in schema_dir.glob("*.sql"):
|
||||
content = sql_file.read_text()
|
||||
self.assertIn(
|
||||
"dolphin_pink", content,
|
||||
f"{sql_file.name} must reference dolphin_pink database",
|
||||
)
|
||||
self.assertNotIn(
|
||||
"dolphin_prodgreen", content,
|
||||
f"{sql_file.name} must not reference dolphin_prodgreen",
|
||||
)
|
||||
self.assertNotIn(
|
||||
"dolphin_green", content,
|
||||
f"{sql_file.name} must not reference dolphin_green",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
527
prod/tests/test_pink_sync_async_seams.py
Normal file
527
prod/tests/test_pink_sync_async_seams.py
Normal file
@@ -0,0 +1,527 @@
|
||||
"""Exhaustive sync↔async seam tests for PINK-on-DITAv2.
|
||||
|
||||
Tests every boundary where sync code meets async code:
|
||||
1. BingxVenueAdapter._run() — 3 execution modes (no-loop, in-loop, already-ran)
|
||||
2. BingxVenueAdapter.connect() -> async backend
|
||||
3. kernel.process_intent() (sync) -> venue.submit() (sync) -> _run() -> async
|
||||
4. PinkDirectRuntime.step() (async) -> kernel.process_intent() (sync)
|
||||
5. launcher._maybe_close() inside/outside event loop
|
||||
6. _backend_snapshot() HTTP timeout cascade
|
||||
7. Thread safety: concurrent _run() calls, _last_snapshot races
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import inspect
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, List, Optional
|
||||
from unittest import mock
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seam 1: _run() execution modes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# We test the real _run() method directly by importing the module
|
||||
from prod.clean_arch.dita_v2.bingx_venue import BingxVenueAdapter
|
||||
|
||||
def _make_adapter() -> BingxVenueAdapter:
|
||||
"""Build a real BingxVenueAdapter for seam testing."""
|
||||
from prod.bingx.config import BingxExecClientConfig
|
||||
from prod.bingx.enums import BingxEnvironment
|
||||
from prod.clean_arch.adapters.bingx_direct import BingxDirectExecutionAdapter
|
||||
|
||||
config = BingxExecClientConfig(
|
||||
api_key="test", secret_key="test",
|
||||
environment=BingxEnvironment.VST,
|
||||
allow_mainnet=False,
|
||||
recv_window_ms=5000,
|
||||
default_leverage=1,
|
||||
exchange_leverage_cap=3,
|
||||
prefer_websocket=False,
|
||||
sizing_mode="testnet",
|
||||
journal_strategy="pink",
|
||||
journal_db="dolphin_pink",
|
||||
)
|
||||
backend = BingxDirectExecutionAdapter(config)
|
||||
return BingxVenueAdapter(backend=backend)
|
||||
|
||||
# Temporary adapter class so we can test _run() without making HTTP calls
|
||||
class _DummyBackend:
|
||||
"""Sync + async method surface for seam testing."""
|
||||
|
||||
def __init__(self):
|
||||
self._call_count = 0
|
||||
|
||||
# Sync method
|
||||
def sync_method(self, x: int = 1) -> int:
|
||||
self._call_count += 1
|
||||
return x * 2
|
||||
|
||||
# Async method
|
||||
async def async_method(self, x: int = 1) -> int:
|
||||
self._call_count += 1
|
||||
await asyncio.sleep(0.001)
|
||||
return x * 2
|
||||
|
||||
# Slow async method for timeout testing
|
||||
async def slow_async_method(self, delay: float = 10.0) -> str:
|
||||
self._call_count += 1
|
||||
await asyncio.sleep(delay)
|
||||
return "done"
|
||||
|
||||
# Coroutine that raises
|
||||
async def failing_async_method(self) -> None:
|
||||
self._call_count += 1
|
||||
await asyncio.sleep(0.001)
|
||||
raise ValueError("async failure")
|
||||
|
||||
# Method that IS a coroutine (not a function returning a coroutine)
|
||||
async def coro_method(self) -> str:
|
||||
return "coro"
|
||||
|
||||
class TestRunExecutionModes(unittest.TestCase):
|
||||
"""Test all 3 _run() execution modes exhaustively."""
|
||||
|
||||
def setUp(self):
|
||||
self.adapter = _make_adapter()
|
||||
self.backend = _DummyBackend()
|
||||
|
||||
# --- Mode 1: Non-awaitable (sync method, pass through) ---
|
||||
|
||||
def test_sync_method_passthrough(self):
|
||||
result = self.adapter._run(self.backend.sync_method(5))
|
||||
self.assertEqual(result, 10)
|
||||
self.assertEqual(self.backend._call_count, 1)
|
||||
|
||||
def test_sync_returns_none_passthrough(self):
|
||||
result = self.adapter._run(None)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_sync_returns_false_passthrough(self):
|
||||
result = self.adapter._run(False)
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_sync_returns_empty_list_passthrough(self):
|
||||
result = self.adapter._run([])
|
||||
self.assertEqual(result, [])
|
||||
|
||||
# --- Mode 2: Awaitable, no running loop (asyncio.run) ---
|
||||
|
||||
def test_async_method_no_loop(self):
|
||||
result = self.adapter._run(self.backend.async_method(7))
|
||||
self.assertEqual(result, 14)
|
||||
self.assertEqual(self.backend._call_count, 1)
|
||||
|
||||
def test_async_method_no_loop_negative(self):
|
||||
result = self.adapter._run(self.backend.async_method(-3))
|
||||
self.assertEqual(result, -6)
|
||||
|
||||
def test_async_method_no_loop_zero(self):
|
||||
result = self.adapter._run(self.backend.async_method(0))
|
||||
self.assertEqual(result, 0)
|
||||
|
||||
def test_async_method_no_loop_large_input(self):
|
||||
result = self.adapter._run(self.backend.async_method(1_000_000))
|
||||
self.assertEqual(result, 2_000_000)
|
||||
|
||||
# --- Mode 3: Awaitable, inside running loop (ThreadPoolExecutor) ---
|
||||
|
||||
def test_async_method_inside_loop(self):
|
||||
"""Call _run() from inside a running asyncio event loop."""
|
||||
async def run_inside_loop():
|
||||
return self.adapter._run(self.backend.async_method(11))
|
||||
result = asyncio.run(run_inside_loop())
|
||||
self.assertEqual(result, 22)
|
||||
|
||||
def test_async_method_inside_loop_multiple_calls(self):
|
||||
async def run_inside_loop():
|
||||
a = self.adapter._run(self.backend.async_method(1))
|
||||
b = self.adapter._run(self.backend.async_method(2))
|
||||
c = self.adapter._run(self.backend.async_method(3))
|
||||
return a, b, c
|
||||
a, b, c = asyncio.run(run_inside_loop())
|
||||
self.assertEqual((a, b, c), (2, 4, 6))
|
||||
|
||||
def test_async_inside_sync_inside_async_nested(self):
|
||||
"""Russian-doll nesting: sync -> async -> sync -> async."""
|
||||
async def outer():
|
||||
# Simulate what PinkDirectRuntime.step() does:
|
||||
# step() is async, calls kernel.process_intent() which is sync,
|
||||
# which calls venue.submit() which calls _run() on async backend
|
||||
def middle_sync():
|
||||
return self.adapter._run(self.backend.async_method(3))
|
||||
return middle_sync()
|
||||
result = asyncio.run(outer())
|
||||
self.assertEqual(result, 6)
|
||||
|
||||
# --- Error propagation ---
|
||||
|
||||
def test_async_exception_no_loop_propagates(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self.adapter._run(self.backend.failing_async_method())
|
||||
|
||||
def test_async_exception_inside_loop_propagates(self):
|
||||
async def run_inside_loop():
|
||||
return self.adapter._run(self.backend.failing_async_method())
|
||||
with self.assertRaises(ValueError):
|
||||
asyncio.run(run_inside_loop())
|
||||
|
||||
# --- Coroutine object handling ---
|
||||
|
||||
def test_coroutine_object_passed(self):
|
||||
"""Passing a coroutine object (not called yet) is handled."""
|
||||
coro = self.backend.async_method(5)
|
||||
self.assertTrue(inspect.iscoroutine(coro))
|
||||
result = self.adapter._run(coro)
|
||||
self.assertEqual(result, 10)
|
||||
|
||||
def test_coroutine_function_rejected(self):
|
||||
"""Passing a coroutine function (not called) is handled gracefully."""
|
||||
result = self.adapter._run(42) # not a coroutine at all
|
||||
self.assertEqual(result, 42)
|
||||
|
||||
# --- Thread pool stress ---
|
||||
|
||||
def test_concurrent_async_calls_from_multiple_threads(self):
|
||||
"""Multiple threads calling _run() simultaneously via shared executor."""
|
||||
errors = []
|
||||
results = []
|
||||
lock = threading.Lock()
|
||||
|
||||
def worker(x: int):
|
||||
try:
|
||||
result = self.adapter._run(self.backend.async_method(x))
|
||||
with lock:
|
||||
results.append(result)
|
||||
except Exception as e:
|
||||
with lock:
|
||||
errors.append(e)
|
||||
|
||||
threads = []
|
||||
for i in range(1, 11):
|
||||
t = threading.Thread(target=worker, args=(i,))
|
||||
threads.append(t)
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
self.assertEqual(len(errors), 0, f"Errors in concurrent calls: {errors}")
|
||||
self.assertEqual(len(results), 10)
|
||||
self.assertEqual(sorted(results), [2, 4, 6, 8, 10, 12, 14, 16, 18, 20])
|
||||
|
||||
def test_concurrent_and_sequential_mixed(self):
|
||||
"""Mix of concurrent and sequential _run() calls."""
|
||||
async def in_loop():
|
||||
results = []
|
||||
for i in range(5):
|
||||
r = self.adapter._run(self.backend.async_method(i))
|
||||
results.append(r)
|
||||
return results
|
||||
|
||||
# Sequential first
|
||||
seq_results = self.adapter._run(self.backend.async_method(100))
|
||||
self.assertEqual(seq_results, 200)
|
||||
|
||||
# Then from inside loop
|
||||
loop_results = asyncio.run(in_loop())
|
||||
self.assertEqual(loop_results, [0, 2, 4, 6, 8])
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seam 2: connect() -> async backend
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestConnectSeam(unittest.TestCase):
|
||||
"""Test the VenueAdapter.connect() sync->async bridge."""
|
||||
|
||||
def setUp(self):
|
||||
self.adapter = _make_adapter()
|
||||
|
||||
def test_connect_no_backend_method(self):
|
||||
"""Connect with no backend.connect method — should just snapshot."""
|
||||
backend = mock.Mock()
|
||||
backend.connect = None
|
||||
adapter = BingxVenueAdapter(backend=backend)
|
||||
# Should not crash — connect() checks for None
|
||||
result = adapter.connect()
|
||||
self.assertTrue(result)
|
||||
|
||||
def test_connect_sync_backend_method(self):
|
||||
"""Backend has sync connect."""
|
||||
backend = mock.Mock()
|
||||
backend.connect = mock.Mock(return_value=True)
|
||||
adapter = BingxVenueAdapter(backend=backend)
|
||||
# The adapter will call backend.connect() and then _backend_snapshot
|
||||
# which calls backend.refresh_state - may not exist on mock
|
||||
backend.refresh_state = mock.Mock(return_value=mock.Mock(
|
||||
capital=25000.0, equity=25000.0, open_positions={},
|
||||
open_orders=[], all_orders=[], all_fills=[],
|
||||
account={}, open_notional=0.0, source="mock", recovered=False,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
))
|
||||
result = adapter.connect()
|
||||
self.assertTrue(result)
|
||||
|
||||
def test_connect_no_connection_leak_on_failure(self):
|
||||
"""If backend connect fails, adapter should not leak."""
|
||||
with mock.patch.object(self.adapter, '_backend_snapshot',
|
||||
side_effect=RuntimeError("boom")):
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.adapter.connect()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seam 3: _backend_snapshot thread safety
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBackendSnapshotThreadSafety(unittest.TestCase):
|
||||
"""Test _last_snapshot is not corrupted by concurrent access."""
|
||||
|
||||
def setUp(self):
|
||||
self.adapter = _make_adapter()
|
||||
|
||||
def test_concurrent_backend_snapshot_calls(self):
|
||||
"""Multiple threads calling _backend_snapshot simultaneously."""
|
||||
backend = mock.Mock()
|
||||
snapshots = []
|
||||
for i in range(10):
|
||||
snapshots.append(mock.Mock(
|
||||
capital=float(25000 + i), equity=float(25000 + i),
|
||||
open_positions={}, open_orders=[], all_orders=[], all_fills=[],
|
||||
account={}, open_notional=0.0, source="mock", recovered=False,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
))
|
||||
backend.refresh_state = mock.Mock(side_effect=snapshots)
|
||||
adapter = BingxVenueAdapter(backend=backend)
|
||||
|
||||
def snapshot_worker():
|
||||
try:
|
||||
s = adapter._backend_snapshot()
|
||||
return s
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
with ThreadPoolExecutor(max_workers=10) as pool:
|
||||
futures = [pool.submit(snapshot_worker) for _ in range(10)]
|
||||
results = [f.result() for f in futures]
|
||||
|
||||
self.assertEqual(len(results), 10)
|
||||
# _last_snapshot should be set to the last one
|
||||
self.assertIsNotNone(adapter._last_snapshot)
|
||||
|
||||
def test_concurrent_open_orders_and_positions(self):
|
||||
"""open_orders() and open_positions() called concurrently."""
|
||||
backend = mock.Mock()
|
||||
backend.refresh_state = mock.Mock(return_value=mock.Mock(
|
||||
capital=25000.0, equity=25000.0,
|
||||
open_positions={"BTCUSDT": {"symbol": "BTCUSDT", "positionAmt": "0.01"}},
|
||||
open_orders=[{"orderId": "1"}], all_orders=[], all_fills=[],
|
||||
account={}, open_notional=100.0, source="mock", recovered=False,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
))
|
||||
adapter = BingxVenueAdapter(backend=backend)
|
||||
|
||||
def orders_worker():
|
||||
return adapter.open_orders()
|
||||
|
||||
def positions_worker():
|
||||
return adapter.open_positions()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
f1 = pool.submit(orders_worker)
|
||||
f2 = pool.submit(positions_worker)
|
||||
f3 = pool.submit(orders_worker)
|
||||
f4 = pool.submit(positions_worker)
|
||||
results = [f1.result(), f2.result(), f3.result(), f4.result()]
|
||||
|
||||
self.assertEqual(len(results[0]), 1) # 1 open order
|
||||
self.assertEqual(len(results[1]), 1) # 1 open position
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seam 4: _call_backend edge cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCallBackend(unittest.TestCase):
|
||||
"""Test the _call_backend sync->async bridge."""
|
||||
|
||||
def setUp(self):
|
||||
self.adapter = _make_adapter()
|
||||
|
||||
def test_call_backend_missing_method_raises(self):
|
||||
backend = object() # real object, not Mock — Mock returns mock for any attr
|
||||
adapter = BingxVenueAdapter(backend=backend)
|
||||
with self.assertRaises(AttributeError):
|
||||
adapter._call_backend("nonexistent_method")
|
||||
|
||||
def test_call_backend_with_args(self):
|
||||
"""Args and kwargs are forwarded correctly through async boundary."""
|
||||
backend = mock.Mock()
|
||||
backend.test_method = mock.Mock(return_value=42)
|
||||
adapter = BingxVenueAdapter(backend=backend)
|
||||
result = adapter._call_backend("test_method", 1, 2, kwarg="v")
|
||||
backend.test_method.assert_called_once_with(1, 2, kwarg="v")
|
||||
self.assertEqual(result, 42)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seam 5: _maybe_close inside/outside event loop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMaybeCloseSeam(unittest.TestCase):
|
||||
"""Test launcher._maybe_close() in various contexts."""
|
||||
|
||||
def test_maybe_close_sync_method(self):
|
||||
from prod.clean_arch.dita_v2.launcher import _maybe_close
|
||||
obj = mock.Mock()
|
||||
obj.close = mock.Mock(return_value=True)
|
||||
_maybe_close(obj)
|
||||
obj.close.assert_called_once()
|
||||
|
||||
def test_maybe_close_async_method_no_loop(self):
|
||||
from prod.clean_arch.dita_v2.launcher import _maybe_close
|
||||
|
||||
async def async_close():
|
||||
return "closed"
|
||||
|
||||
obj = mock.Mock()
|
||||
obj.close = mock.Mock(return_value=async_close())
|
||||
_maybe_close(obj)
|
||||
obj.close.assert_called_once()
|
||||
|
||||
def test_maybe_close_async_method_inside_loop(self):
|
||||
"""Must not crash if called from inside a running event loop."""
|
||||
from prod.clean_arch.dita_v2.launcher import _maybe_close
|
||||
|
||||
async def test():
|
||||
async def async_close():
|
||||
return "closed"
|
||||
obj = mock.Mock()
|
||||
obj.close = mock.Mock(return_value=async_close())
|
||||
# _maybe_close must handle RuntimeError from asyncio.run()
|
||||
# and swallow it gracefully
|
||||
_maybe_close(obj)
|
||||
return True
|
||||
|
||||
result = asyncio.run(test())
|
||||
self.assertTrue(result)
|
||||
|
||||
def test_maybe_close_disconnect_fallback(self):
|
||||
from prod.clean_arch.dita_v2.launcher import _maybe_close
|
||||
obj = mock.Mock()
|
||||
obj.close = None
|
||||
obj.disconnect = mock.Mock(return_value=True)
|
||||
_maybe_close(obj)
|
||||
obj.disconnect.assert_called_once()
|
||||
|
||||
def test_maybe_close_no_methods(self):
|
||||
from prod.clean_arch.dita_v2.launcher import _maybe_close
|
||||
obj = object()
|
||||
_maybe_close(obj) # Should not crash
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seam 6: Full lifecycle race conditions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFullLifecycleRaceConditions(unittest.TestCase):
|
||||
"""Race conditions between kernel, venue, and runtime."""
|
||||
|
||||
def test_concurrent_submit_and_reconcile(self):
|
||||
"""submit() and reconcile() called simultaneously from different threads."""
|
||||
backend = mock.Mock()
|
||||
backend.submit_intent = mock.Mock(return_value=mock.Mock(
|
||||
status="FILLED", quantity=1.0, price=100.0,
|
||||
client_order_id="test", order_id="1",
|
||||
raw_ack={"status": "FILLED"}, raw_state={},
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
))
|
||||
base_snapshot = mock.Mock(
|
||||
capital=25000.0, equity=25000.0,
|
||||
open_positions={}, open_orders=[], all_orders=[], all_fills=[],
|
||||
account={}, open_notional=0.0, source="mock", recovered=False,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
backend.refresh_state = mock.Mock(return_value=base_snapshot)
|
||||
adapter = BingxVenueAdapter(backend=backend)
|
||||
|
||||
from prod.clean_arch.dita_v2.contracts import KernelCommandType, KernelIntent, TradeSide
|
||||
|
||||
intent = KernelIntent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
intent_id="race-test", trade_id="race-trade",
|
||||
slot_id=0, asset="BTCUSDT", side=TradeSide.SHORT,
|
||||
action=KernelCommandType.ENTER,
|
||||
reference_price=100.0, target_size=1.0, leverage=1.0,
|
||||
)
|
||||
|
||||
def submit_worker():
|
||||
return adapter.submit(intent)
|
||||
|
||||
def reconcile_worker():
|
||||
return adapter.reconcile()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
f_submit = pool.submit(submit_worker)
|
||||
f_reconcile = pool.submit(reconcile_worker)
|
||||
f_submit2 = pool.submit(submit_worker)
|
||||
f_reconcile2 = pool.submit(reconcile_worker)
|
||||
results = [f.result() for f in [f_submit, f_reconcile, f_submit2, f_reconcile2]]
|
||||
|
||||
self.assertEqual(len(results), 4)
|
||||
self.assertIsNotNone(adapter._last_snapshot)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seam 7: Nested event-loop detection and prevention
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seam 8: Timeout and hang detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTimeoutAndHangDetection(unittest.TestCase):
|
||||
"""Test that slow async methods trigger timeouts properly."""
|
||||
|
||||
def test_slow_async_no_timeout_no_loop(self):
|
||||
"""Slow async without loop just runs — no timeout mechanism in _run()."""
|
||||
backend = _DummyBackend()
|
||||
adapter = _make_adapter()
|
||||
# This would hang for 10 seconds if we actually ran it
|
||||
# Instead we verify that _run() would pass it through correctly
|
||||
coro = backend.slow_async_method(delay=0.001) # fast
|
||||
result = adapter._run(coro)
|
||||
self.assertEqual(result, "done")
|
||||
|
||||
def test_slow_async_with_timeout_inside_loop_future(self):
|
||||
"""ThreadPoolExecutor submit().result() can be given a timeout."""
|
||||
backend = _DummyBackend()
|
||||
|
||||
async def test():
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||
future = pool.submit(asyncio.run, backend.slow_async_method(delay=10.0))
|
||||
with self.assertRaises(concurrent.futures.TimeoutError):
|
||||
future.result(timeout=0.5)
|
||||
return True
|
||||
|
||||
result = asyncio.run(test())
|
||||
self.assertTrue(result)
|
||||
|
||||
def test_http_timeout_propagation(self):
|
||||
"""Verify BingX HTTP client timeout propagates through async boundary."""
|
||||
# The httpx.AsyncClient has a 10s timeout by default
|
||||
# This test verifies the timeout config is respected
|
||||
from prod.bingx.http import BingxHttpClient
|
||||
from prod.bingx.config import BingxExecClientConfig
|
||||
from prod.bingx.enums import BingxEnvironment
|
||||
|
||||
config = BingxExecClientConfig(
|
||||
api_key="test", secret_key="test",
|
||||
environment=BingxEnvironment.VST,
|
||||
http_timeout_secs=5,
|
||||
)
|
||||
client = BingxHttpClient(config)
|
||||
self.assertEqual(client._timeout_secs, 5)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user