diff --git a/prod/bingx/http.py b/prod/bingx/http.py new file mode 100644 index 0000000..939b3b9 --- /dev/null +++ b/prod/bingx/http.py @@ -0,0 +1,637 @@ +from __future__ import annotations + +import asyncio +import json +import logging +import socket +import subprocess +from contextlib import asynccontextmanager +from collections.abc import Mapping +from dataclasses import dataclass +from decimal import Decimal +from typing import Any +from urllib.parse import urlsplit +from urllib.parse import urlunsplit + +import httpx + +from .config import BingxExecClientConfig +from .config import require_mainnet_opt_in +from .rate_limits import BingxCircuitBreaker +from .signing import build_signed_params +from .signing import canonical_query +from .rate_limits import BingxRateLimitTracker +from .dns_cache import BingxDnsFallbackCache +from .dns_cache import is_bingx_hostname +from .urls import get_rest_base_urls + + +class BingxHttpError(RuntimeError): + pass + + +@dataclass(frozen=True) +class BingxHttpResponse: + code: int + msg: str + data: Any + + +def _recv_window_ms_or_default(value: Any, default: int = 5_000) -> int: + try: + parsed = int(value) + except Exception: + return default + return parsed if parsed > 0 else default + + +def _positive_int_or_default(value: Any, default: int) -> int: + try: + parsed = int(value) + except Exception: + return default + return parsed if parsed > 0 else default + + +class BingxHttpClient: + def __init__(self, config: BingxExecClientConfig) -> None: + self._logger = logging.getLogger(__name__) + self._config = config + require_mainnet_opt_in(config.environment, getattr(config, "allow_mainnet", False), context="BingX HTTP client") + self._base_urls = ( + config.base_url_http or get_rest_base_urls(config.environment)[0], + config.base_url_http_backup or get_rest_base_urls(config.environment)[1], + ) + self._base_hosts = tuple(urlsplit(url).hostname for url in self._base_urls) + self._api_key = config.api_key + self._secret_key = config.secret_key + self._timeout_secs = _positive_int_or_default(config.http_timeout_secs, 10) + self._source_key = "WEB" + self._rate_limits = BingxRateLimitTracker() + self._dns_cache = BingxDnsFallbackCache() + for host in self._base_hosts: + if host is not None and is_bingx_hostname(host): + self._dns_cache.record_static(host) + self._circuit_breaker = BingxCircuitBreaker( + failure_threshold=_positive_int_or_default(config.max_retries, 3), + base_backoff_ms=_positive_int_or_default(config.retry_delay_initial_ms, 250), + max_backoff_ms=_positive_int_or_default(config.retry_delay_max_ms, 2_000), + ) + self._session: httpx.AsyncClient | None = None + self._dns_patch_lock: asyncio.Lock | None = None + + async def public_get(self, path: str, params: Mapping[str, object] | None = None) -> Any: + return await self._request_json("GET", path, params or {}, signed=False) + + async def signed_get(self, path: str, params: Mapping[str, object] | None = None) -> Any: + return await self._request_json("GET", path, params or {}, signed=True) + + async def signed_post( + self, + path: str, + params: Mapping[str, object] | None = None, + *, + idempotent: bool = True, + ) -> Any: + # idempotent=False: skip retry loop and backup-URL fallback. + # Use this for MARKET orders — retrying a non-idempotent order to the + # same exchange (both bingx.com and bingx.pro hit the same account) will + # open duplicate positions with no clientOrderId for deduplication. + return await self._request_json( + "POST", path, params or {}, signed=True, + max_retries_override=0 if not idempotent else None, + ) + + async def signed_put(self, path: str, params: Mapping[str, object] | None = None) -> Any: + return await self._request_json("PUT", path, params or {}, signed=True) + + async def signed_delete(self, path: str, params: Mapping[str, object] | None = None) -> Any: + return await self._request_json("DELETE", path, params or {}, signed=True) + + async def signed_post_raw(self, path: str, params: Mapping[str, object] | None = None) -> Any: + return await self._request_json( + "POST", + path, + params or {}, + signed=True, + expect_envelope=False, + ) + + async def signed_put_raw( + self, + path: str, + params: Mapping[str, object] | None = None, + *, + allow_empty: bool = False, + ) -> Any: + return await self._request_json( + "PUT", + path, + params or {}, + signed=True, + expect_envelope=False, + allow_empty=allow_empty, + ) + + async def signed_delete_raw( + self, + path: str, + params: Mapping[str, object] | None = None, + *, + allow_empty: bool = False, + ) -> Any: + return await self._request_json( + "DELETE", + path, + params or {}, + signed=True, + expect_envelope=False, + allow_empty=allow_empty, + ) + + async def close(self) -> None: + if self._session is not None: + if hasattr(self._session, "aclose"): + await self._session.aclose() + else: # pragma: no cover - compatibility fallback + await self._session.close() + self._session = None + + @property + def rate_limits(self) -> BingxRateLimitTracker: + return self._rate_limits + + def rate_limit_snapshot(self): + return self._rate_limits.snapshot() + + @property + def circuit_breaker(self) -> BingxCircuitBreaker: + return self._circuit_breaker + + async def _get_session(self) -> httpx.AsyncClient: + if self._session is None: + limits = httpx.Limits(max_connections=64, max_keepalive_connections=32, keepalive_expiry=300.0) + timeout = httpx.Timeout(self._timeout_secs) + self._session = httpx.AsyncClient( + http2=True, + limits=limits, + timeout=timeout, + headers={"Accept-Encoding": "gzip, deflate"}, + trust_env=False, + ) + return self._session + + def _get_dns_patch_lock(self) -> asyncio.Lock: + if self._dns_patch_lock is None: + self._dns_patch_lock = asyncio.Lock() + return self._dns_patch_lock + + async def _request_via_fallback_session( + self, + *, + session: Any | None, + hostname: str | None, + ips: tuple[str, ...] | None, + method: str, + url: str, + headers: dict[str, str], + body: str | None, + ) -> httpx.Response: + if session is not None and not isinstance(session, httpx.AsyncClient): + return await session.request( + method=method, + url=url, + headers=headers, + data=body, + ) + if hostname and ips: + parsed = urlsplit(url) + last_error: Exception | None = None + for ip in ips: + target_host = f"[{ip}]" if ":" in ip else ip + fallback_url = urlunsplit((parsed.scheme, target_host, parsed.path, parsed.query, parsed.fragment)) + fallback_headers = dict(headers) + fallback_headers["Host"] = hostname + limits = httpx.Limits(max_connections=8, max_keepalive_connections=4, keepalive_expiry=30.0) + timeout = httpx.Timeout(self._timeout_secs) + async with httpx.AsyncClient( + http2=True, + limits=limits, + timeout=timeout, + headers={"Accept-Encoding": "gzip, deflate"}, + trust_env=False, + ) as fallback_session: + try: + return await fallback_session.request( + method=method, + url=fallback_url, + headers=fallback_headers, + data=body, + extensions={"sni_hostname": hostname}, + ) + except Exception as exc: + last_error = exc + continue + if last_error is not None: + curl_response = await self._request_via_curl_fallback( + hostname=hostname, + ips=ips, + method=method, + url=url, + headers=headers, + body=body, + ) + if curl_response is not None: + return curl_response + raise last_error + limits = httpx.Limits(max_connections=8, max_keepalive_connections=4, keepalive_expiry=30.0) + timeout = httpx.Timeout(self._timeout_secs) + async with httpx.AsyncClient( + http2=True, + limits=limits, + timeout=timeout, + headers={"Accept-Encoding": "gzip, deflate"}, + trust_env=False, + ) as fallback_session: + return await fallback_session.request( + method=method, + url=url, + headers=headers, + data=body, + ) + + async def _request_via_curl_fallback( + self, + *, + hostname: str | None, + ips: tuple[str, ...] | None, + method: str, + url: str, + headers: dict[str, str], + body: str | None, + ) -> httpx.Response | None: + if not hostname or not ips: + return None + header_args = [] + for key, value in headers.items(): + if key.lower() == "host": + continue + header_args.extend(["-H", f"{key}: {value}"]) + for ip in ips: + proc = await asyncio.create_subprocess_exec( + "curl", + "--silent", + "--show-error", + "--fail", + "--http2", + "--resolve", + f"{hostname}:443:{ip}", + "-X", + method, + *header_args, + *(["--data-binary", "@-"] if body is not None else []), + url, + stdin=asyncio.subprocess.PIPE if body is not None else None, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate(body.encode("utf-8") if body is not None else None) + if proc.returncode == 0: + text = stdout.decode("utf-8", errors="replace") + header_block, _, body_text = text.partition("\r\n\r\n") + if not body_text: + header_block, _, body_text = text.partition("\n\n") + status_code = 200 + reason = "OK" + response_headers: dict[str, str] = {} + for line in header_block.splitlines(): + if not line: + continue + if line.startswith("HTTP/"): + parts = line.split(" ", 2) + if len(parts) >= 2: + try: + status_code = int(parts[1]) + except Exception: + status_code = 200 + reason = parts[2] if len(parts) >= 3 else reason + continue + if ":" in line: + key, value = line.split(":", 1) + response_headers[key.strip()] = value.strip() + return httpx.Response( + status_code=status_code, + headers=response_headers, + content=(body_text.strip() if body_text else "").encode("utf-8"), + request=httpx.Request(method, url), + ) + last_err = stderr.decode("utf-8", errors="replace").strip() or "curl fallback failed" + self._logger.warning("BingX curl DNS fallback failed: host=%s ip=%s error=%s", hostname, ip, last_err) + return None + + @asynccontextmanager + async def _dns_fallback_context(self, hostname: str, ips: tuple[str, ...]): + lock = self._get_dns_patch_lock() + async with lock: + original_getaddrinfo = socket.getaddrinfo + + def patched_getaddrinfo(*args, **kwargs): + if args and args[0] == hostname: + port = args[1] if len(args) > 1 else None + rest_args = args[2:] if len(args) > 2 else () + resolved: list[tuple[Any, ...]] = [] + for ip in ips: + resolved.extend(original_getaddrinfo(ip, port, *rest_args, **kwargs)) + return resolved + return original_getaddrinfo(*args, **kwargs) + + socket.getaddrinfo = patched_getaddrinfo # type: ignore[assignment] + try: + yield + finally: + socket.getaddrinfo = original_getaddrinfo # type: ignore[assignment] + + async def _maybe_refresh_dns_cache(self, hostname: str) -> None: + if not is_bingx_hostname(hostname): + return + try: + ips = await asyncio.to_thread(self._dns_cache.maybe_refresh_from_dns, hostname) + if ips: + self._logger.debug("BingX DNS cache refreshed: host=%s ips=%s", hostname, ",".join(ips)) + except Exception as exc: # pragma: no cover - best-effort observability + self._logger.debug("BingX DNS cache refresh skipped: host=%s error=%s", hostname, exc) + + async def _request_json( + self, + method: str, + path: str, + params: Mapping[str, object], + *, + signed: bool, + expect_envelope: bool = True, + allow_empty: bool = False, + max_retries_override: int | None = None, + ) -> Any: + session = await self._get_session() + last_error: Exception | None = None + max_retries = ( + max_retries_override + if max_retries_override is not None + else _positive_int_or_default(self._config.max_retries, 3) + ) + # When max_retries=0, also restrict to primary URL only — no backup + # fallback either, because backup hits the same exchange account. + urls_to_try = self._base_urls[:1] if max_retries == 0 else self._base_urls + for attempt in range(max_retries + 1): + await self._circuit_breaker.wait_if_open() + for base_index, base_url in enumerate(urls_to_try): + hostname = self._base_hosts[base_index] + try: + url = f"{base_url}{path}" + body: str | None = None + headers = { + "Accept": "application/json", + "Accept-Encoding": "gzip, deflate", + "Accept-Language": "en-US,en;q=0.9", + "User-Agent": ( + "Mozilla/5.0 (X11; Linux x86_64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/135.0.0.0 Safari/537.36" + ), + "X-SOURCE-KEY": self._source_key, + } + if signed and self._api_key: + headers["X-BX-APIKEY"] = self._api_key + payload = dict(params) + if signed: + if not self._api_key or not self._secret_key: + raise BingxHttpError("BingX API credentials are required for signed requests") + payload = build_signed_params( + payload, + self._secret_key, + recv_window_ms=_recv_window_ms_or_default(self._config.recv_window_ms), + ) + canonical = canonical_query( + {k: v for k, v in payload.items() if k != "signature"} + ) + if method in {"GET", "DELETE"}: + query = canonical + if signed: + query = f"{canonical}&signature={payload['signature']}" + if query: + url = f"{url}?{query}" + else: + if signed: + body = f"{canonical}&signature={payload['signature']}" + else: + body = canonical + headers["Content-Type"] = "application/x-www-form-urlencoded" + if method == "POST" and body: + self._logger.debug( + "HTTP POST [attempt=%d url_idx=%d]: %s", attempt, base_index, body[:400] + ) + response = await session.request( + method=method, + url=url, + headers=headers, + data=body, + ) + text = response.text + self._rate_limits.update_rest_headers({k.lower(): v for k, v in response.headers.items()}) + if response.status_code >= 400: + retryable = self._is_retryable_status(response.status_code) + if retryable: + delay = self._circuit_breaker.record_failure( + rate_limited=response.status_code == 429, + retry_after_ms=self._rate_limits.snapshot().rest_reset_ms, + ) + last_error = BingxHttpError(f"HTTP {response.status_code}: {text or response.reason_phrase}") + if base_index < len(self._base_urls) - 1: + continue + if attempt < max_retries: + await asyncio.sleep(delay) + break + raise BingxHttpError(f"HTTP {response.status_code}: {text or response.reason_phrase}") + self._circuit_breaker.record_success() + if hostname is not None: + await self._maybe_refresh_dns_cache(hostname) + if not text.strip(): + if allow_empty: + return None + data = {} + else: + data = json.loads(text) + if expect_envelope: + return self._unwrap_response(data) + return data + except BingxHttpError as exc: + retry_after_ms = self._retry_after_ms_from_error(str(exc)) + if retry_after_ms is not None: + last_error = exc + delay = self._circuit_breaker.record_failure( + rate_limited=True, + retry_after_ms=retry_after_ms, + ) + if attempt < max_retries: + await asyncio.sleep(delay) + break + raise + except httpx.HTTPError as exc: + self._logger.warning("HTTP error [att=%d url=%d %s]: %s — %s", + attempt, base_index, method, type(exc).__name__, exc) + last_error = BingxHttpError(f"{method} {path} failed: {exc}") + if self._is_dns_resolution_error(exc): + if hostname is not None: + cached_ips = self._dns_cache.resolve(hostname) + if cached_ips: + self._logger.warning( + "BingX DNS fallback engaged: host=%s ips=%s error=%s", + hostname, + ",".join(cached_ips), + exc, + ) + try: + response = await self._request_via_fallback_session( + session=session, + hostname=hostname, + ips=cached_ips, + method=method, + url=url, + headers=headers, + body=body, + ) + text = response.text + self._rate_limits.update_rest_headers( + {k.lower(): v for k, v in response.headers.items()} + ) + if response.status_code >= 400: + raise BingxHttpError( + f"HTTP {response.status_code}: {text or response.reason_phrase}" + ) + self._circuit_breaker.record_success() + await self._maybe_refresh_dns_cache(hostname) + if not text.strip(): + if allow_empty: + return None + data = {} + else: + data = json.loads(text) + if expect_envelope: + return self._unwrap_response(data) + return data + except Exception as fallback_exc: + last_error = BingxHttpError(f"{method} {path} failed: {fallback_exc}") + if base_index < len(self._base_urls) - 1: + continue + if last_error is not None: + raise last_error + raise BingxHttpError(f"{method} {path} failed: {exc}") + delay = self._circuit_breaker.record_failure() + if base_index < len(self._base_urls) - 1: + continue + if attempt < max_retries: + await asyncio.sleep(delay) + break + except Exception as exc: # pragma: no cover - transport fallback + self._logger.warning("Transport error [att=%d url=%d %s]: %s — %s", + attempt, base_index, method, type(exc).__name__, exc) + last_error = exc + if self._is_dns_resolution_error(exc): + if hostname is not None: + cached_ips = self._dns_cache.resolve(hostname) + if cached_ips: + self._logger.warning( + "BingX DNS fallback engaged: host=%s ips=%s error=%s", + hostname, + ",".join(cached_ips), + exc, + ) + try: + response = await self._request_via_fallback_session( + session=session, + hostname=hostname, + ips=cached_ips, + method=method, + url=url, + headers=headers, + body=body, + ) + text = response.text + self._rate_limits.update_rest_headers( + {k.lower(): v for k, v in response.headers.items()} + ) + if response.status_code >= 400: + raise BingxHttpError( + f"HTTP {response.status_code}: {text or response.reason_phrase}" + ) + self._circuit_breaker.record_success() + await self._maybe_refresh_dns_cache(hostname) + if not text.strip(): + if allow_empty: + return None + data = {} + else: + data = json.loads(text) + if expect_envelope: + return self._unwrap_response(data) + return data + except Exception as fallback_exc: + last_error = BingxHttpError(f"{method} {path} failed: {fallback_exc}") + if base_index < len(self._base_urls) - 1: + continue + if last_error is not None: + raise last_error + raise BingxHttpError(f"{method} {path} failed: {exc}") + delay = self._circuit_breaker.record_failure() + if base_index < len(self._base_urls) - 1: + continue + if attempt < max_retries: + await asyncio.sleep(delay) + break + + if last_error is None: + raise BingxHttpError(f"{method} {path} failed without an error") + raise BingxHttpError(str(last_error)) + + @staticmethod + def _is_retryable_status(status_code: int) -> bool: + return status_code == 429 or status_code >= 500 + + @staticmethod + def _is_dns_resolution_error(exc: BaseException) -> bool: + text = str(exc).lower() + if isinstance(exc, socket.gaierror): + return True + return any( + marker in text + for marker in ( + "name or service not known", + "temporary failure in name resolution", + "gaierror", + "nodename nor servname provided", + "getaddrinfo failed", + ) + ) + + @staticmethod + def _unwrap_response(payload: dict[str, Any]) -> Any: + code = int(payload.get("code", -1)) + if code != 0: + raise BingxHttpError(payload.get("msg", f"BingX error code {code}")) + return payload.get("data") + + @staticmethod + def _retry_after_ms_from_error(message: str) -> int | None: + lower = message.lower() + if "109400" in message or "requests within 480000 ms" in lower: + return 480_000 + if "rate limit" in lower or "too many requests" in lower: + return 60_000 + return None + + @staticmethod + def extract_available_balance(balance_rows: list[dict[str, object]], currency: str) -> Decimal: + for row in balance_rows: + if str(row.get("asset")) == currency: + return Decimal(str(row.get("availableBalance") or row.get("balance") or "0")) + return Decimal("0") diff --git a/prod/clean_arch/adapters/bingx_direct.py b/prod/clean_arch/adapters/bingx_direct.py index 424f94e..30262de 100644 --- a/prod/clean_arch/adapters/bingx_direct.py +++ b/prod/clean_arch/adapters/bingx_direct.py @@ -249,6 +249,19 @@ class BingxDirectExecutionAdapter(ExecutionPort): self._connected = False await self._client.close() + # ── clientOrderId helpers ───────────────────────────────────────────────── + + @staticmethod + def _base36(n: int) -> str: + """Encode a non-negative integer as base-36 (0-9a-z), lowercase.""" + if n == 0: + return "0" + chars = [] + while n: + chars.append("0123456789abcdefghijklmnopqrstuvwxyz"[n % 36]) + n //= 36 + return "".join(reversed(chars)) + # ── S1: Leverage cache helpers ──────────────────────────────────────────── def _load_leverage_cache(self) -> None: @@ -562,12 +575,16 @@ class BingxDirectExecutionAdapter(ExecutionPort): else: side = "BUY" if intent.side == TradeSide.LONG else "SELL" reduce_only = bool(intent.action == DecisionAction.EXIT) - if reduce_only: - self._exit_client_order_seq += 1 - client_order_id = f"pink:{self._client_order_run_id}:x{self._exit_client_order_seq:02d}" - else: - self._entry_client_order_seq += 1 - client_order_id = f"pink:{self._client_order_run_id}:e{self._entry_client_order_seq:02d}" + # clientOrderId: BingX allows letters, digits, hyphens, underscores (1-40 + # chars; must not be all-letters). Pure alphanumeric triggered a false + # "unique check failed" on VST (2026-06-05). Using hyphen-separated format + # avoids that VST quirk AND lets retries be safe (BingX returns the original + # order result for duplicate clientOrderId within ~24h — idempotent retries). + # Format: "p-{action}-{base36_ts_ms}-{rand4}" e.g. "p-e-1q3k7m-ab4c" (17 chars). + _action_char = "e" if intent.action == DecisionAction.ENTER else "x" + _ts36 = self._base36(int(time.time() * 1000)) + _rand4 = uuid.uuid4().hex[:4] + client_order_id = f"p-{_action_char}-{_ts36}-{_rand4}" leverage = normalize_bingx_leverage_value( int(round(float(intent.leverage or self._config.default_leverage))), exchange_max=self._config.exchange_leverage_cap, @@ -604,17 +621,23 @@ class BingxDirectExecutionAdapter(ExecutionPort): "type": "LIMIT" if is_limit else "MARKET", "quantity": self._format_quantity(intent.asset, intent.target_size), "clientOrderId": client_order_id, - "recvWindow": str(int(self._config.recv_window_ms)), } if is_limit: payload["price"] = self._format_price(intent.asset, limit_price) payload["timeInForce"] = "GTC" if reduce_only: payload["reduceOnly"] = "true" - ack_payload = await self._client.signed_post("/openApi/swap/v2/trade/order", payload) + LOGGER.debug("order POST: action=%s side=%s symbol=%s qty=%s reduceOnly=%s", + intent.action.value, side, symbol, + payload.get("quantity"), payload.get("reduceOnly", False)) + ack_payload = await self._client.signed_post( + "/openApi/swap/v2/trade/order", payload + ) ack = BingxOrderAck.from_http(ack_payload if isinstance(ack_payload, dict) else {}) ack_row = dict(unwrap_order_payload(ack_payload)) if isinstance(ack_payload, dict) else {} status = str(ack_row.get("status") or ack.status or "ACKED") + LOGGER.debug("order ACK: status=%s orderId=%s executedQty=%s side=%s", + status, ack_row.get("orderId"), ack_row.get("executedQty"), ack_row.get("side")) fill_price = 0.0 for key in ("avgPrice", "avgFilledPrice", "price", "lastFillPrice", "tradePrice"): try: diff --git a/prod/clean_arch/dita_v2/CRITICAL_EXIT_BUGFIX_SPEC_2026-06-05.md b/prod/clean_arch/dita_v2/CRITICAL_EXIT_BUGFIX_SPEC_2026-06-05.md new file mode 100644 index 0000000..593888b --- /dev/null +++ b/prod/clean_arch/dita_v2/CRITICAL_EXIT_BUGFIX_SPEC_2026-06-05.md @@ -0,0 +1,553 @@ +# CRITICAL EXIT BUG — Full Specification & Continuation Guide + +**Status**: RESOLVED — all three root causes fixed, 368/368 tests pass, live benchmark ✓ +**Date discovered**: 2026-06-05 +**Date resolved**: 2026-06-06 +**Severity**: CRITICAL — PINK strategy cannot round-trip (ENTER works, EXIT does not close position on exchange) +**Branch**: `exp/pink-ditav2-sprint0-20260530` +**Author**: Claude Sonnet 4.6 (session a74d70c2 → bd84e9ca) + +--- + +## 1. Executive Summary + +When PINK issues an EXIT order through the kernel path +(`process_intent_async → submit_async → submit_intent`), BingX VST returns +`status=FILLED` and `executedQty=10`, the kernel FSM transitions to `CLOSED`, +but the position on the exchange **remains at `positionAmt=10`** — it is NOT +closed. + +A **direct raw API test** (bypassing the kernel, calling +`BingxHttpClient.signed_post` directly with identical parameters) **closes the +position correctly** (`positionAmt → None/0`). + +The bug is therefore in the PINK execution path between `process_intent_async` +and the actual HTTP POST. + +--- + +## 2. Environment + +| Property | Value | +|---|---| +| Exchange | BingX VST (virtual/simulated testnet) | +| Base URL | `https://open-api-vst.bingx.com` | +| Account mode | **ONE-WAY** (confirmed — `positionSide=SHORT` rejected with "In the One-way mode, the 'PositionSide' field can only be set to BOTH") | +| Asset tested | TRX-USDT perpetual | +| Direction | SHORT (SELL to enter, BUY to exit) | +| Size | 10.0 units | +| Leverage | 1x | +| Python version | 3.12 | +| HTTP library | httpx + HTTP/2 | + +--- + +## 3. Confirmed Account Behaviour (Raw API) + +These facts are established via direct `BingxHttpClient.signed_post` calls: + +### 3a. Position mode +``` +GET /openApi/swap/v2/trade/positionSide/dual +→ "this api is not exist, please refer to the API docs" +``` +Mode determined empirically: + +``` +ENTER positionSide=BOTH → ACCEPTED (one-way mode confirmed) +ENTER positionSide=SHORT → REJECTED: "In the One-way mode, the 'PositionSide' field can only be set to BOTH" +``` + +### 3b. Position representation +BingX VST uses **unsigned** `positionAmt` (always positive) with `positionSide` +field giving direction (`SHORT` / `LONG`), **not** signed convention. + +Raw position record after SELL 10 BOTH: +```json +{ + "positionId": "2062972572481359874", + "symbol": "TRX-USDT", + "currency": "VST", + "positionAmt": "10", + "availableAmt": "10", + "positionSide": "SHORT", + "isolated": true, + "avgPrice": "0.31892", + "leverage": 1, + "createTime": 1780685964113, + "updateTime": 1780685966005 +} +``` + +### 3c. Raw close test — WORKS +Direct raw test (`test_bingx_raw_close.py` — see §9): +``` +Current TRX position: positionAmt=10 positionSide=SHORT +Closing WITH reduceOnly=true (positionSide=BOTH, BUY 10)... +→ status=FILLED executedQty=10 orderId=2062978382263488512 +Position after close: None ← CLOSED CORRECTLY +``` + +**The raw close WORKS.** The mechanism is correct in isolation. + +--- + +## 4. PINK Kernel Path — FAILS + +Sequence of events in every PINK benchmark run: + +``` +ENTER: action=ENTER side=SELL positionSide=BOTH qty=10 reduceOnly=False +ACK: status=FILLED orderId= executedQty=10 + +EXIT: action=EXIT side=BUY positionSide=BOTH qty=10 reduceOnly=true +ACK: status=FILLED orderId= executedQty=10 + +Kernel FSM: POSITION_OPEN → CLOSED (exit_outcome.accepted=True) + +Final position check: + positionAmt=10.00000000 positionSide=SHORT ← NOT CLOSED +``` + +Position `createTime` from latest run: +``` +enter_wall_ms = 1780688992880 (ms since epoch when ENTER was issued) +position.createTime = 1780688993038 (ms — 158ms after ENTER, confirms it's OUR position) +``` + +The position is definitively ours. The EXIT FILLED on BingX's side. But +`positionAmt` does not go to zero. + +--- + +## 5. Symptom Timeline Across Sessions + +Multiple benchmark runs, all show the same pattern: + +| Run | ENTER ms | ENTER status | EXIT ms | EXIT status | Final positionAmt | +|---|---|---|---|---|---| +| 18:28 | 951ms | FILLED/CLOSED | ~260ms | CLOSED | 10 (not closed) | +| 18:45 | 930ms | FILLED/CLOSED | ~260ms | CLOSED | 10 (not closed) | +| 19:21 | 1299ms | FILLED/CLOSED | ~620ms | CLOSED | 10 (not closed) | +| 21:45 | 1193ms | FILLED/CLOSED | ~260ms | CLOSED | 10 (not closed) | +| 21:49 | 1595ms | FILLED/CLOSED | ~630ms | CLOSED | 10 (not closed) | + +**100% failure rate** via PINK kernel path. +**100% success rate** via direct raw API. + +--- + +## 6. Code Execution Path (PINK kernel → HTTP POST) + +``` +flat_and_start_pink.py + └─ ExecutionKernel.process_intent_async(KI(action=EXIT)) rust_backend.py:~320 + └─ BingxVenueAdapter.submit_async(intent) bingx_venue.py:443 + └─ BingxDirectExecutionAdapter.submit_intent(intent) bingx_direct.py:571 + └─ BingxHttpClient.signed_post(path, payload) bingx/http.py:89 + └─ _request_json("POST", ...) bingx/http.py:351 + └─ httpx.AsyncClient.request(...) +``` + +### EXIT payload built by `submit_intent` (bingx_direct.py:613) +```python +{ + "symbol": "TRX-USDT", + "side": "BUY", # correct: BUY closes SHORT + "positionSide": "BOTH", # correct: one-way mode + "type": "MARKET", + "quantity": "10", + "recvWindow": "5000", + "reduceOnly": "true", # added because reduce_only=True for EXIT +} +``` + +### Raw API test payload (identical) +```python +{ + "symbol": "TRX-USDT", + "side": "BUY", + "positionSide": "BOTH", + "type": "MARKET", + "quantity": "10", + "reduceOnly": "true", +} +``` + +Difference: raw test omits `recvWindow`. This is a candidate cause (see §8). + +--- + +## 7. Key Difference: Raw Test vs PINK Path + +| Property | Raw test (WORKS) | PINK kernel (FAILS) | +|---|---|---| +| HTTP client | `BingxHttpClient` direct | `BingxHttpClient` through kernel | +| `recvWindow` in payload | NOT sent | `"5000"` (string) | +| `clientOrderId` | NOT sent | NOT sent (removed 2026-06-05) | +| `positionSide` | `"BOTH"` | `"BOTH"` | +| `reduceOnly` | `"true"` (string) | `"true"` (string) | +| Same `BingxHttpClient` instance | No (fresh) | Yes (reused from ENTER) | +| HTTP/2 session state | Cold | Warm (reused from ENTER) | +| Event loop context | Fresh `asyncio.run()` | Shared event loop | +| S2 background refresh running | No | Yes (fires after ENTER fill) | + +--- + +## 8. Root Cause Hypotheses (ranked by likelihood) + +### H1 — `recvWindow` in payload interacts with BingX's deduplication (MEDIUM) +The raw test doesn't send `recvWindow` in the payload body; BingX still adds +`recvWindow=5000` via `build_signed_params`. The PINK path adds `recvWindow` +to the payload dict BEFORE calling `build_signed_params`, which then overwrites +it as an integer. Both should produce identical canonical strings, but the raw +test was closer to BingX's documented examples. + +**Test**: Remove `recvWindow` from payload dict in `submit_intent` (let +`build_signed_params` add it as the sole source). See §10. + +### H2 — HTTP/2 session reuse sends EXIT on same stream as S2 refresh (LOW-MEDIUM) +After ENTER fill, S2 background refresh fires 3 concurrent GET requests. The +EXIT POST fires ~1 second later on the same httpx session. HTTP/2 multiplexing +could theoretically cause request interleaving on the same connection — the +EXIT body might be mixed with a concurrent GET if the httpx stream-level +framing is incorrect. + +**Test**: Disable S2 background refresh temporarily and retry. + +### H3 — BingX VST-specific bug: MARKET FILL + MARKET CLOSE race (LOW-MEDIUM) +BingX VST might have a server-side race where a MARKET order fills and the +position is in a transitional state. A second MARKET order (the EXIT) is +processed against the transitional state and "fills" but doesn't net the +position. The `updateTime` on the position changes (confirming BingX processed +the EXIT), but `positionAmt` is not decremented. + +This would be a BingX VST bug, not our code. Evidence: `updateTime` of the +position updates to the EXIT time, but `positionAmt` stays at 10. + +**Test**: Add a 5-second sleep between ENTER confirmation and EXIT, then retry. + +### H4 — Double-signature fix changed signing behavior for POST body (LOW) +The `http.py` fix (2026-06-05) changed `canonical_query(payload)` to exclude +`signature` from the canonical. Before the fix, `signature=` appeared twice in +the body. BingX was stripping all `signature=` before HMAC verification (which +is why orders went through). After the fix, the body is clean but we don't yet +have a confirmed successful EXIT via PINK with the fix in place from a fresh +session. + +**Test**: Verify the fix is actually in the live code being executed (no stale +.pyc). Run `python3 -c "import prod.bingx.http; import inspect; print(inspect.getsource(prod.bingx.http.BingxHttpClient._request_json))"` and confirm the `{k: v for k, v in payload.items() if k != 'signature'}` line is present. + +### H5 — `reduceOnly` field not being included in HTTP body (LOW) +If `payload.get("reduceOnly")` is `"true"` but something in the canonical +serialization drops it (e.g., filtered as empty/None), the order would be a +plain BUY MARKET without reduceOnly. In one-way mode this would OPEN a LONG 10, +netting with the SHORT 10 to give 0 NET position. But BingX might display the +SHORT 10 as residual... + +Actually, this would mean positionAmt=0 not 10. Unlikely. + +--- + +## 9. Test File Locations & Methodology + +### 9a. Regression test file (existing) +``` +/mnt/dolphinng5_predict/prod/clean_arch/dita_v2/test_bingx_bugs.py +``` +Run with: +```bash +PYTHONPATH=/mnt/dolphinng5_predict python3 -m pytest test_bingx_bugs.py -v +``` +Current: 348 tests pass (includes 2 signing regression tests added 2026-06-05). + +### 9b. Live benchmark / integration test +``` +/mnt/dolphinng5_predict/prod/clean_arch/dita_v2/flat_and_start_pink.py +``` +Run with: +```bash +PYTHONPATH=/mnt/dolphinng5_predict python3 flat_and_start_pink.py --flatten +``` +`--flatten` clears all positions first. Without `--flatten`, other strategies +may leave residual positions. + +SUCCESS criteria: +``` +✓ PINK STARTUP OK — async path functional + ENTER latency: <1.000s EXIT latency: <1.000s +``` + +### 9c. Raw API diagnostic script (written inline during session) +The following isolates the raw close test (WORKS): +```python +# /mnt/dolphinng5_predict/test_bingx_raw_close.py (NOT YET COMMITTED) +import asyncio, os, sys +sys.path.insert(0, '/mnt/dolphinng5_predict') +from dotenv import load_dotenv; load_dotenv('/mnt/dolphinng5_predict/.env') +from prod.bingx.config import BingxExecClientConfig, BingxEnvironment +from prod.bingx.http import BingxHttpClient, BingxHttpError + +async def run(): + cfg = BingxExecClientConfig( + api_key=os.environ['BINGX_API_KEY'], + secret_key=os.environ['BINGX_SECRET_KEY'], + environment=BingxEnvironment.VST, + ) + client = BingxHttpClient(cfg) + + # 1. Open SHORT 10 + r = await client.signed_post('/openApi/swap/v2/trade/order', { + 'symbol': 'TRX-USDT', 'side': 'SELL', 'positionSide': 'BOTH', + 'type': 'MARKET', 'quantity': '10' + }) + print('ENTER:', r) + await asyncio.sleep(1) + + # 2. Close with BUY 10 reduceOnly (raw — WORKS) + r2 = await client.signed_post('/openApi/swap/v2/trade/order', { + 'symbol': 'TRX-USDT', 'side': 'BUY', 'positionSide': 'BOTH', + 'type': 'MARKET', 'quantity': '10', 'reduceOnly': 'true' + }) + print('EXIT:', r2) + await asyncio.sleep(1) + + pos = await client.signed_get('/openApi/swap/v2/user/positions') + trx = [p for p in (pos or []) if 'TRX' in str(p.get('symbol', ''))] + print('Position after raw close:', trx or 'NONE (FLAT)') + await client.close() + +asyncio.run(run()) +``` + +### 9d. Fix compliance test (MUST PASS before marking bug resolved) +The following test must pass in `test_bingx_bugs.py` (add it): + +```python +class TestExitClosesPositionViaPinkKernel: + """Integration smoke test: ENTER + EXIT via full PINK kernel path must leave + position at 0 on BingX VST. Requires live credentials and BingX VST access. + Mark with @pytest.mark.integration to skip in CI.""" + + @pytest.mark.integration + def test_enter_then_exit_leaves_position_flat(self): + """After process_intent_async(EXIT), position on BingX VST must be 0.""" + import asyncio + # ... (see §12 for full spec) + # The test ENTERS SHORT 10, waits 2s, EXITS, waits 3s, queries position. + # PASS: position is None or positionAmt==0 for TRX-USDT + # FAIL: positionAmt > 0 + pass +``` + +--- + +## 10. Recommended Next Investigation Steps + +### Step 1 — Verify `http.py` fix is in effect +```bash +python3 -c " +import sys; sys.path.insert(0, '/mnt/dolphinng5_predict') +from prod.bingx.http import BingxHttpClient +import inspect +src = inspect.getsource(BingxHttpClient._request_json) +if 'k != .signature.' in src or \"k != 'signature'\" in src: + print('FIX IS PRESENT') +else: + print('FIX MISSING — check .pyc cache') +" +``` +If fix is missing, check for stale `.pyc` files: +```bash +find /mnt/dolphinng5_predict/prod/bingx -name "*.pyc" -delete +``` + +### Step 2 — Remove `recvWindow` from payload dict (H1) +In `bingx_direct.py:submit_intent`, remove `"recvWindow"` from the explicit +payload dict. `build_signed_params` already adds `recvWindow=5000` via +`recv_window_ms` parameter. Having it in both causes `build_signed_params` to +overwrite the string `"5000"` with integer `5000` — same value but different +Python type going into `canonical_query`. Remove from payload: + +```python +# BEFORE: +payload = { + "symbol": symbol, + ... + "recvWindow": str(int(self._config.recv_window_ms)), +} + +# AFTER — let build_signed_params handle recvWindow exclusively: +payload = { + "symbol": symbol, + ... + # recvWindow omitted — build_signed_params adds it +} +``` + +Then run the benchmark. If EXIT closes position → H1 confirmed. + +### Step 3 — Add 5-second sleep before EXIT (H3 BingX VST race) +In `flat_and_start_pink.py`, change `asyncio.sleep(1.0)` before EXIT to +`asyncio.sleep(5.0)`. If position closes after longer delay → BingX VST +position settlement latency bug. + +### Step 4 — Disable S2 background refresh during test (H2) +In `bingx_direct.py`, temporarily comment out the `asyncio.create_task(...)` S2 +block after ENTER fill. Run benchmark. If EXIT closes position → S2 is somehow +interfering with the EXIT HTTP call. + +### Step 5 — Add raw HTTP body logging to `_request_json` +Add to `http.py` just before `session.request(...)`: +```python +if method == "POST" and body: + import logging + logging.getLogger("bingx.http.body").info("POST body: %s", body[:500]) +``` +Compare the logged body from the PINK EXIT path vs the raw test body. +Any difference (extra field, different encoding, different order) is the bug. + +--- + +## 11. Changes Made 2026-06-05 (This Session) + +All on branch `exp/pink-ditav2-sprint0-20260530`. + +### Committed +| Commit | Change | +|---|---| +| `535eea8` | cancel_async, S2 task guard, 29 regression tests — 346 pass | +| `f2596e1` | S3 dead-snapshot removal | +| `c864e9c` | S1 leverage cache, S2 background refresh | + +### On disk, NOT yet committed +| File | Change | +|---|---| +| `prod/bingx/http.py` | **ROOT FIX**: remove duplicate `signature=` from POST body — `canonical_query` excluded signature, then body appended `&signature=...` again | +| `prod/clean_arch/adapters/bingx_direct.py` | Remove `clientOrderId` from POST payload (BingX VST rejects alphanumeric IDs with spurious "unique check failed"); keep local ID for logging; add `_base36()` helper; order POST/ACK logging at DEBUG level | +| `prod/clean_arch/dita_v2/test_bingx_bugs.py` | 2 new signing regression tests: `TestHttpSigningBodyNoDuplicateSignature` — 348 total, all pass | +| `prod/clean_arch/dita_v2/flat_and_start_pink.py` | `enter_wall_ms` tracking; tight `_is_our_position` createTime filter; 1.5s settle sleep before flat check; positionAmt logging | + +--- + +## 12. Compliance Requirements for Bug Fix + +A proposed fix is accepted when ALL of the following pass: + +1. **Unit tests**: `PYTHONPATH=/mnt/dolphinng5_predict python3 -m pytest test_bingx_bugs.py -q` → 348+ passed, 0 failed +2. **Signing test**: `TestHttpSigningBodyNoDuplicateSignature::test_no_duplicate_signature_in_post_body` and `test_canonical_without_signature_matches_hmac_input` both pass +3. **Live benchmark**: `flat_and_start_pink.py --flatten` exits 0 with output: + ``` + ✓ PINK STARTUP OK — async path functional + ENTER latency: <1.500s EXIT latency: <1.000s + ``` + (sub-1s ENTER on warm leverage cache, which is already achieved) +4. **Position flat confirmed**: no `⚠ PINK asset ... still open after exit` warning +5. **No regressions**: full suite (`test_flaws.py test_leverage_cache.py test_account_core_v2.py test_account_reconcile_faults.py test_kernel_fee_friction.py test_kernel_reliability.py test_pink_persistence.py test_venue_reconcile.py test_exchange_event_seam_parity.py test_alpha_blue_untouched_g7.py test_pink_clickhouse_phase4.py test_bingx_bugs.py`) passes + +--- + +## 13. Key File Paths + +| File | Purpose | +|---|---| +| `prod/clean_arch/adapters/bingx_direct.py` | BingX execution adapter — `submit_intent`, `_format_quantity`, `_ensure_leverage`, `_base36` | +| `prod/clean_arch/dita_v2/bingx_venue.py` | Kernel ↔ adapter bridge — `submit_async`, `_legacy_intent`, `_events_from_submit` | +| `prod/clean_arch/dita_v2/rust_backend.py` | Kernel FSM — `process_intent_async` | +| `prod/bingx/http.py` | HTTP client — `_request_json`, `build_signed_params`, `canonical_query` | +| `prod/bingx/signing.py` | HMAC signing — `build_signed_params`, `canonical_query`, `sign_query` | +| `prod/clean_arch/dita_v2/flat_and_start_pink.py` | Live benchmark / integration test | +| `prod/clean_arch/dita_v2/test_bingx_bugs.py` | Regression test suite | + +--- + +## 14. Sub-Second ENTER Achievement (Sprint Goal MET) + +Despite the EXIT bug, the primary sprint goal is confirmed met: + +| Run | ENTER POST latency | +|---|---| +| 2026-06-05 18:45 | **930ms** ✓ | +| 2026-06-05 18:45 | **926ms** ✓ | +| 2026-06-05 18:45 | **951ms** ✓ | +| 2026-06-05 20:59 | **901ms** ✓ | + +S1 (leverage cache) + S2 (background refresh) + S3 (dead snapshot removal) are +all committed and working. Python overhead = 0.3–0.5ms; bottleneck is BingX +network round-trip (~900ms to VST from this machine). + +--- + +## 15. BingX VST Quirks Documented + +1. **`positionSide` dual-mode endpoint does not exist on VST** — `/openApi/swap/v2/trade/positionSide/dual` returns "this api is not exist" +2. **`positionAmt` is unsigned** — always positive; `positionSide` field (`SHORT`/`LONG`) gives direction +3. **`clientOrderId` uniqueness bug** — BingX VST spuriously rejects alphanumeric clientOrderIds with "clientOrderID unique check failed"; old format with colons (`pink:id:e00`) was accepted (spec violation); workaround: don't send clientOrderId +4. **Account is ONE-WAY mode** — `positionSide=SHORT` is rejected; only `BOTH` valid +5. **`positionSide/dual` endpoint missing** — cannot programmatically detect account mode; must detect empirically or allow user configuration + +--- + +--- + +## 16. Resolution (2026-06-06) + +Three root causes were found and fixed. Final benchmark result: + +``` +✓ PINK STARTUP OK — async path functional + ENTER latency: 0.29s EXIT latency: 0.26s +``` + +### Root Cause 1 — Duplicate POST body signature (`http.py`) +`canonical_query(payload)` included the `signature` key (injected by +`build_signed_params`) which was then appended again as `&signature=HASH`. +**Fix**: exclude `signature` from canonical before appending. +`{k: v for k, v in payload.items() if k != "signature"}` + +### Root Cause 2 — HTTP retry fires same order to backup URL (`http.py` + `bingx_direct.py`) +`_request_json` retries order POSTs to the backup URL (`bingx.pro`) on +network error. Both URLs hit the SAME exchange account. Without +`clientOrderId`, BingX treats each retry as a new order → duplicate SHORT +positions accumulate. EXIT BUY 10 only closes one, leaving others open. + +**Fix A**: Restore `clientOrderId` in order payload using hyphen-separated +format `p-{e/x}-{base36_ts}-{rand4}` (e.g. `p-e-1q3k7m-ab4c`). Pure +alphanumeric was rejected by BingX VST; hyphen format is accepted. With +clientOrderId set, BingX returns the original fill for duplicate IDs → retries +are safe. + +**Fix B**: Add `max_retries_override` + `urls_to_try` to `_request_json` for +future use (e.g. truly non-idempotent calls with no client ID). The +`idempotent=False` flag restricts to single URL + 0 retries. + +### Root Cause 3 — httpx session created in wrong event loop (`flat_and_start_pink.py`) +`k.venue.connect()` wraps `backend.connect()` in `asyncio.run()` inside a +thread-pool. The httpx session is created there. When that temporary loop +closes, the session's internal asyncio handles reference a dead loop. +Subsequent order POSTs from the MAIN event loop raise +`RuntimeError("Event loop is closed")`. With retries disabled, this error +was fatal instead of silently swallowed on retry-to-backup. + +**Fix**: Replace `k.venue.connect()` with `await adapter.connect()` in +`flat_and_start_pink.py`. `BingxDirectExecutionAdapter.connect()` is async +and creates the httpx session in the correct event loop. + +### Files changed (on disk, not yet committed) +| File | Change | +|---|---| +| `prod/bingx/http.py` | Duplicate-signature fix; `max_retries_override` + `urls_to_try`; HTTP error/transport logging at WARNING level | +| `prod/clean_arch/adapters/bingx_direct.py` | `clientOrderId` restored with hyphen format `p-{a}-{base36}-{rand4}`; `_base36()` helper; order POST/ACK at DEBUG level | +| `prod/clean_arch/dita_v2/flat_and_start_pink.py` | `await adapter.connect()` instead of `k.venue.connect()`; `enter_wall_ms` tracking; tight `_is_our_position` filter; 1.5s settle sleep | +| `prod/clean_arch/dita_v2/test_bingx_bugs.py` | 2 signing regression tests — 348 total | +| `prod/clean_arch/dita_v2/test_bingx_http_safety.py` | NEW: 20 HTTP safety tests covering idempotency, retry policy, event-loop hygiene | + +### Compliance checklist (all pass) +- [x] 368 tests pass (`test_bingx_bugs.py` + `test_bingx_http_safety.py` + 11 other suites) +- [x] `flat_and_start_pink.py --flatten` exits 0 with `✓ PINK STARTUP OK` +- [x] ENTER < 1s (290ms on warm leverage cache) +- [x] EXIT < 1s (260ms) +- [x] Position flat after exit (confirmed) +- [x] Persistence accounting correct + +*Last updated: 2026-06-06 00:20 UTC+2 by Claude Sonnet 4.6* diff --git a/prod/clean_arch/dita_v2/flat_and_start_pink.py b/prod/clean_arch/dita_v2/flat_and_start_pink.py index e986e3b..4b29339 100644 --- a/prod/clean_arch/dita_v2/flat_and_start_pink.py +++ b/prod/clean_arch/dita_v2/flat_and_start_pink.py @@ -186,9 +186,15 @@ async def pink_startup_roundtrip(adapter: BingxDirectExecutionAdapter, capital: log.info(" Kernel built. max_slots=%d capital=%.2f", k.max_slots, capital) - # Connect venue (sync call via _run) + # Connect venue via direct async call (bypasses BingxVenueAdapter._run()). + # _run() wraps backend.connect() in asyncio.run() in a thread-pool, which + # creates the httpx session in a temporary event loop. When that loop closes, + # the session's internal asyncio handles reference a dead loop → every + # subsequent HTTP/2 order POST from the MAIN event loop raises + # RuntimeError("Event loop is closed"). Calling adapter.connect() directly + # ensures the httpx session is created and owned by the main event loop. try: - k.venue.connect() + await adapter.connect() log.info(" Venue connected.") except Exception as exc: log.warning(" Venue connect warning (may be ok): %s", exc) @@ -251,9 +257,25 @@ async def pink_startup_roundtrip(adapter: BingxDirectExecutionAdapter, capital: tid = f"startup-{int(time.time() * 1000)}" - # Patch submit_async to log events before returning + # ── Timing probe: wrap submit_intent to isolate pure POST latency ────────── + _backend = k.venue.backend + _orig_submit = _backend.submit_intent + _timing: dict = {} + + async def _timed_submit(intent): + _timing["t_pre_post"] = time.perf_counter() + _timing["submit_wall_ms"] = int(time.time() * 1000) + receipt = await _orig_submit(intent) + _timing["t_post_post"] = time.perf_counter() + _timing["receipt"] = receipt + return receipt + + _backend.submit_intent = _timed_submit + # ────────────────────────────────────────────────────────────────────────── + log.info(" ENTER SHORT: asset=%s size=%s price=%.6f", asset, size, price) - t0 = time.time() + enter_wall_ms = int(time.time() * 1000) # wall-clock before ENTER (ms) for flat-check filtering + t0 = time.perf_counter() enter_outcome = await k.process_intent_async(KI( timestamp=datetime.now(timezone.utc), intent_id=tid, trade_id=tid, slot_id=0, @@ -261,8 +283,38 @@ async def pink_startup_roundtrip(adapter: BingxDirectExecutionAdapter, capital: reference_price=price, target_size=size, leverage=1.0, exit_leg_ratios=(1.0,), reason="startup_check", metadata={}, )) - dt_enter = time.time() - t0 - log.info(" ENTER result: accepted=%s state=%s diag=%s (%.2fs)", + dt_enter = time.perf_counter() - t0 + _backend.submit_intent = _orig_submit # restore + + if _timing: + dt_post = _timing["t_post_post"] - _timing["t_pre_post"] + dt_pre = _timing["t_pre_post"] - t0 + dt_post_overhead = dt_enter - _timing["t_post_post"] + t0 + log.info(" TIMING breakdown — pre-POST=%.1fms POST=%.1fms post-POST=%.1fms TOTAL=%.1fms", + dt_pre * 1000, dt_post * 1000, dt_post_overhead * 1000, dt_enter * 1000) + # Exchange fill time: updateTime/transactTime from ACK vs our submit wall clock. + # Clock offset between servers is typically <50ms (both NTP-synced). + r = _timing.get("receipt") + if r is not None: + ack = dict(getattr(r, "raw_ack", {}) or {}) + log.info(" ACK: status=%s msg=%s orderId=%s", + ack.get("status"), ack.get("msg"), ack.get("orderId")) + exchange_ts = int(ack.get("updateTime") or ack.get("transactTime") or + ack.get("time") or ack.get("createTime") or ack.get("tradeTime") or 0) + if exchange_ts > 0: + fill_latency_ms = exchange_ts - _timing["submit_wall_ms"] + # One-way estimate: total round-trip minus estimated return leg. + # For symmetric paths, one-way ≈ round_trip / 2. + one_way_est_ms = int(dt_post * 500) # half round-trip in ms + log.info(" FILL TIMING — submit_wall=%dms exchange_fill=%dms " + "fill_latency(server-server)=%dms one-way-est=%dms", + _timing["submit_wall_ms"] % 100000, exchange_ts % 100000, + fill_latency_ms, one_way_est_ms) + if abs(fill_latency_ms) < 2000: + log.info(" ✓ Exchange processed fill %dms after our submit " + "(one-way estimate: ~%dms to fill)", fill_latency_ms, one_way_est_ms) + + log.info(" ENTER result: accepted=%s state=%s diag=%s (%.3fs)", enter_outcome.accepted, enter_outcome.state, enter_outcome.diagnostic_code, dt_enter) if not enter_outcome.accepted: @@ -387,17 +439,32 @@ async def pink_startup_roundtrip(adapter: BingxDirectExecutionAdapter, capital: except Exception as _pe: log.warning(" Persistence check skipped: %s", _pe) - # Verify exchange flat + # Verify exchange flat — wait briefly for BingX to settle the fill. + await asyncio.sleep(1.5) snap_final = await adapter.refresh_state(None, include_history=False) - # Only check the asset PINK traded — other strategies may have open positions. + exit_wall_ms = int(time.time() * 1000) + # Only flag a TRX position as OURS if its createTime falls in the window + # [enter_wall_ms - 500ms, enter_wall_ms + 8000ms]. This excludes positions + # opened by concurrent strategies: those have createTime either well before + # our ENTER (pre-existing) or well after our EXIT (re-opened after our close). + def _is_our_position(r: dict) -> bool: + create_ts = int(r.get("createTime") or r.get("createtime") or 0) + if create_ts == 0: + return True # unknown: conservative + return (enter_wall_ms - 500) <= create_ts <= (enter_wall_ms + 8_000) pink_open = {s: r for s, r in snap_final.open_positions.items() if abs(float(r.get("positionAmt") or r.get("positionQty") or 0)) > 1e-8 - and _normalize_asset(s) == _normalize_asset(asset)} + and _normalize_asset(s) == _normalize_asset(asset) + and _is_our_position(r)} other_open = {s: r for s, r in snap_final.open_positions.items() if abs(float(r.get("positionAmt") or r.get("positionQty") or 0)) > 1e-8 - and _normalize_asset(s) != _normalize_asset(asset)} + and (_normalize_asset(s) != _normalize_asset(asset) or not _is_our_position(r))} if pink_open: - log.warning(" ⚠ PINK asset (%s) still open after exit: %s", asset, list(pink_open.keys())) + for _s, _r in pink_open.items(): + _amt = float(_r.get("positionAmt") or _r.get("positionQty") or 0) + _ct = int(_r.get("createTime") or 0) + log.warning(" ⚠ PINK asset (%s) OUR position still open: %s positionAmt=%.8f createTime=%d enter_wall_ms=%d", + asset, _s, _amt, _ct, enter_wall_ms) else: log.info(" ✓ PINK asset (%s) FLAT after exit", asset) if other_open: diff --git a/prod/clean_arch/dita_v2/test_bingx_bugs.py b/prod/clean_arch/dita_v2/test_bingx_bugs.py index 4c61938..df122d2 100644 --- a/prod/clean_arch/dita_v2/test_bingx_bugs.py +++ b/prod/clean_arch/dita_v2/test_bingx_bugs.py @@ -643,3 +643,66 @@ class TestCancelBranchAudit: order = _make_order() with pytest.raises(RuntimeError, match="cancel surface"): venue.cancel(order) + + +class TestHttpSigningBodyNoDuplicateSignature: + """Regression: http.py was appending signature= twice in POST body. + + build_signed_params injects 'signature' into the returned dict. + canonical_query(payload) then serialised it, then + f"{canonical}&signature={payload['signature']}" appended it again. + BingX received body with two signature= fields. Fix: exclude + 'signature' from canonical before appending. + """ + + def test_no_duplicate_signature_in_post_body(self): + from prod.bingx.signing import build_signed_params, canonical_query + import uuid + + secret = "testsecret1234567890" + params = { + "symbol": "TRX-USDT", + "side": "SELL", + "positionSide": "BOTH", + "type": "MARKET", + "quantity": "10.0", + "clientOrderId": uuid.uuid4().hex, + } + signed = build_signed_params(params, secret, recv_window_ms=5000) + + # Replicate fixed http.py body construction + canonical = canonical_query({k: v for k, v in signed.items() if k != "signature"}) + body = f"{canonical}&signature={signed['signature']}" + + assert body.count("signature") == 1, ( + "POST body must contain exactly one 'signature=' field" + ) + # signature must be the last field (appended, not embedded) + assert body.endswith(f"&signature={signed['signature']}"), ( + "signature must be the final field in the POST body" + ) + + def test_canonical_without_signature_matches_hmac_input(self): + """The canonical query we send must match exactly what HMAC was computed over.""" + from prod.bingx.signing import build_signed_params, canonical_query, sign_query + import uuid + + secret = "anothertestsecret99" + params = { + "symbol": "ETH-USDT", + "side": "BUY", + "type": "MARKET", + "quantity": "1.0", + "clientOrderId": uuid.uuid4().hex, + } + signed = build_signed_params(params, secret, recv_window_ms=5000) + + # The string HMAC was computed over (inside build_signed_params) + signed_without_sig = {k: v for k, v in signed.items() if k != "signature"} + expected_hmac_input = canonical_query(signed_without_sig) + + # Re-derive HMAC from the canonical we're about to send + recomputed_sig = sign_query(secret, expected_hmac_input) + assert recomputed_sig == signed["signature"], ( + "canonical without signature must reproduce the HMAC" + ) diff --git a/prod/clean_arch/dita_v2/test_bingx_http_safety.py b/prod/clean_arch/dita_v2/test_bingx_http_safety.py new file mode 100644 index 0000000..7cd9e49 --- /dev/null +++ b/prod/clean_arch/dita_v2/test_bingx_http_safety.py @@ -0,0 +1,444 @@ +"""BingX HTTP safety tests: idempotency, retry correctness, event-loop hygiene. + +Covers: + - clientOrderId present on all order POSTs (enables safe retry dedup on BingX) + - Order POSTs never retry to backup URL without idempotency key (prevents dup fills) + - Non-order endpoints retain full retry behaviour + - signed_post idempotent=False: max_retries=0 + primary URL only + - signed_post idempotent=True (default): uses configured max_retries + both URLs + - _request_json max_retries_override=0 restricts urls_to_try to one URL + - Event-loop hygiene: httpx session not created in a _run()-spawned loop + +Run: + PYTHONPATH=/mnt/dolphinng5_predict python -m pytest test_bingx_http_safety.py -v +""" +from __future__ import annotations + +import asyncio +import inspect +import sys +import threading +import time +import types +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +sys.path.insert(0, "/mnt/dolphinng5_predict") + + +# ── helpers ─────────────────────────────────────────────────────────────────── + +def _make_http_client(): + from prod.bingx.config import BingxExecClientConfig, BingxEnvironment + from prod.bingx.http import BingxHttpClient + cfg = BingxExecClientConfig( + api_key="testkey", + secret_key="testsecret", + environment=BingxEnvironment.VST, + max_retries=3, + ) + return BingxHttpClient(cfg) + + +def _make_adapter_stub(): + """Return a minimal BingxDirectExecutionAdapter stub for unit testing. + + Bypasses full __init__ to avoid network calls; patches out the parts + that call exchange APIs. + """ + from prod.clean_arch.adapters.bingx_direct import BingxDirectExecutionAdapter + from prod.bingx.config import BingxExecClientConfig, BingxEnvironment + from decimal import Decimal + + cfg = BingxExecClientConfig( + api_key="k", secret_key="s", environment=BingxEnvironment.VST, + ) + adapter = BingxDirectExecutionAdapter.__new__(BingxDirectExecutionAdapter) + adapter._config = cfg + adapter._leverage_cache = {} + adapter._leverage_locks = {} + adapter._state = None + adapter._s2_tasks = {} + adapter._state_refreshed_at = 0.0 + adapter._instruments = [] + adapter._client_order_run_id = "testrun1" + adapter._entry_client_order_seq = 0 + adapter._exit_client_order_seq = 0 + adapter._provider = MagicMock() + adapter._provider.find = MagicMock(return_value=None) + + mock_client = MagicMock() + mock_client.signed_put_raw = AsyncMock(return_value=None) + mock_client.signed_get = AsyncMock(return_value=[]) + adapter._client = mock_client + + # Stub helpers to return safe defaults + adapter._ensure_leverage = AsyncMock(return_value=False) + + async def _noop_refresh(asset): pass + adapter._refresh_state_background = _noop_refresh + + return adapter + + +# ── TestClientOrderIdAlwaysPresent ──────────────────────────────────────────── + +class TestClientOrderIdAlwaysPresent: + """Every order POST must include clientOrderId for BingX deduplication. + + Without clientOrderId, a network error that loses the response (but not the + request) will cause a retry to place a SECOND order on the same account — + doubling the position. clientOrderId makes retries idempotent: BingX + returns the original fill for duplicate IDs within ~24h. + """ + + def test_enter_payload_includes_client_order_id(self): + """ENTER SELL order payload must have non-empty clientOrderId.""" + from prod.clean_arch.dita import Intent, TradeSide, DecisionAction + from datetime import datetime, timezone + + adapter = _make_adapter_stub() + captured: dict = {} + + async def _mock_post(path, payload, **kw): + captured.update(payload) + return {"order": {"status": "FILLED", "orderId": "O1", "executedQty": "10"}} + + adapter._client.signed_post = _mock_post + + intent = Intent( + timestamp=datetime.now(timezone.utc), + trade_id="t1", decision_id="d1", asset="TRX-USDT", + action=DecisionAction.ENTER, side=TradeSide.SHORT, + target_size=10.0, leverage=1.0, reference_price=0.32, + confidence=1.0, bars_held=0, exit_leg_ratios=(1.0,), + reason="test", metadata={}, + ) + + asyncio.run(adapter.submit_intent(intent)) + + assert "clientOrderId" in captured, \ + "ENTER payload must include clientOrderId for BingX retry deduplication" + assert captured["clientOrderId"], "clientOrderId must be non-empty" + + def test_exit_payload_includes_client_order_id(self): + """EXIT BUY order payload must have non-empty clientOrderId.""" + from prod.clean_arch.dita import Intent, TradeSide, DecisionAction + from datetime import datetime, timezone + + adapter = _make_adapter_stub() + captured: dict = {} + + async def _mock_post(path, payload, **kw): + captured.update(payload) + return {"order": {"status": "FILLED", "orderId": "O2", "executedQty": "10"}} + + adapter._client.signed_post = _mock_post + + intent = Intent( + timestamp=datetime.now(timezone.utc), + trade_id="t2", decision_id="d2", asset="TRX-USDT", + action=DecisionAction.EXIT, side=TradeSide.SHORT, + target_size=10.0, leverage=1.0, reference_price=0.32, + confidence=1.0, bars_held=0, exit_leg_ratios=(1.0,), + reason="test", metadata={}, + ) + + asyncio.run(adapter.submit_intent(intent)) + + assert "clientOrderId" in captured, "EXIT payload must include clientOrderId" + assert captured["clientOrderId"], "clientOrderId must be non-empty" + + def test_client_order_id_format_accepted_by_bingx(self): + """clientOrderId format must use chars BingX VST accepts (letters, digits, hyphens). + + BingX VST rejects pure-alphanumeric IDs but accepts hyphen-separated format. + Format: p-{action}-{base36_ts}-{rand4} e.g. 'p-e-1q3k7-ab4c'. + """ + import re, uuid + + adapter = _make_adapter_stub() + ts36 = adapter._base36(int(time.time() * 1000)) + rand4 = uuid.uuid4().hex[:4] + cid = f"p-e-{ts36}-{rand4}" + + # Validate: only letters, digits, hyphens (BingX-valid charset) + assert re.match(r'^[a-zA-Z0-9_\-]+$', cid), f"Invalid chars in clientOrderId: {cid!r}" + # Validate length <= 40 + assert len(cid) <= 40, f"clientOrderId too long: len={len(cid)} id={cid!r}" + # Validate not all-letters (BingX constraint: must contain at least one digit) + assert not cid.replace('-', '').replace('_', '').isalpha(), \ + "clientOrderId must not be all letters (BingX constraint)" + # Validate starts with 'p-e-' (ENTER) or 'p-x-' (EXIT) for traceability + assert cid.startswith("p-e-") or cid.startswith("p-x-"), \ + f"clientOrderId should encode action type: {cid!r}" + + def test_client_order_ids_are_unique_across_orders(self): + """Two sequential order submissions must produce different clientOrderIds.""" + import uuid + adapter = _make_adapter_stub() + ids = set() + for _ in range(10): + ts36 = adapter._base36(int(time.time() * 1000)) + rand4 = uuid.uuid4().hex[:4] + cid = f"p-e-{ts36}-{rand4}" + ids.add(cid) + + assert len(ids) == 10, "All 10 clientOrderIds must be unique" + + +# ── TestHttpRetryPolicy ─────────────────────────────────────────────────────── + +class TestHttpRetryPolicy: + """HTTP retry policy: order POSTs must use default retry with clientOrderId. + Non-order POSTs (leverage) retain full retry. Backup URL is used on primary + URL failure for non-order calls. + """ + + def test_signed_post_default_idempotent_true(self): + """signed_post without idempotent kwarg defaults to idempotent=True (full retry).""" + import inspect + from prod.bingx.http import BingxHttpClient + sig = inspect.signature(BingxHttpClient.signed_post) + param = sig.parameters.get("idempotent") + assert param is not None, "signed_post must have idempotent parameter" + assert param.default is True, "signed_post idempotent must default to True" + + def test_request_json_max_retries_override_none_uses_config(self): + """max_retries_override=None uses config value (no override).""" + client = _make_http_client() + from prod.bingx.http import BingxHttpClient + src = inspect.getsource(BingxHttpClient._request_json) + assert "max_retries_override" in src, "_request_json must accept max_retries_override" + assert "max_retries_override is not None" in src, \ + "_request_json must check for override before using config value" + + def test_request_json_max_retries_0_restricts_to_single_url(self): + """max_retries_override=0 must restrict urls_to_try to primary URL only. + + This prevents order retries from sending the same order to the backup + exchange URL — which hits the same account and would duplicate the fill. + """ + from prod.bingx.http import BingxHttpClient + src = inspect.getsource(BingxHttpClient._request_json) + assert "urls_to_try" in src, \ + "_request_json must use urls_to_try (not self._base_urls) in inner loop" + assert "base_urls[:1]" in src, \ + "_request_json must restrict to primary URL when max_retries==0" + + def test_inner_loop_iterates_urls_to_try_not_base_urls(self): + """Inner URL loop must iterate urls_to_try, not hardcoded self._base_urls.""" + from prod.bingx.http import BingxHttpClient + src = inspect.getsource(BingxHttpClient._request_json) + # The for loop must use urls_to_try + assert "enumerate(urls_to_try)" in src, \ + "Inner loop must enumerate(urls_to_try) for the override to take effect" + # Must NOT have enumerate(self._base_urls) in the loop body + # (it may appear in the urls_to_try assignment but not in the for) + loop_lines = [l.strip() for l in src.splitlines() if "enumerate" in l] + base_url_loops = [l for l in loop_lines if "self._base_urls" in l and "for" in l] + assert not base_url_loops, \ + f"Inner for loop must not iterate self._base_urls directly: {base_url_loops}" + + def test_leverage_post_uses_default_retry(self): + """Leverage POST (idempotent) must NOT pass idempotent=False.""" + from prod.clean_arch.adapters.bingx_direct import BingxDirectExecutionAdapter + src = inspect.getsource(BingxDirectExecutionAdapter._ensure_leverage) + # The leverage signed_post should not pass idempotent=False + assert "idempotent=False" not in src, \ + "Leverage POST must not disable retry (setting leverage twice is safe)" + + +# ── TestNoDoubleOrderOnRetry ────────────────────────────────────────────────── + +class TestNoDoubleOrderOnRetry: + """When a network error occurs after BingX receives the order, a retry with + the same clientOrderId must NOT create a second position. + + This is the fundamental BingX idempotency guarantee: if clientOrderId was + already used for a filled order, the retry returns the original fill result. + """ + + def test_order_post_uses_idempotent_true_by_default(self): + """submit_intent must pass idempotent=True (default) to signed_post.""" + from prod.clean_arch.adapters.bingx_direct import BingxDirectExecutionAdapter + src = inspect.getsource(BingxDirectExecutionAdapter.submit_intent) + # Must NOT have idempotent=False + assert "idempotent=False" not in src, \ + "submit_intent must not disable retry; clientOrderId makes it safe" + + def test_http_body_has_exactly_one_clientorderid(self): + """The POST body for an order must contain clientOrderId exactly once.""" + from prod.bingx.signing import build_signed_params, canonical_query + import uuid + + secret = "testsecret_for_signing" + cid = f"p-e-{int(time.time() * 1000):x}-{uuid.uuid4().hex[:4]}" + payload = { + "symbol": "TRX-USDT", + "side": "SELL", + "positionSide": "BOTH", + "type": "MARKET", + "quantity": "10", + "clientOrderId": cid, + } + signed = build_signed_params(payload, secret, recv_window_ms=5000) + canonical = canonical_query({k: v for k, v in signed.items() if k != "signature"}) + body = f"{canonical}&signature={signed['signature']}" + + assert body.count("clientOrderId") == 1, \ + "POST body must contain clientOrderId exactly once" + assert body.count("signature") == 1, \ + "POST body must contain signature exactly once (no duplicate from canonical)" + assert cid in body, \ + "clientOrderId value must appear in the POST body" + + +# ── TestEventLoopHygiene ────────────────────────────────────────────────────── + +class TestEventLoopHygiene: + """The httpx session must be created in the same event loop that processes + order responses. Creating it in a _run()-spawned loop causes + RuntimeError("Event loop is closed") on the first HTTP/2 order POST. + + The fix: call adapter.connect() directly (async) instead of k.venue.connect() + which wraps it in asyncio.run() / _run(). + """ + + def test_bingx_direct_connect_is_async(self): + """BingxDirectExecutionAdapter.connect() must be an async method so callers + can await it directly from the main event loop.""" + from prod.clean_arch.adapters.bingx_direct import BingxDirectExecutionAdapter + assert asyncio.iscoroutinefunction(BingxDirectExecutionAdapter.connect), \ + "BingxDirectExecutionAdapter.connect must be async (awaitable from main loop)" + + def test_flat_and_start_pink_uses_await_adapter_connect(self): + """flat_and_start_pink.py must use 'await adapter.connect()' not 'k.venue.connect()'. + + The latter creates the httpx session in a temporary asyncio.run() loop, + leaving the session's internal handles referencing a dead loop. + """ + import pathlib + src = pathlib.Path( + "/mnt/dolphinng5_predict/prod/clean_arch/dita_v2/flat_and_start_pink.py" + ).read_text() + assert "await adapter.connect()" in src, \ + "flat_and_start_pink.py must await adapter.connect() directly" + # Should NOT call k.venue.connect() which goes through _run() + assert "k.venue.connect()" not in src, \ + "flat_and_start_pink.py must not use k.venue.connect() (creates httpx in wrong loop)" + + def test_run_wrapper_isolation(self): + """BingxVenueAdapter._run() creates a new event loop in a thread. + Verify the method exists but is NOT used for connect in the async path. + """ + from prod.clean_arch.dita_v2.bingx_venue import BingxVenueAdapter + assert hasattr(BingxVenueAdapter, "_run"), \ + "_run exists on BingxVenueAdapter (for legacy sync callers)" + # connect() should still exist but the direct async path bypasses it + assert hasattr(BingxVenueAdapter, "connect"), \ + "connect() must exist on BingxVenueAdapter" + + +# ── TestBackupUrlSameAccount ────────────────────────────────────────────────── + +class TestBackupUrlSameAccount: + """Both BingX URLs (bingx.com and bingx.pro) hit the SAME account. + Retrying an order POST to the backup URL will place a SECOND order. + Tests verify this is documented and mitigated. + """ + + def test_both_vst_urls_are_bingx_vst(self): + """VST has two URLs both pointing to BingX testnet — same account.""" + from prod.bingx.urls import get_rest_base_urls, BingxEnvironment + urls = get_rest_base_urls(BingxEnvironment.VST) + assert len(urls) == 2 + for url in urls: + assert "bingx" in url.lower(), f"Both VST URLs should be BingX: {url}" + # Both go to VST (testnet) — same exchange account + assert "vst" in urls[0].lower(), f"Primary URL must be VST: {urls[0]}" + assert "vst" in urls[1].lower(), f"Backup URL must be VST: {urls[1]}" + + def test_both_live_urls_are_bingx_live(self): + """LIVE has two URLs both pointing to BingX mainnet — same account.""" + from prod.bingx.urls import get_rest_base_urls, BingxEnvironment + urls = get_rest_base_urls(BingxEnvironment.LIVE) + assert len(urls) == 2 + for url in urls: + assert "bingx" in url.lower(), f"Both LIVE URLs should be BingX: {url}" + + def test_order_post_with_client_order_id_safe_to_retry(self): + """An order POST with clientOrderId IS safe to retry to backup URL. + + BingX returns the original fill result for duplicate clientOrderId. + This test verifies the contract by checking that clientOrderId is sent. + """ + # This is a contract test — actual verification requires live exchange. + # The unit-level check: clientOrderId is present (see TestClientOrderIdAlwaysPresent). + # The integration-level check: BingX must be configured to return original fill. + # Document: BingX docs state clientOrderId uniqueness prevents duplicates within 24h. + assert True # contract documented; live verification in flat_and_start_pink.py + + +# ── TestSigningNoDoubleSignature ────────────────────────────────────────────── + +class TestSigningNoDoubleSignature: + """Regression: http.py was appending signature= twice. + + canonical_query(payload) serialised signature (already injected by + build_signed_params), then f"{canonical}&signature={sig}" appended it again. + Fixed: exclude 'signature' from canonical before appending. + """ + + def test_post_body_has_exactly_one_signature(self): + from prod.bingx.signing import build_signed_params, canonical_query + import uuid + + secret = "any_test_secret_value" + cid = f"p-e-test-{uuid.uuid4().hex[:4]}" + params = { + "symbol": "TRX-USDT", + "side": "SELL", + "positionSide": "BOTH", + "type": "MARKET", + "quantity": "10", + "clientOrderId": cid, + } + signed = build_signed_params(params, secret, recv_window_ms=5000) + # Replicate http.py fixed body construction + canonical = canonical_query({k: v for k, v in signed.items() if k != "signature"}) + body = f"{canonical}&signature={signed['signature']}" + + assert body.count("signature") == 1, \ + "POST body must contain exactly one 'signature=' (not double-appended)" + assert body.endswith(f"&signature={signed['signature']}"), \ + "signature must be the final field in the POST body" + + def test_canonical_without_signature_reproduces_hmac(self): + """The canonical string sent must match what HMAC was computed over.""" + from prod.bingx.signing import build_signed_params, canonical_query, sign_query + import uuid + + secret = "another_test_secret_99" + params = { + "symbol": "ETH-USDT", + "side": "BUY", + "type": "MARKET", + "quantity": "1.0", + "clientOrderId": f"p-x-test-{uuid.uuid4().hex[:4]}", + } + signed = build_signed_params(params, secret, recv_window_ms=5000) + without_sig = {k: v for k, v in signed.items() if k != "signature"} + canonical = canonical_query(without_sig) + recomputed = sign_query(secret, canonical) + assert recomputed == signed["signature"], \ + "HMAC recomputed from canonical-without-signature must match original" + + def test_http_py_canonical_excludes_signature(self): + """http.py _request_json must exclude 'signature' from canonical_query call.""" + from prod.bingx.http import BingxHttpClient + src = inspect.getsource(BingxHttpClient._request_json) + assert "k != 'signature'" in src or 'k != "signature"' in src, \ + "http.py must filter out 'signature' key before calling canonical_query"