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:
@@ -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
|
||||
|
||||
362
prod/clean_arch/dita_v2/contracts.py
Normal file
362
prod/clean_arch/dita_v2/contracts.py
Normal 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)
|
||||
390
prod/clean_arch/dita_v2/test_leverage_cache.py
Normal file
390
prod/clean_arch/dita_v2/test_leverage_cache.py
Normal 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}"
|
||||
Reference in New Issue
Block a user