fix(order): store terminal-only order-path acks (313/315/317/331) before mark_complete - #53
fix(order): store terminal-only order-path acks (313/315/317/331) before mark_complete#53olivier-babelcast wants to merge 1 commit into
Conversation
|
hey, so there are conflicts because I already merged your previous PR and made tiny changes. |
|
I copied your second commit (fix(ticker): raise on empty get_front_month_contract response) to main, since this PR has conflicts. (will release a new version of the package this weekend probably) Concerning your last commit: I tested and I'm receiving two responses as expected on my end (response with data + terminal response) Either I misunderstood the problem, or your issue is something else |
|
Ok, I will have a look later on.
…On Mon, May 4, 2026, 18:29 Mickael Burguet ***@***.***> wrote:
*rundef* left a comment (rundef/async_rithmic#53)
<#53 (comment)>
hi @olivier-babelcast <https://github.com/olivier-babelcast>
I copied your second commit (fix(ticker): raise on empty
get_front_month_contract response) to main, since this PR has conflicts.
(will release a new version of the package this weekend probably)
Concerning your last commit: always store terminal response before
mark_complete, if I understand correctly, you were receiving empty list
responses when calling new/modify/cancel/bracket order methods ?
I tested and I'm receiving two responses as expected on my end (response
with data + terminal response)
Either I misunderstood the problem, or your issue is something else
2026-05-04 18:20:21,919 - rithmic.plant.order - DEBUG - Received message {'basketId': '2524962026', 'userMsg': ['3c566f54-0371-4483-85f3-2bef7a43b30b'], 'rqHandlerRpCode': ['0'], 'ssboe': 1777933221, 'usecs': 923944, 'userTag': '20260504_182021_order', 'templateId': 313}
2026-05-04 18:20:21,919 - rithmic.plant.order - DEBUG - Received message {'userMsg': ['3c566f54-0371-4483-85f3-2bef7a43b30b'], 'rpCode': ['0'], 'templateId': 313}
2026-05-04 18:20:23,110 - rithmic.plant.order - DEBUG - Received message {'basketId': '2524962026', 'userMsg': ['02f70234-54cb-45db-8ef6-f1f61ffe1742'], 'rqHandlerRpCode': ['0'], 'ssboe': 1777933223, 'usecs': 104665, 'templateId': 317}
2026-05-04 18:20:23,110 - rithmic.plant.order - DEBUG - Received message {'userMsg': ['02f70234-54cb-45db-8ef6-f1f61ffe1742'], 'rpCode': ['0'], 'templateId': 317}
—
Reply to this email directly, view it on GitHub
<#53 (comment)>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/AMBBSIUWFE6GPQV7SCOSJAT4ZEKWDAVCNFSM6AAAAACYAKWQNWVHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHM2DGNZUHE3TEMZUGQ>
.
You are receiving this because you were mentioned.Message ID:
***@***.***>
|
…331) + regression test
Under concurrent submit/modify/cancel/bracket on one Rithmic session, the only
frame that arrives is the terminal ack (rp_code=0). _process_response handed it to
mark_complete WITHOUT storing it (it wasn't in _terminal_carries_data), so
send_and_collect returned [] and the caller raised "Rithmic empty response" —
missing live order entries (observed live_propfirms 2026-04-23, 3 APEX accounts).
Add 313/315/317/331 to _terminal_carries_data. New tests/test_order_terminal_response.py
drives the REAL OrderPlant/_process_response/RequestManager with real protobuf 313
acks (no live connection): fails on the old {11,15,114,301} set, passes with the fix.
This is the terminal-only/concurrent case a single-order test never reproduces.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Added a self-contained regression test in this PR: It exercises the real Why single-order tests don't catch this: a single in-flight order normally gets a data frame followed by a terminal frame, so the data frame is stored regardless of the allow-list. Under concurrent submits on one session the data frame is lost in the race and the terminal frame ( The three cases: def _terminal_ack(request_id, template_id=313):
"""A real protobuf order-ack as a terminal-only response (rp_code='0')."""
resp = pb.response_new_order_pb2.ResponseNewOrder()
resp.template_id = template_id
resp.user_msg.append(request_id)
resp.rp_code.append('0') # terminal success marker, no data frame preceded it
return resp
async def test_terminal_only_order_ack_is_stored(order_plant):
rid = "req-single"
order_plant.request_manager.start(
rid, request={"template_id": 312}, expected_response={"user_msg": [rid]}
)
await order_plant._process_response(_terminal_ack(rid))
stored = order_plant.request_manager.responses.get(rid, [])
assert stored # dropped before the fix
assert stored[0].template_id == 313
async def test_concurrent_terminal_only_acks_all_stored(order_plant):
# N accounts submit on one session; each request gets only its terminal ack.
rids = [f"req-{i}" for i in range(6)]
for rid in rids:
order_plant.request_manager.start(
rid, request={"template_id": 312}, expected_response={"user_msg": [rid]}
)
await asyncio.gather(*(order_plant._process_response(_terminal_ack(rid)) for rid in rids))
dropped = [rid for rid in rids if not order_plant.request_manager.responses.get(rid)]
assert not dropped
async def test_genuine_absence_still_times_out(order_plant):
# The fix must NOT mask a real no-response.
order_plant._send_request = AsyncMock()
with pytest.raises(asyncio.TimeoutError):
await order_plant.request_manager.send_and_collect(
timeout=0.2, user_msg="req-none", template_id=312, expected_response={}
)Result on top of
Full suite: 🤖 Generated with Claude Code |
|
I am not familiar enough with the library to assess if this correct, or my Claude do believe so. It wrote a test for it. I will investigate a bit more. We did have an issue that supposedly was fixed by it. I am running live 20+ bots and it has been stable the last month or so with this fix, then it is certainly not dangereous |
|
here is Claude analysis (take with Caution) Every request (submit/modify/cancel) goes through _send_and_collect → RequestManager:
Why stage 3 dropped it _process_response classifies every incoming frame by one signal — the presence of rp_code:
The exception is a hard-coded allow-list, _terminal_carries_data, for endpoints where that terminal frame is itself the payload Order acks are exactly that shape: a single 313/315/317/331 frame carrying rp_code='0' and the order details — there's no separate Why it's genuinely bad (and sneaky)
|
|
I will look into this some more next week |
What this fixes
Under concurrent order submits on a single Rithmic session, an order-path
acknowledgement can arrive as a terminal-only response (
rp_code == '0',no data frame preceding it).
BasePlant._process_responsetreats a terminalframe as a completion signal and jumps straight to
mark_complete— it onlycalls
handle_responsefirst when thetemplate_idis in the_terminal_carries_dataallow-list.The order-ack templates were not in that set:
313new-order ack (request312)315modify-order ack (request314)317cancel-order ack (request316)331bracket-order ack (request330)So the ack was discarded before being stored,
send_and_collectreturned anempty list, and the caller raised
Rithmic empty response— a silentlymissed order.
Why it only shows under concurrency
A single in-flight order usually receives a data frame followed by a terminal
frame, so the data frame gets stored regardless of the allow-list. Under
concurrent submits on one session the data frame is lost in the race and the
terminal frame is the only one that arrives — which is exactly the case the
allow-list was dropping. That's why single-order tests pass while production
under load misses fills.
We hit this on a live prop-firm account on 2026-04-23: N accounts submitting on
one session, each request getting only its terminal ack.
The fix
One-line allow-list extension in
async_rithmic/plants/base.py. The terminalack is now stored via
handle_responsebeforemark_complete, sosend_and_collectreturns it.Regression test
tests/test_order_terminal_response.pyexercises the realBasePlant._process_response+RequestManageragainst a realOrderPlantand real protobuf
ResponseNewOrderframes — no live connection, noapplication code, fully self-contained. Three cases:
test_terminal_only_order_ack_is_stored— a single terminal-only313(rp_code
0) is stored, not dropped.test_concurrent_terminal_only_acks_all_stored— 6 concurrentterminal-only acks are all stored (mirrors the live incident).
test_genuine_absence_still_times_out— the fix must not mask a realno-response: when nothing arrives,
send_and_collectstill times out.Fails on the old allow-list
{11, 15, 114, 301}; passes with{11, 15, 114, 301, 313, 315, 317, 331}. Full suite green (pytest -q→ allpassing) on top of
v1.6.1.🤖 Generated with Claude Code