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>
This commit is contained in:
Codex
2026-06-05 12:25:12 +02:00
parent 714913bab6
commit c864e9c550
6 changed files with 1171 additions and 36 deletions

View File

@@ -493,6 +493,14 @@ class BingxVenueAdapter(VenueAdapter):
**{**base_event.__dict__, "event_id": _event_id(self._event_seq), "kind": KernelEventKind.ORDER_REJECT, "status": VenueEventStatus.REJECTED, "reason": _row_text(ack_row, "msg", "message", default="BINGX_ORDER_REJECTED")},
)
]
# Extract friction fields annotated by submit_intent (Gap 1/2/3).
fee_estimated = float(ack_row.get("_fee_estimated") or 0.0)
fee_source = str(ack_row.get("_fee_source") or "")
is_maker_est = bool(ack_row.get("_is_maker_est", False))
mark_at_submit = float(ack_row.get("_mark_at_submit") or 0.0)
slippage_bps = float(ack_row.get("_slippage_bps") or 0.0)
exchange_ts = int(ack_row.get("_exchange_ts") or 0)
events = [base_event]
fill_status = _venue_event_status_from_row(status)
filled_size = _row_float(ack_row, "executedQty", "cumFilledQty", "filledQty", "lastFilledQty", default=0.0)
@@ -524,6 +532,15 @@ class BingxVenueAdapter(VenueAdapter):
reason="",
raw_payload=ack_row or json_safe(receipt),
metadata={"intent_id": intent.intent_id, "action": intent.action.value},
# Gap 1/2/3: fee + friction fields populated from submit_intent annotations.
# fee_source="ESTIMATED_*" until WS FILL_SETTLED updates it to "WS_SETTLED".
fee=fee_estimated,
fee_asset="USDT",
fee_source=fee_source,
is_maker=is_maker_est,
exchange_ts=exchange_ts,
slippage_bps=slippage_bps,
mark_at_submit=mark_at_submit,
)
)
return events

View File

