391 lines
17 KiB
Python
391 lines
17 KiB
Python
|
|
"""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}"
|