"""S1: Leverage cache — comprehensive mock tests. Covers: - Same-leverage skip (no POST) - Change triggers POST - POST failure → cache NOT updated → retry on next call - Concurrent same-symbol same-leverage: only one POST (lock) - Concurrent same-symbol different-leverage: serialised, both POST - Connect-time drift detection and cache correction - Persist/restore across "restarts" (file-based) - Multi-runner conflict: both runners see the same account-level leverage - Leverage after BingX HTTP error: partial state handled correctly Bad-leverage-at-trade is one of the worst possible outcomes — these tests guard every code path that could produce it. """ from __future__ import annotations import asyncio import json import sys import tempfile from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch, call sys.path.insert(0, "/mnt/dolphinng5_predict") import pytest # --------------------------------------------------------------------------- # Helpers — minimal adapter stub wired to a fake HTTP client # --------------------------------------------------------------------------- def _make_adapter(tmp_path: Path, env_tag: str = "vst"): """Build a BingxDirectExecutionAdapter with a mocked HTTP client.""" from prod.clean_arch.adapters.bingx_direct import BingxDirectExecutionAdapter, BingxDirectExecutionConfig from prod.bingx.enums import BingxEnvironment cfg = BingxDirectExecutionConfig( environment=BingxEnvironment.VST, allow_mainnet=False, default_leverage=1, exchange_leverage_cap=3, ) mock_client = AsyncMock() mock_client.signed_post = AsyncMock(return_value={"leverage": 1}) mock_client.signed_get = AsyncMock(return_value={"leverage": 1, "longLeverage": 1}) mock_provider = MagicMock() mock_provider.initialize = AsyncMock() mock_provider.find = MagicMock(return_value=None) mock_provider.list_all = MagicMock(return_value=[]) adapter = BingxDirectExecutionAdapter(cfg, client=mock_client, provider=mock_provider) # Override persist path to tmp THEN reload — __init__ already loaded from the # default /tmp path. Reloading after override ensures tests are path-isolated. adapter._leverage_cache_path = tmp_path / f".bingx_leverage_cache_{env_tag}.json" adapter._leverage_cache = {} adapter._load_leverage_cache() # load from test-specific tmp path (may be empty) return adapter, mock_client # --------------------------------------------------------------------------- # 1. Basic skip — same leverage, no POST # --------------------------------------------------------------------------- class TestLeverageCacheBasic: @pytest.mark.asyncio async def test_skip_when_same_leverage(self, tmp_path): adapter, client = _make_adapter(tmp_path) # First call: cache miss → POST await adapter._ensure_leverage("TRX-USDT", 1) assert client.signed_post.call_count == 1 # Second call: cache hit → NO POST await adapter._ensure_leverage("TRX-USDT", 1) assert client.signed_post.call_count == 1, "Should not POST when leverage unchanged" @pytest.mark.asyncio async def test_post_on_change(self, tmp_path): adapter, client = _make_adapter(tmp_path) await adapter._ensure_leverage("TRX-USDT", 1) assert client.signed_post.call_count == 1 # Change leverage → must POST again await adapter._ensure_leverage("TRX-USDT", 2) assert client.signed_post.call_count == 2, "Should POST when leverage changes" assert adapter._leverage_cache["TRX-USDT"] == 2 @pytest.mark.asyncio async def test_different_symbols_independent(self, tmp_path): adapter, client = _make_adapter(tmp_path) await adapter._ensure_leverage("TRX-USDT", 1) await adapter._ensure_leverage("XRP-USDT", 1) assert client.signed_post.call_count == 2, "Each symbol needs its own POST" # Skip both on repeat await adapter._ensure_leverage("TRX-USDT", 1) await adapter._ensure_leverage("XRP-USDT", 1) assert client.signed_post.call_count == 2, "No extra POSTs for cached symbols" # --------------------------------------------------------------------------- # 2. Failure handling — cache NOT updated on POST failure # --------------------------------------------------------------------------- class TestLeverageCacheFailure: @pytest.mark.asyncio async def test_cache_not_updated_on_post_failure(self, tmp_path): from prod.bingx.http import BingxHttpError adapter, client = _make_adapter(tmp_path) client.signed_post.side_effect = BingxHttpError("HTTP 429 rate limit") result = await adapter._ensure_leverage("TRX-USDT", 2) assert result is False, "Should return False on failure" assert "TRX-USDT" not in adapter._leverage_cache, ( "Cache must NOT be updated when POST fails — " "next submit must retry, not use wrong leverage" ) @pytest.mark.asyncio async def test_retry_on_next_call_after_failure(self, tmp_path): from prod.bingx.http import BingxHttpError adapter, client = _make_adapter(tmp_path) # First attempt fails client.signed_post.side_effect = BingxHttpError("rate limit") await adapter._ensure_leverage("TRX-USDT", 2) assert client.signed_post.call_count == 1 # Second attempt succeeds client.signed_post.side_effect = None client.signed_post.return_value = {"leverage": 2} await adapter._ensure_leverage("TRX-USDT", 2) assert client.signed_post.call_count == 2, "Must retry after failure" assert adapter._leverage_cache.get("TRX-USDT") == 2 @pytest.mark.asyncio async def test_first_call_returns_true_on_success(self, tmp_path): adapter, client = _make_adapter(tmp_path) result = await adapter._ensure_leverage("TRX-USDT", 1) assert result is True, "Should return True when POST is made" @pytest.mark.asyncio async def test_skip_returns_false(self, tmp_path): adapter, client = _make_adapter(tmp_path) await adapter._ensure_leverage("TRX-USDT", 1) result = await adapter._ensure_leverage("TRX-USDT", 1) assert result is False, "Should return False when POST is skipped" # --------------------------------------------------------------------------- # 3. Concurrency — asyncio.Lock prevents interleaving # --------------------------------------------------------------------------- class TestLeverageCacheConcurrency: @pytest.mark.asyncio async def test_concurrent_same_symbol_same_leverage_one_post(self, tmp_path): """Two concurrent submits for same symbol+leverage → exactly one POST.""" adapter, client = _make_adapter(tmp_path) # Introduce a small delay so both calls enter _ensure_leverage before either completes call_count = 0 async def slow_post(*args, **kwargs): nonlocal call_count call_count += 1 await asyncio.sleep(0.01) return {"leverage": 1} client.signed_post.side_effect = slow_post results = await asyncio.gather( adapter._ensure_leverage("TRX-USDT", 1), adapter._ensure_leverage("TRX-USDT", 1), ) # Only ONE should have actually POSTed (the one that won the lock) assert call_count == 1, ( f"Expected exactly 1 leverage POST for same symbol+leverage, got {call_count}. " "This is the heisenbug: if the lock isn't protecting the cache check+update " "atomically, both calls see an empty cache and both POST." ) # One True (did POST), one False (saw cache hit after lock) assert sorted(results) == [False, True], f"Expected [False, True], got {results}" @pytest.mark.asyncio async def test_concurrent_same_symbol_different_leverage_both_post(self, tmp_path): """Two calls with different leverages for same symbol → both POST, serialised.""" adapter, client = _make_adapter(tmp_path) posted_leverages = [] async def recording_post(path, params): await asyncio.sleep(0.005) posted_leverages.append(params.get("leverage")) return {"leverage": params.get("leverage")} client.signed_post.side_effect = recording_post await asyncio.gather( adapter._ensure_leverage("TRX-USDT", 1), adapter._ensure_leverage("TRX-USDT", 2), ) assert len(posted_leverages) == 2, "Both different-leverage calls must POST" assert set(posted_leverages) == {1, 2} @pytest.mark.asyncio async def test_independent_symbols_concurrent_no_interference(self, tmp_path): """Different symbols are fully independent — no cross-symbol blocking.""" adapter, client = _make_adapter(tmp_path) call_order = [] async def recording_post(path, params): call_order.append(params.get("symbol", "")) return {"leverage": 1} client.signed_post.side_effect = recording_post await asyncio.gather( adapter._ensure_leverage("TRX-USDT", 1), adapter._ensure_leverage("XRP-USDT", 1), adapter._ensure_leverage("BTC-USDT", 1), ) assert len(call_order) == 3, "All three symbols should POST independently" # --------------------------------------------------------------------------- # 4. Persistence — JSON sidecar survives "restarts" # --------------------------------------------------------------------------- class TestLeverageCachePersistence: @pytest.mark.asyncio async def test_cache_persisted_on_set(self, tmp_path): adapter, client = _make_adapter(tmp_path) await adapter._ensure_leverage("TRX-USDT", 2) persisted = json.loads(adapter._leverage_cache_path.read_text()) assert persisted.get("TRX-USDT") == 2 @pytest.mark.asyncio async def test_cache_restored_on_init(self, tmp_path): """Second adapter instance loads cache from file → skips POST for cached symbol.""" adapter1, client1 = _make_adapter(tmp_path) await adapter1._ensure_leverage("TRX-USDT", 2) assert client1.signed_post.call_count == 1 # Second adapter reads from same file adapter2, client2 = _make_adapter(tmp_path) assert adapter2._leverage_cache.get("TRX-USDT") == 2 await adapter2._ensure_leverage("TRX-USDT", 2) assert client2.signed_post.call_count == 0, ( "After restart, cached leverage should not trigger another POST" ) @pytest.mark.asyncio async def test_corrupt_cache_file_handled_gracefully(self, tmp_path): adapter, _ = _make_adapter(tmp_path) adapter._leverage_cache_path.write_text("{not valid json}") # Re-load should not crash; cache resets to empty adapter._load_leverage_cache() assert adapter._leverage_cache == {} @pytest.mark.asyncio async def test_invalid_leverage_values_filtered(self, tmp_path): # Write the file first, then create adapter pointing at it cache_file = tmp_path / ".bingx_leverage_cache_vst.json" cache_file.write_text( json.dumps({"TRX-USDT": 2, "XRP-USDT": -1, "BTC-USDT": "bad", "ETH-USDT": 0}) ) adapter, _ = _make_adapter(tmp_path) # _make_adapter calls _load_leverage_cache after path set # Only valid (>= 1) entries survive assert adapter._leverage_cache.get("TRX-USDT") == 2 assert "XRP-USDT" not in adapter._leverage_cache, "Negative leverage filtered" assert "BTC-USDT" not in adapter._leverage_cache, "Non-numeric leverage filtered" assert "ETH-USDT" not in adapter._leverage_cache, "Zero leverage filtered" # --------------------------------------------------------------------------- # 5. Connect-time drift detection # --------------------------------------------------------------------------- class TestLeverageDriftDetection: @pytest.mark.asyncio async def test_drift_detected_and_cache_corrected(self, tmp_path): """Exchange has lev=2 but cache says 1 → cache updated to exchange truth.""" adapter, client = _make_adapter(tmp_path) adapter._leverage_cache["TRX-USDT"] = 1 # stale cache # Exchange returns 2 (another runner changed it) client.signed_get.return_value = {"leverage": 2, "longLeverage": 2} await adapter._verify_leverage_drift() assert adapter._leverage_cache.get("TRX-USDT") == 2, ( "Cache must be updated to exchange truth after drift detected" ) @pytest.mark.asyncio async def test_no_drift_no_update(self, tmp_path): adapter, client = _make_adapter(tmp_path) adapter._leverage_cache["TRX-USDT"] = 1 client.signed_get.return_value = {"leverage": 1, "longLeverage": 1} await adapter._verify_leverage_drift() # No change — cache is already correct assert adapter._leverage_cache.get("TRX-USDT") == 1 @pytest.mark.asyncio async def test_drift_check_failure_is_non_fatal(self, tmp_path): """If drift check itself fails (network error), adapter must not crash.""" from prod.bingx.http import BingxHttpError adapter, client = _make_adapter(tmp_path) adapter._leverage_cache["TRX-USDT"] = 1 client.signed_get.side_effect = BingxHttpError("rate limit") await adapter._verify_leverage_drift() # must not raise assert adapter._leverage_cache.get("TRX-USDT") == 1 # unchanged @pytest.mark.asyncio async def test_connect_calls_drift_verification(self, tmp_path): """connect() must call _verify_leverage_drift after refresh_state.""" adapter, client = _make_adapter(tmp_path) adapter._leverage_cache["TRX-USDT"] = 99 # obviously wrong from prod.clean_arch.ports.execution import ExchangeStateSnapshot from datetime import datetime, timezone snap = ExchangeStateSnapshot( timestamp=datetime.now(timezone.utc), capital=25000.0, equity=25000.0, open_positions={}, open_orders=[], all_orders=[], all_fills=[], account={}, open_notional=0.0, source="test", ) adapter.refresh_state = AsyncMock(return_value=snap) client.signed_get.return_value = {"leverage": 1, "longLeverage": 1} await adapter.connect() client.signed_get.assert_called() # drift check happened # --------------------------------------------------------------------------- # 6. Multi-runner contract documentation test # --------------------------------------------------------------------------- class TestMultiRunnerContract: @pytest.mark.asyncio async def test_account_level_leverage_last_writer_wins(self, tmp_path): """ CRITICAL: BingX has ONE leverage setting per symbol per account. Two runners requesting different leverages for the same symbol CANNOT be safely arbitrated by the cache alone — the exchange will reflect whichever runner's POST arrived last. This test documents the known limitation: runner-A sets lev=1, runner-B sets lev=2, runner-A's order may execute at lev=2. Detection requires cross-process coordination (Zinc arbiter) which is not yet implemented. For now, ensure leverage is uniform across all runners for a shared account. """ # Simulate runner A adapter_a, client_a = _make_adapter(tmp_path) client_a.signed_post.return_value = {"leverage": 1} await adapter_a._ensure_leverage("TRX-USDT", 1) assert adapter_a._leverage_cache["TRX-USDT"] == 1 # Simulate runner B (different adapter, same account) adapter_b, client_b = _make_adapter(tmp_path) client_b.signed_post.return_value = {"leverage": 2} await adapter_b._ensure_leverage("TRX-USDT", 2) assert adapter_b._leverage_cache["TRX-USDT"] == 2 # Runner A's cache is now STALE — exchange has lev=2 from runner B # Runner A believes lev=1 but the exchange has lev=2. # This is the known multi-runner conflict with no current mitigation. assert adapter_a._leverage_cache["TRX-USDT"] == 1, ( "Runner A's cache is stale after runner B changed leverage. " "This is expected — document the known limitation. " "Fix: cross-process Zinc leverage arbiter (future work)." ) @pytest.mark.asyncio async def test_single_runner_consistent_after_multiple_symbols(self, tmp_path): """Within one runner: leverage is always correct after successful POST.""" adapter, client = _make_adapter(tmp_path) for sym, lev in [("TRX-USDT", 1), ("XRP-USDT", 2), ("BTC-USDT", 1)]: await adapter._ensure_leverage(sym, lev) assert adapter._leverage_cache[sym] == lev, f"Cache wrong for {sym}"