Files
siloqy/prod/tests/test_pink_sync_async_seams.py
Codex 3d7b00e28d 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>
2026-05-30 18:26:43 +02:00

528 lines
20 KiB
Python

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