2026-05-31 03:23:44 +02:00
|
|
|
"""L2 — LIMIT order payload wiring in BingxDirectExecutionAdapter.submit_intent.
|
|
|
|
|
|
|
|
|
|
The venue adapter forwards _order_type/_limit_price in the intent metadata; the
|
|
|
|
|
backend must place a LIMIT order (type=LIMIT + price + GTC) when asked, and keep
|
|
|
|
|
MARKET as the default. Offline unit test of payload construction — the signed_post
|
|
|
|
|
client is stubbed to capture the order payload; no exchange contact.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
from types import SimpleNamespace
|
|
|
|
|
|
|
|
|
|
from prod.clean_arch.adapters.bingx_direct import BingxDirectExecutionAdapter
|
|
|
|
|
from prod.clean_arch.dita import DecisionAction, Intent, TradeSide
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _adapter(captured: dict):
|
|
|
|
|
a = BingxDirectExecutionAdapter.__new__(BingxDirectExecutionAdapter)
|
|
|
|
|
a._config = SimpleNamespace(recv_window_ms=5000, default_leverage=1, exchange_leverage_cap=3)
|
|
|
|
|
a._client_order_run_id = "test"
|
|
|
|
|
a._entry_client_order_seq = 0
|
|
|
|
|
a._exit_client_order_seq = 0
|
|
|
|
|
a._state = SimpleNamespace(open_positions={}, account={})
|
|
|
|
|
|
|
|
|
|
async def _signed_post(path, params):
|
|
|
|
|
if path.endswith("/trade/order"):
|
|
|
|
|
captured["order"] = dict(params)
|
|
|
|
|
return {"orderId": "1", "status": "NEW"}
|
|
|
|
|
|
|
|
|
|
a._client = SimpleNamespace(signed_post=_signed_post)
|
|
|
|
|
a._instrument_venue_symbol = lambda asset: "BTC-USDT"
|
|
|
|
|
a._format_quantity = lambda asset, q: f"{float(q)}"
|
|
|
|
|
a._format_price = lambda asset, p: f"{float(p)}"
|
|
|
|
|
|
|
|
|
|
async def _refresh(asset, include_history=True):
|
|
|
|
|
return a._state
|
|
|
|
|
|
|
|
|
|
a._refresh_exchange_state = _refresh
|
|
|
|
|
return a
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _intent(metadata: dict) -> Intent:
|
|
|
|
|
return Intent(
|
|
|
|
|
timestamp=datetime.now(timezone.utc), trade_id="T1", decision_id="D1",
|
|
|
|
|
asset="BTCUSDT", action=DecisionAction.ENTER, side=TradeSide.SHORT,
|
|
|
|
|
reason="TEST", target_size=0.01, leverage=2.0, reference_price=100.0,
|
|
|
|
|
confidence=0.5, exit_leg_ratios=(1.0,), metadata=metadata,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_limit_intent_places_limit_order():
|
|
|
|
|
captured: dict = {}
|
|
|
|
|
asyncio.run(_adapter(captured).submit_intent(_intent({"_order_type": "LIMIT", "_limit_price": 95.0})))
|
|
|
|
|
o = captured["order"]
|
|
|
|
|
assert o["type"] == "LIMIT", o
|
|
|
|
|
assert "price" in o and float(o["price"]) == 95.0, o
|
|
|
|
|
assert o.get("timeInForce") == "GTC", o
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_market_intent_places_market_order():
|
|
|
|
|
captured: dict = {}
|
|
|
|
|
asyncio.run(_adapter(captured).submit_intent(_intent({})))
|
|
|
|
|
o = captured["order"]
|
|
|
|
|
assert o["type"] == "MARKET", o
|
|
|
|
|
assert "price" not in o, o
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_limit_without_valid_price_falls_back_to_market():
|
|
|
|
|
captured: dict = {}
|
|
|
|
|
asyncio.run(_adapter(captured).submit_intent(_intent({"_order_type": "LIMIT", "_limit_price": 0.0})))
|
|
|
|
|
assert captured["order"]["type"] == "MARKET", captured["order"]
|
2026-05-31 08:03:27 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- cancel: truth-based confirmation (trust exchange state over the response) ---
|
|
|
|
|
|
|
|
|
|
def _cancel_adapter(*, open_after: list):
|
|
|
|
|
a = BingxDirectExecutionAdapter.__new__(BingxDirectExecutionAdapter)
|
|
|
|
|
a._config = SimpleNamespace(recv_window_ms=5000)
|
|
|
|
|
a._instrument_venue_symbol = lambda asset: "TRX-USDT"
|
|
|
|
|
|
|
|
|
|
async def _signed_delete(path, params):
|
|
|
|
|
# Simulate BingX returning a transient error even when the order is removed.
|
|
|
|
|
return {"status": "REJECTED", "msg": "order not exist"}
|
|
|
|
|
|
|
|
|
|
async def _signed_get(path, params):
|
|
|
|
|
return {"data": {"orders": open_after}}
|
|
|
|
|
|
|
|
|
|
a._client = SimpleNamespace(signed_delete=_signed_delete, signed_get=_signed_get)
|
|
|
|
|
return a
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _order(oid="2060963645141028864"):
|
|
|
|
|
return SimpleNamespace(venue_order_id=oid, venue_client_id="T:i", metadata={"asset": "TRXUSDT"})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_cancel_succeeds_when_order_gone_despite_error_response():
|
|
|
|
|
a = _cancel_adapter(open_after=[]) # order no longer open
|
|
|
|
|
resp = asyncio.run(a.cancel(_order()))
|
|
|
|
|
assert resp["status"] == "CANCELED", resp
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_cancel_rejected_when_order_still_open():
|
|
|
|
|
a = _cancel_adapter(open_after=[{"orderId": "2060963645141028864", "status": "PENDING"}])
|
|
|
|
|
resp = asyncio.run(a.cancel(_order()))
|
|
|
|
|
assert resp["status"] != "CANCELED", resp
|