Files
siloqy/prod/tests/test_bingx_direct_limit_order.py
Codex 0c15a7698e PINK DITAv2 L3: fix live LIMIT cancel (kernel order-id propagation + truth-based cancel)
L3 live validation surfaced a live-only defect: a working LIMIT order could not
be cancelled (MARKET never exercised cancel — synchronous fills).

Two coupled fixes:
- Rust FSM (lib.rs): propagate the venue's order id onto the active order for
  ALL order types and event kinds (ACK/partial/full fill) whenever the exchange
  provides one — orders are created at submit with an empty venue_order_id, so a
  later cancel had no real id to reference. Only fills empty ids, never overwrites.
  Requires recompiling libdita_v2_kernel.so.
- Backend (bingx_direct.py): add cancel(order) — a properly-signed DELETE by
  orderId (clientOrderId fallback) with TRUTH-BASED confirmation: BingX can return
  transient errors ("order not exist", dup-within-1s from an internal retry) even
  when the order was removed, so the cancel succeeds iff the order is no longer
  open on the venue. The venue adapter prefers this backend cancel over its raw
  signed_delete fallback (which failed signature with an empty id).

Validated:
- Offline: 63 + new cancel-truth unit tests green (no regression post-recompile).
- Live VST: resting SHORT LIMIT (+5%) rests as ENTRY_WORKING, confirmed as a LIMIT
  open order, cancel -> CANCEL_ACK -> IDLE, exchange flat (test_pink_limit_live.py).
- Live VST MARKET run-through re-validated post-recompile: PASS, exact capital
  reconciliation, two-phase rows visible (ORDER_REQUESTED + ENTRY_FILLED/EXIT).

LIMIT remains execution-infra only; PINK policy stays MARKET. BLUE untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 08:03:27 +02:00

108 lines
4.0 KiB
Python

"""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"]
# --- 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