"""BingX venue dialect — the ONLY place venue-specific idioms live (spec §11, §4-14/15/17). The layer above speaks a normalized order language (Side, ExecutionMethod, Decimal size); this module translates it into a BingX REST order payload at the boundary. Everything that is "BingX law" is encoded here and nowhere else, so a second venue is a second dialect, not a rewrite. Encoded findings (each a headstone): * H4 / BI-1: clientOrderId unique per attempt, sanitized to the venue charset, length-capped deterministically (BingX caps it) — a retry must not collide, FIX OrigClOrdID discipline. * L7 (mm_ correction): POST_ONLY/IOC/FOK are BingX ``timeInForce`` values, NOT ``type`` — the map that flattened them into ``type`` caused venue rejects. type = LIMIT|MARKET only. * §4-15: quantize price to tick (conservatively by side, maker-safe) AND size to step (floor — trade slightly less, never overshoot) BEFORE submit. * §4-17: one-way flattening needs ``positionSide=BOTH``; MARKET carries no price/TIF; exits are ``reduceOnly``. Pure stdlib + Decimal. No I/O, no dita_v2. """ from __future__ import annotations import hashlib import re from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal from .contract import Side from .router import ExecutionMethod # BingX clientOrderId: conservative cap + charset. BingX accepts alphanumerics plus a few # separators; keep it tight and deterministic so a retry id never collides. CLIENT_ORDER_ID_MAX = 40 _CID_ALLOWED = re.compile(r"[^A-Za-z0-9_-]") # Quote currencies, longest-first, for splitting the dash into a BingX wire symbol. _QUOTES = ("USDT", "USDC", "BUSD", "USD") def dash_symbol(asset: str) -> str: """Canonical undashed ("BTCUSDT") → BingX wire symbol ("BTC-USDT"). Idempotent.""" a = asset.upper() if "-" in a: return a for q in _QUOTES: if a.endswith(q) and len(a) > len(q): return f"{a[:-len(q)]}-{q}" return a def client_order_id(request_id: str, attempt: int = 0, *, prefix: str = "u-") -> str: """Mint a venue-legal clientOrderId, unique per attempt (audit H4). ``-`` sanitized to [A-Za-z0-9_-] and capped at CLIENT_ORDER_ID_MAX. If it would overflow, truncate the core and append a deterministic 8-hex digest of the FULL core so uniqueness (and per-attempt distinctness) survives. """ if attempt < 0: raise ValueError(f"attempt must be >= 0, got {attempt}") core = f"{request_id}-{attempt}" cid = _CID_ALLOWED.sub("", f"{prefix}{core}") if len(cid) <= CLIENT_ORDER_ID_MAX: return cid digest = hashlib.sha1(core.encode()).hexdigest()[:8] keep = CLIENT_ORDER_ID_MAX - 9 # room for "-" + 8 hex return f"{cid[:keep]}-{digest}" def quantize_price(price: Decimal, tick: Decimal, side: Side) -> Decimal: """Quantize a maker price to tick CONSERVATIVELY by side (never cross the touch): BUY floors (never up into the ask), SELL ceils (never down into the bid). §4-15 + placer's fill-rate fix.""" if tick <= 0: raise ValueError(f"tick must be > 0, got {tick}") rounding = ROUND_FLOOR if side is Side.BUY else ROUND_CEILING return (price / tick).quantize(Decimal("1"), rounding=rounding) * tick def quantize_size(size: Decimal, step: Decimal) -> Decimal: """Floor size to the venue step — trade slightly less, NEVER overshoot (§4-15; L8: partials cluster on low-price/high-qty symbols, XRP 31.446→31 etc.).""" if step <= 0: raise ValueError(f"step must be > 0, got {step}") return (size / step).quantize(Decimal("1"), rounding=ROUND_FLOOR) * step def build_order_payload(*, asset: str, side: Side, method: ExecutionMethod, limit_price: Decimal, size: Decimal, reduce_only: bool, client_order_id: str, tick: Decimal, step: Decimal, position_side: str = "BOTH") -> dict: """Normalized order → BingX REST payload. The single venue-idiom boundary.""" qty = quantize_size(size, step) if qty <= 0: raise ValueError(f"quantized size is non-positive (size={size}, step={step}) — " "below the venue minimum; caller must not submit dust") payload = { "symbol": dash_symbol(asset), "side": side.value, # BUY | SELL (order direction) "positionSide": position_side, # BOTH for one-way flatten (§4-17) "quantity": str(qty), "clientOrderID": client_order_id, "reduceOnly": "true" if reduce_only else "false", } if method is ExecutionMethod.MAKER: payload["type"] = "LIMIT" payload["price"] = str(quantize_price(limit_price, tick, side)) payload["timeInForce"] = "PostOnly" # L7: PostOnly is timeInForce, NOT type else: payload["type"] = "MARKET" # no price, no timeInForce on a market cross return payload