Repository navigation
v0.8.4 — Propagate per-item sCode into exchange_code on batch failures
Fixes a latent bug that made the v0.7.2 idempotent-cancel handler a no-op against OKX.
The bug
OKX returns batch-failure responses as:
{"code":"1","msg":"All operations failed","data":[{"sCode":"51400","sMsg":"Order cancellation failed as the order has been filled, canceled or does not exist."}]}The top-level code="1" is the generic "at least one operation failed" envelope. The per-item data[0].sCode is the actual error.
process_status (in src/execution/okx_rest_client.cpp) was writing the top-level 1 into exchange_code_out and returning false. The per-item sCode was extracted via parse_data_first but only used in the log line — never written back.
Downstream, OrderManager::cancel_slot (v0.7.2) checks:
if (c.binance_code == 51400 || c.binance_code == -2011) {
spdlog::info("cancel_idempotent ... reason=already_gone");
transition(slot_idx, OrderState::CANCELED);
s.active = false;
return true;
}With c.binance_code = 1 (not 51400), the idempotent branch never fired. The slot stayed active=true in state ACKNOWLEDGED, and every subsequent quote tick retried the cancel → another 51400 → another retry, forever. Sides got stuck after the first silent fill.
Caught during the v0.8.3 OKX demo validation:
- 22 ACK cycles ✓
- 21 CANCELED transitions ✓
- 29
okx_rest_fail item_code=51400errors - 0
cancel_idempotentlog lines — the smoking gun
The fix
In process_status, after parse_data_first(body), if pr.s_code != 0, overwrite exchange_code_out with the per-item code before returning false. The per-item code is the actionable one for callers.
Three lines of real change inside the existing block, plus a WHY comment explaining why per-item takes precedence over top-level on batch failures.
const auto pr = parse_data_first(body);
if (pr.s_code != 0) {
exchange_code_out = pr.s_code;
}
spdlog::warn(...);
return false;Effect across callers
This change benefits every OKX REST caller that inspects binance_code on failure:
cancel_order→OrderManager::cancel_slotidempotent path (51400) now fires.place_order/place_market_order→ callers see specific reject reasons (51008insufficient margin,51010account-mode mismatch,51000clOrdId format error, etc.) instead of generic1. Useful for any future code that wants to react to specific OKX error codes.amend_order→ same surface.
Tests
8/8 C++ tests pass under -O3 -march=native -Wall -Wextra -Werror, zero warnings. Existing test_execution::AmendHalfStateReportsCritical and test_execution::GeoblockHaltTrigger cover the geoblock (50114) and amend-half-state paths; both still pass because the change is additive (only overwrites when per-item code is present and non-zero).
Known follow-up: Phase 9 user-data stream
The validation run also surfaced 1 silent fill on the OKX demo book — our local PositionTracker showed inv=0 while OKX held pos=1 at 78145.30. Root cause is the absence of a private user-data WebSocket subscription (/v5/private with signed auth handshake for the orders / fills channels). With this fix, the cancel path handles the silent-fill aftermath gracefully (slot transitions to CANCELED idempotently), but the fill itself still goes unnotified to the local position tracker. The next startup catches the divergence via reconcile_now() and either trips the circuit breaker (if > position_divergence_tolerance) or accepts it as new state.
Full fix is the Phase 9 private-WS wiring; tools/okx_demo_flatten.sh remains the workaround for demo testing.