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