Files
siloqy/prod/clean_arch/dita_v2/test_leverage_cache.py

391 lines
17 KiB
Python
Raw Normal View History

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