@@ -0,0 +1,362 @@
"""Canonical v2 contracts for the DITAv2 execution kernel."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Any, Dict, Mapping, Optional, Sequence, Tuple
class TradeSide(str, Enum):
"""Trade side."""
LONG = "LONG"
SHORT = "SHORT"
FLAT = "FLAT"
class TradeStage(str, Enum):
"""Execution stage for a trade slot."""
IDLE = "IDLE"
DECISION_CREATED = "DECISION_CREATED"
INTENT_CREATED = "INTENT_CREATED"
ORDER_REQUESTED = "ORDER_REQUESTED"
ORDER_SENT = "ORDER_SENT"
ORDER_ACKED = "ORDER_ACKED"
ORDER_REJECTED = "ORDER_REJECTED"
ENTRY_WORKING = "ENTRY_WORKING"
PARTIAL_FILL = "PARTIAL_FILL"
POSITION_OPENED = "POSITION_OPENED"
POSITION_OPEN = "POSITION_OPEN"
EXIT_REQUESTED = "EXIT_REQUESTED"
EXIT_SENT = "EXIT_SENT"
EXIT_ACKED = "EXIT_ACKED"
EXIT_REJECTED = "EXIT_REJECTED"
EXIT_WORKING = "EXIT_WORKING"
POSITION_PARTIALLY_CLOSED = "POSITION_PARTIALLY_CLOSED"
POSITION_CLOSED = "POSITION_CLOSED"
CLOSED = "CLOSED"
TRADE_TERMINAL_WRITTEN = "TRADE_TERMINAL_WRITTEN"
STALE_STATE_RECONCILING = "STALE_STATE_RECONCILING"
class KernelCommandType(str, Enum):
"""Kernel command types."""
ENTER = "ENTER"
EXIT = "EXIT"
MARK_PRICE = "MARK_PRICE"
RECONCILE = "RECONCILE"
CONTROL = "CONTROL"
CANCEL = "CANCEL"
class KernelEventKind(str, Enum):
"""Normalized venue event kinds."""
ORDER_ACK = "ORDER_ACK"
ORDER_REJECT = "ORDER_REJECT"
RATE_LIMITED = "RATE_LIMITED"
PARTIAL_FILL = "PARTIAL_FILL"
FULL_FILL = "FULL_FILL"
CANCEL_ACK = "CANCEL_ACK"
CANCEL_REJECT = "CANCEL_REJECT"
MARK_PRICE = "MARK_PRICE"
RECONCILE = "RECONCILE"
CONTROL = "CONTROL"
class KernelDiagnosticCode(str, Enum):
"""Structured diagnostic codes emitted by the kernel."""
OK = "OK"
RATE_LIMITED = "RATE_LIMITED"
INVALID_SLOT_ID = "INVALID_SLOT_ID"
INVALID_INTENT = "INVALID_INTENT"
UNSUPPORTED_INTENT = "UNSUPPORTED_INTENT"
SLOT_BUSY = "SLOT_BUSY"
NO_OPEN_POSITION = "NO_OPEN_POSITION"
NO_ACTIVE_EXIT_ORDER = "NO_ACTIVE_EXIT_ORDER"
UNKNOWN_EVENT_KIND = "UNKNOWN_EVENT_KIND"
ORDER_REJECTED = "ORDER_REJECTED"
ENTRY_ORDER_REJECTED = "ENTRY_ORDER_REJECTED"
EXIT_ORDER_REJECTED = "EXIT_ORDER_REJECTED"
CANCEL_REJECTED = "CANCEL_REJECTED"
STALE_STATE_RECONCILE = "STALE_STATE_RECONCILE"
RECONCILED = "RECONCILED"
DUPLICATE_EVENT = "DUPLICATE_EVENT"
UNRESOLVED_SLOT = "UNRESOLVED_SLOT"
INVALID_TRANSITION = "INVALID_TRANSITION"
TERMINAL_STATE = "TERMINAL_STATE"
CAPITAL_FROZEN = "CAPITAL_FROZEN"
class KernelSeverity(str, Enum):
"""Severity classification for kernel outcomes."""
INFO = "INFO"
WARNING = "WARNING"
ERROR = "ERROR"
CRITICAL = "CRITICAL"
class VenueOrderStatus(str, Enum):
"""Order status surface mirrored from venue truth."""
NEW = "NEW"
ACKED = "ACKED"
PARTIALLY_FILLED = "PARTIALLY_FILLED"
FILLED = "FILLED"
CANCELED = "CANCELED"
REJECTED = "REJECTED"
class VenueEventStatus(str, Enum):
"""Status alias for normalized venue events."""
ACKED = "ACKED"
REJECTED = "REJECTED"
RATE_LIMITED = "RATE_LIMITED"
PARTIALLY_FILLED = "PARTIALLY_FILLED"
FILLED = "FILLED"
CANCELED = "CANCELED"
CANCELED_REJECTED = "CANCEL_REJECTED"
@dataclass(frozen=True)
class VenueOrder:
"""Venue-specific order identity and fill state."""
internal_trade_id: str
venue_order_id: str
venue_client_id: str
side: TradeSide
intended_size: float
filled_size: float = 0.0
average_fill_price: float = 0.0
status: VenueOrderStatus = VenueOrderStatus.NEW
metadata: Dict[str, Any] = field(default_factory=dict)
@property
def remaining_size(self) -> float:
return max(0.0, float(self.intended_size) - float(self.filled_size))
@dataclass
class TradeSlot:
"""A single execution slot managed by the v2 kernel."""
slot_id: int
trade_id: str = ""
asset: str = ""
side: TradeSide = TradeSide.FLAT
entry_price: float = 0.0
size: float = 0.0
initial_size: float = 0.0
leverage: float = 0.0
entry_time: Optional[datetime] = None
unrealized_pnl: float = 0.0
realized_pnl: float = 0.0
closed: bool = False
exit_leg_ratios: Tuple[float, ...] = (1.0,)
active_leg_index: int = 0
active_exit_order: Optional[VenueOrder] = None
active_entry_order: Optional[VenueOrder] = None
fsm_state: TradeStage = TradeStage.IDLE
close_reason: str = ""
last_event_time: Optional[datetime] = None
seen_event_ids: Tuple[str, ...] = ()
metadata: Dict[str, Any] = field(default_factory=dict)
def is_free(self) -> bool:
return self.fsm_state in {TradeStage.IDLE, TradeStage.CLOSED} and float(self.size or 0.0) <= 0.0 and not self.active_entry_order and not self.active_exit_order
def is_open(self) -> bool:
return self.fsm_state in {
TradeStage.ENTRY_WORKING,
TradeStage.POSITION_OPENED,
TradeStage.POSITION_OPEN,
TradeStage.EXIT_WORKING,
} and not self.closed
def mark_price(self, price: float) -> None:
if price is None or price != price or price <= 0:
return
self.entry_price = self.entry_price or price
if self.entry_price <= 0 or self.size <= 0:
self.unrealized_pnl = 0.0
return
delta = (price - self.entry_price) / self.entry_price
if self.side == TradeSide.SHORT:
delta = -delta
self.unrealized_pnl = delta * self.size * self.entry_price * self.leverage
def next_exit_ratio(self) -> float:
if self.active_leg_index < len(self.exit_leg_ratios):
ratio = float(self.exit_leg_ratios[self.active_leg_index])
return max(0.0, min(1.0, ratio))
return 1.0
def consume_exit_leg(self) -> float:
ratio = self.next_exit_ratio()
self.active_leg_index = min(self.active_leg_index + 1, max(len(self.exit_leg_ratios), 1))
return ratio
def remaining_size(self) -> float:
return max(0.0, float(self.size))
def attach_entry_order(self, order: VenueOrder) -> None:
self.active_entry_order = order
def attach_exit_order(self, order: VenueOrder) -> None:
self.active_exit_order = order
def to_dict(self) -> Dict[str, Any]:
def _order_dict(order: Optional[VenueOrder]) -> Optional[Dict[str, Any]]:
if order is None:
return None
return {
"internal_trade_id": order.internal_trade_id,
"venue_order_id": order.venue_order_id,
"venue_client_id": order.venue_client_id,
"side": order.side.value,
"intended_size": float(order.intended_size or 0.0),
"filled_size": float(order.filled_size or 0.0),
"average_fill_price": float(order.average_fill_price or 0.0),
"status": order.status.value,
"metadata": dict(order.metadata),
}
return {
"slot_id": self.slot_id,
"trade_id": self.trade_id,
"asset": self.asset,
"side": self.side.value,
"entry_price": float(self.entry_price or 0.0),
"size": float(self.size or 0.0),
"initial_size": float(self.initial_size or 0.0),
"leverage": float(self.leverage or 0.0),
"entry_time": self.entry_time.isoformat() if hasattr(self.entry_time, "isoformat") else None,
"unrealized_pnl": float(self.unrealized_pnl or 0.0),
"realized_pnl": float(self.realized_pnl or 0.0),
"closed": bool(self.closed),
"exit_leg_ratios": [float(r) for r in self.exit_leg_ratios],
"active_leg_index": int(self.active_leg_index or 0),
"active_exit_order": _order_dict(self.active_exit_order),
"active_entry_order": _order_dict(self.active_entry_order),
"fsm_state": self.fsm_state.value,
"close_reason": self.close_reason,
"last_event_time": self.last_event_time.isoformat() if hasattr(self.last_event_time, "isoformat") else None,
"seen_event_ids": list(self.seen_event_ids),
"metadata": dict(self.metadata),
}
@dataclass(frozen=True)
class KernelIntent:
"""Command emitted by the algo and written to the hot-path intent region."""
timestamp: datetime
intent_id: str
trade_id: str
slot_id: int
asset: str
side: TradeSide
action: KernelCommandType
reference_price: float
target_size: float
leverage: float
exit_leg_ratios: Tuple[float, ...] = (1.0,)
reason: str = ""
metadata: Dict[str, Any] = field(default_factory=dict)
stage: TradeStage = TradeStage.INTENT_CREATED
order_type: str = "MARKET"
limit_price: float = 0.0
@dataclass(frozen=True)
class VenueEvent:
"""Normalized venue truth mapped into DITAv2 semantics."""
timestamp: datetime
event_id: str
trade_id: str
slot_id: int
kind: KernelEventKind
status: VenueEventStatus
venue_order_id: str = ""
venue_client_id: str = ""
side: TradeSide = TradeSide.FLAT
asset: str = ""
price: float = 0.0 # avg fill price
size: float = 0.0
filled_size: float = 0.0
remaining_size: float = 0.0
reason: str = ""
raw_payload: Dict[str, Any] = field(default_factory=dict)
metadata: Dict[str, Any] = field(default_factory=dict)
# ── Fee / friction fields ──────────────────────────────────────────────
# fee: exchange commission for this fill event.
# Positive = cost; negative = rebate (maker on some venues).
# Starts as ESTIMATED_TAKER from the REST ACK path (BingX ACK does not
# include commission — we estimate from fill_size × fill_price × rate).
# Updated to WS_SETTLED when the FILL_SETTLED event arrives from the
# account stream with the actual commission field "n".
fee: float = 0.0
fee_asset: str = "" # e.g. "USDT"
# fee_source provenance codes:
# "" — fee unknown (e.g. CANCEL_ACK, ORDER_ACK events)
# "ESTIMATED_TAKER" — REST path, MARKET order; estimated at taker rate
# "ESTIMATED_MAKER" — REST path, LIMIT order that may rest; estimated at maker rate
# "WS_SETTLED" — actual fee from WS ORDER_TRADE_UPDATE field "n"
# "REST_SETTLED" — actual fee from REST fill history (allFillOrders)
fee_source: str = ""
is_maker: bool = False # True when LIMIT order rested and filled as maker
# exchange_ts: exchange-assigned fill timestamp (ms epoch).
# 0 when not available (REST ACK path — use VenueEvent.timestamp as fallback).
# Non-zero from WS ORDER_TRADE_UPDATE (field "T" or "t").
exchange_ts: int = 0
# slippage_bps: signed fill-quality metric.
# For taker fills: (fill_price - mark_at_submit) / mark_at_submit × 10_000
# positive = worse than mark (usual for taker on moving markets)
# negative = better than mark (rare — mark moved in our favour between submit and fill)
# For maker fills: fill_price is better than mid by design; slippage is typically
# negative (price improvement vs what a taker would have paid).
# 0.0 when mark_at_submit is unavailable.
slippage_bps: float = 0.0
mark_at_submit: float = 0.0 # mark/mid price captured just before submit_intent POST
@dataclass(frozen=True)
class KernelTransition:
"""Durable kernel transition used for debug journaling."""
timestamp: datetime
trade_id: str
slot_id: int
prev_state: TradeStage
next_state: TradeStage
trigger: str
intent_id: str = ""
event_id: str = ""
control_mode: str = ""
control_verbosity: str = ""
details: Dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class KernelOutcome:
"""Result of applying a command or venue event."""
accepted: bool
slot_id: int
trade_id: str
state: TradeStage
diagnostic_code: KernelDiagnosticCode = KernelDiagnosticCode.OK
severity: KernelSeverity = KernelSeverity.INFO
transitions: Tuple[KernelTransition, ...] = ()
emitted_events: Tuple[VenueEvent, ...] = ()
details: Dict[str, Any] = field(default_factory=dict)

View File

@@ -0,0 +1,390 @@
"""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}"