Files
siloqy/prod/clean_arch/dita_v2/test_leverage_cache.py
Codex c864e9c550 PINK: S1 leverage cache, S2 background refresh, Gap 1/2/3 fee+slippage logging
S1 — Leverage cache (bingx_direct.py):
  _ensure_leverage(): per-symbol asyncio.Lock + cached value check; skips ~350ms
  POST when exchange already has the requested leverage.  Saves ~350ms/trade.
  Cache updated ONLY on success; failed POST leaves cache stale → correct retry.
  Persist: JSON sidecar /tmp/.bingx_leverage_cache_{env}.json; survives restarts.
  connect(): _verify_leverage_drift() detects when another process changed leverage
  at the exchange and updates cache to exchange truth (logs WARNING on drift).
  Multi-runner contract: leverage is account-level on BingX; documented that
  concurrent runners with different leverage desires for same symbol conflict.
  20 mock tests: same-lev skip, change-triggers-POST, failure-no-cache-update,
  concurrent-same-symbol (lock prevents race), drift-detect, persist/restore,
  multi-runner known-limitation documentation test.

S2 — Background state refresh (bingx_direct.py):
  MARKET fills: asyncio.create_task(_refresh_state_background) — does not block
  submit path.  WS FILL_SETTLED + ACCOUNT_UPDATE deliver capital truth anyway.
  LIMIT fills: synchronous refresh retained (include_history=False, not True) —
  needed to detect resting order state for next pump cycle.
  Saves ~600–900ms/trade on MARKET exits. ENTER similarly improved.

Gap 1 — VenueEvent friction fields (contracts.py):
  Added: fee, fee_asset, fee_source, is_maker, exchange_ts, slippage_bps,
  mark_at_submit — all with defaults so existing callers are unaffected.
  Detailed inline docs for sign conventions and provenance codes.

Gap 2 — Fee estimation + WS_SETTLED provenance (bingx_direct.py, pink_clickhouse.py):
  submit_intent: estimates fee from fill_price × fill_qty × taker/maker rate;
  annotates ack_row with _fee_estimated, _fee_source, _is_maker_est.
  persist_fee_settled(): new method writes fee_settled_events row when WS
  ORDER_TRADE_UPDATE delivers actual commission ("n" field); fee_source="WS_SETTLED".
  pink_direct._run_account_stream: calls persist_fee_settled on FILL_SETTLED.

Gap 3 — Slippage measurement (bingx_direct.py, bingx_venue.py, pink_clickhouse.py):
  Captures mark_at_submit before the order POST; computes slippage_bps signed
  by side: positive = adverse (taker overpaid / maker undersold), negative =
  price improvement.  Measured for BOTH taker and maker fills for symmetry.
  Flows through VenueEvent → trade_events.slippage_bps + trade_exit_legs.slippage_bps.

S3 / SOR — Maker order placement: comprehensive TODO block in submit_intent with:
  SHORT/LONG-aware price offset design, OBF integration requirements,
  TODO_ADD_PARAMSET_VIBRISS for spread_bps threshold, intelligent timeout_s
  calibration requirements, price-impact awareness gap, SOR abstraction CRITICAL TODO.
  REST/WS split: documented why BingX (and all retail venues) separate these
  and why a unified VenueAdapter protocol is the long-term solution.

151/151 existing tests green + 20 new leverage cache tests = 171 total.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 12:25:12 +02:00

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}"