107 lines
4.9 KiB
Python
107 lines
4.9 KiB
Python
|
|
"""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).
|
||
|
|
|
||
|
|
``<prefix><request_id>-<attempt>`` 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
|