PINK: fix EXIT position not closing — 3 root causes, 368/368 tests green

Root cause 1 (http.py): duplicate signature= in POST body — canonical_query
included signature key after build_signed_params injected it, then body
appended &signature= again. Fix: exclude 'signature' from canonical.

Root cause 2 (bingx_direct + http.py): HTTP retry sent same MARKET order to
backup URL (bingx.pro), which hits the same VST account. Without clientOrderId,
each retry opened a new SHORT position; EXIT BUY 10 only closed one. Fix:
restore clientOrderId in hyphen format p-{e/x}-{base36_ts}-{rand4} (pure
alphanumeric rejected by VST; hyphen format accepted). Adds max_retries_override
+ urls_to_try to _request_json for non-idempotent override path.

Root cause 3 (flat_and_start_pink): k.venue.connect() ran backend.connect()
inside asyncio.run() in a thread-pool. httpx session created there references
a dead event loop; order POSTs raise RuntimeError("Event loop is closed").
Fix: await adapter.connect() directly from main event loop.

Also: enter_wall_ms + tight _is_our_position createTime filter to separate
PINK's position from concurrent strategies on shared VST account. 1.5s
settle sleep before flat check.

New test suite test_bingx_http_safety.py: 20 tests covering idempotency,
retry correctness, backup-URL dedup, event-loop hygiene, signing correctness.

Live result: ENTER 290ms, EXIT 260ms — both sub-second. Position flat.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Codex
2026-06-06 01:39:35 +02:00
parent 535eea855d
commit 33d8e855c8
6 changed files with 1806 additions and 19 deletions

View File

@@ -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"
)