fixtureqa 0.4.9__tar.gz → 0.4.11__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fixtureqa-0.4.9/fixtureqa.egg-info → fixtureqa-0.4.11}/PKG-INFO +1 -1
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/perf_engine.py +161 -24
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/perf_models.py +13 -1
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/perf_stats.py +6 -0
- fixtureqa-0.4.9/fixture/static/assets/index-DrmyYeG0.js → fixtureqa-0.4.11/fixture/static/assets/index-DHd_iEnT.js +22 -22
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/static/index.html +1 -1
- {fixtureqa-0.4.9 → fixtureqa-0.4.11/fixtureqa.egg-info}/PKG-INFO +1 -1
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixtureqa.egg-info/SOURCES.txt +1 -1
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/pyproject.toml +1 -1
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_perf_engine.py +189 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/LICENSE +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/README.md +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/__init__.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/__main__.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/api/__init__.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/api/app.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/api/connection_manager.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/api/deps.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/api/routers/__init__.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/api/routers/admin.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/api/routers/auth.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/api/routers/branding.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/api/routers/custom_tags.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/api/routers/fix_spec.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/api/routers/messages.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/api/routers/perf.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/api/routers/scenarios.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/api/routers/sessions.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/api/routers/setup.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/api/routers/spec_overlay.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/api/routers/templates.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/api/routers/ws.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/api/schemas.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/config/__init__.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/__init__.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/atomic_io.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/auth.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/config_store.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/custom_tag_store.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/db_migrations.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/events.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/exec_csv_writer.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/fix_application.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/fix_builder.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/fix_parser.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/fix_spec_parser.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/fix_tags.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/fix_time.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/housekeeping.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/inbound.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/json_store.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/message_log.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/message_store.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/models.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/perf_payload.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/perf_store.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/perf_writer.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/scenario_runner.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/scenario_store.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/session.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/session_manager.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/spec_overlay_store.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/template_store.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/user_store.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/venue_responses.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/fix_specs/FIX42.xml +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/fix_specs/FIX44.xml +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/server.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/static/assets/index-BwQf-cei.css +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/static/assets/index-CyNOPa0n.js +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/static/favicon.svg +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/ui/__init__.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixtureqa.egg-info/dependency_links.txt +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixtureqa.egg-info/entry_points.txt +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixtureqa.egg-info/requires.txt +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixtureqa.egg-info/top_level.txt +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/setup.cfg +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_atomic_io.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_auth.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_config_store.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_connection_manager.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_db_migrations.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_fix_builder.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_health.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_inbound.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_inbound_validation.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_message_store.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_perf_api.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_perf_models.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_perf_payload.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_perf_rehydrate.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_scenarios.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_session_lifecycle.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_session_manager_concurrency.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_sessions.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_templates.py +0 -0
- {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_ws.py +0 -0
|
@@ -25,6 +25,7 @@ import os
|
|
|
25
25
|
import random
|
|
26
26
|
import time
|
|
27
27
|
import uuid
|
|
28
|
+
from collections import deque
|
|
28
29
|
from typing import Optional
|
|
29
30
|
|
|
30
31
|
from fixcore.message.message import Message
|
|
@@ -111,6 +112,13 @@ class PerfRun:
|
|
|
111
112
|
self._amend_on = config.amend.enabled and self._has_client
|
|
112
113
|
self._amend_tasks: set[asyncio.Task] = set()
|
|
113
114
|
|
|
115
|
+
# Venue order book: corr → resumable fill state. The dispatcher books
|
|
116
|
+
# orders; _fill_sender drains them round-robin (via _fill_runnable),
|
|
117
|
+
# pausing at predicted amend trigger points when amends are enabled.
|
|
118
|
+
self._venue_orders: dict[str, dict] = {}
|
|
119
|
+
self._fill_runnable: deque[str] = deque()
|
|
120
|
+
self._fill_wakeup = asyncio.Event()
|
|
121
|
+
|
|
114
122
|
# PayloadFactory validates templates/tokens now — raises PerfConfigError
|
|
115
123
|
# (surfaced as 422) before any task starts.
|
|
116
124
|
self._factory = PayloadFactory(config, template_store, owner_uid,
|
|
@@ -180,7 +188,7 @@ class PerfRun:
|
|
|
180
188
|
if self._has_client:
|
|
181
189
|
tasks += [self._injector(), self._correlator(), self._timeout_sweeper()]
|
|
182
190
|
if self._has_venue:
|
|
183
|
-
tasks += [self._fill_dispatcher()]
|
|
191
|
+
tasks += [self._fill_dispatcher(), self._fill_sender()]
|
|
184
192
|
self._tasks = [asyncio.create_task(t) for t in tasks]
|
|
185
193
|
dur = self.config.test.duration
|
|
186
194
|
try:
|
|
@@ -218,6 +226,8 @@ class PerfRun:
|
|
|
218
226
|
self.stats.amends_lost += 1
|
|
219
227
|
self._finalize(entry, "filled" if entry.get("completed") else "lost")
|
|
220
228
|
self._pending.clear()
|
|
229
|
+
self._venue_orders.clear()
|
|
230
|
+
self._fill_runnable.clear()
|
|
221
231
|
if self._client_sub:
|
|
222
232
|
self._client_sub.close()
|
|
223
233
|
if self._venue_sub:
|
|
@@ -245,8 +255,13 @@ class PerfRun:
|
|
|
245
255
|
r = self.config.rate
|
|
246
256
|
fill_rate = r.fills_per_window / (r.fill_window_ms / 1000.0) # fills/sec
|
|
247
257
|
quiet = max(0.3, (3.0 / fill_rate) if fill_rate > 0 else 0.3)
|
|
248
|
-
|
|
249
|
-
|
|
258
|
+
# Backlog = unread inbound orders (not yet booked) + booked orders'
|
|
259
|
+
# remaining scheduled fills. Held orders resume via grace at the
|
|
260
|
+
# latest (no new holds once injection stops), so add one grace.
|
|
261
|
+
backlog = (self._venue_sub.qsize() * max(1, self.config.payload.fill.fills_per_order)
|
|
262
|
+
+ sum(len(e["schedule"]) - e["idx"] for e in self._venue_orders.values()))
|
|
263
|
+
cap = time.perf_counter() + max(30.0, (backlog / max(1.0, fill_rate)) * 2.0
|
|
264
|
+
+ self.config.amend.amend_wait_ms / 1000.0 + 10.0)
|
|
250
265
|
|
|
251
266
|
last_sent = self.stats.fills_sent + self.stats.acks_sent
|
|
252
267
|
last_activity = time.perf_counter()
|
|
@@ -260,10 +275,12 @@ class PerfRun:
|
|
|
260
275
|
if cur_sent != last_sent:
|
|
261
276
|
last_sent = cur_sent
|
|
262
277
|
last_activity = now
|
|
263
|
-
# Done when the venue has drained its backlog
|
|
264
|
-
#
|
|
265
|
-
#
|
|
278
|
+
# Done when the venue has drained its backlog (inbound queue AND
|
|
279
|
+
# order book), the client has received the fills, and no new
|
|
280
|
+
# fill/ack has gone out for a quiet window (covers the
|
|
281
|
+
# rate-limited last order still being filled).
|
|
266
282
|
quiet_ok = (self._venue_sub.qsize() == 0 and self._client_sub.qsize() == 0
|
|
283
|
+
and not self._venue_orders
|
|
267
284
|
and (now - last_activity) >= quiet)
|
|
268
285
|
if quiet_ok:
|
|
269
286
|
break
|
|
@@ -396,6 +413,10 @@ class PerfRun:
|
|
|
396
413
|
# ------------------------------------------------------------------
|
|
397
414
|
|
|
398
415
|
async def _fill_dispatcher(self) -> None:
|
|
416
|
+
"""Venue inbound router. Never blocks on fill pacing: orders are booked
|
|
417
|
+
with a resumable schedule for _fill_sender, so an inbound 35=G is
|
|
418
|
+
handled the moment it arrives instead of queueing behind the very
|
|
419
|
+
fills that triggered it."""
|
|
399
420
|
cfg = self.config
|
|
400
421
|
f = cfg.payload.fill
|
|
401
422
|
sub = self._venue_sub
|
|
@@ -409,9 +430,9 @@ class PerfRun:
|
|
|
409
430
|
self.stats.orders_received += 1
|
|
410
431
|
of = inb.fields
|
|
411
432
|
order_qty = _to_int(of.get(T_ORDERQTY), 0)
|
|
412
|
-
|
|
433
|
+
corr = of.get(cfg.correlation_tag)
|
|
434
|
+
if corr is None or order_qty <= 0:
|
|
413
435
|
continue
|
|
414
|
-
px = of.get(T_PRICE) or "100.00"
|
|
415
436
|
|
|
416
437
|
# 1. Ack the order first (New) — immediate, not rate-limited.
|
|
417
438
|
if f.send_ack:
|
|
@@ -420,37 +441,127 @@ class PerfRun:
|
|
|
420
441
|
if ack is not None and await self._sm.send_message(cfg.venue_session_id, ack):
|
|
421
442
|
self.stats.acks_sent += 1
|
|
422
443
|
|
|
423
|
-
# 2.
|
|
444
|
+
# 2. Book the fills — gated by fill_ratio (a skipped order stays
|
|
445
|
+
# acked/open and is never booked).
|
|
424
446
|
if cfg.rate.fill_ratio < 1.0 and random.random() > cfg.rate.fill_ratio:
|
|
425
447
|
continue
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
448
|
+
schedule = self._fill_schedule(order_qty)
|
|
449
|
+
if not schedule:
|
|
450
|
+
continue
|
|
451
|
+
self._venue_orders[corr] = {
|
|
452
|
+
"of": of, "schedule": schedule, "idx": 0, "cum": 0,
|
|
453
|
+
"px": of.get(T_PRICE) or "100.00", "order_qty": order_qty,
|
|
454
|
+
"pauses_done": 0, "waiting": False, "wait_deadline": 0.0,
|
|
455
|
+
}
|
|
456
|
+
self._fill_runnable.append(corr)
|
|
457
|
+
self._fill_wakeup.set()
|
|
458
|
+
|
|
459
|
+
async def _fill_sender(self) -> None:
|
|
460
|
+
"""Drains all booked orders' fills round-robin through the shared fill
|
|
461
|
+
bucket — one order holding for its amend never blocks the others.
|
|
462
|
+
|
|
463
|
+
Amend-aware hold: after fill k of a still-working order, when amends
|
|
464
|
+
are enabled and k is a predicted trigger point (k % every_n_fills == 0,
|
|
465
|
+
pause cap not reached), the order waits up to amend_wait_ms for its
|
|
466
|
+
35=G; _handle_amend resumes it early, the grace check resumes it late.
|
|
467
|
+
No holds when amends are off or once injection has stopped (drain)."""
|
|
468
|
+
cfg = self.config
|
|
469
|
+
a = cfg.amend
|
|
470
|
+
book = self._venue_orders
|
|
471
|
+
runnable = self._fill_runnable
|
|
472
|
+
next_wait_check = 0.0
|
|
473
|
+
while True:
|
|
474
|
+
now = time.monotonic()
|
|
475
|
+
if now >= next_wait_check:
|
|
476
|
+
self._release_expired_waits(now)
|
|
477
|
+
next_wait_check = now + 0.05
|
|
478
|
+
if not runnable:
|
|
479
|
+
self._fill_wakeup.clear()
|
|
480
|
+
if not runnable:
|
|
481
|
+
try:
|
|
482
|
+
await asyncio.wait_for(self._fill_wakeup.wait(), timeout=0.05)
|
|
483
|
+
except asyncio.TimeoutError:
|
|
484
|
+
pass
|
|
485
|
+
continue
|
|
486
|
+
corr = runnable.popleft()
|
|
487
|
+
entry = book.get(corr)
|
|
488
|
+
if entry is None or entry["waiting"]:
|
|
489
|
+
continue # completed meanwhile, or re-queued while holding
|
|
490
|
+
await self._fill_bucket.acquire()
|
|
491
|
+
et, ost, last_qty, cum_qty, leaves_qty = entry["schedule"][entry["idx"]]
|
|
492
|
+
er = self._exec(entry["of"], exec_type=et, ord_status=ost, last_qty=last_qty,
|
|
493
|
+
cum_qty=cum_qty, leaves_qty=leaves_qty, px=entry["px"])
|
|
494
|
+
if er is not None and await self._sm.send_message(cfg.venue_session_id, er):
|
|
495
|
+
self.stats.fills_sent += 1
|
|
496
|
+
entry["idx"] += 1
|
|
497
|
+
entry["cum"] = cum_qty
|
|
498
|
+
if entry["idx"] >= len(entry["schedule"]):
|
|
499
|
+
book.pop(corr, None)
|
|
500
|
+
elif (a.enabled and self._injecting
|
|
501
|
+
and entry["idx"] % a.every_n_fills == 0
|
|
502
|
+
and (a.max_per_order == 0 or entry["pauses_done"] < a.max_per_order)):
|
|
503
|
+
entry["waiting"] = True
|
|
504
|
+
entry["wait_deadline"] = time.monotonic() + a.amend_wait_ms / 1000.0
|
|
505
|
+
else:
|
|
506
|
+
runnable.append(corr)
|
|
507
|
+
|
|
508
|
+
def _release_expired_waits(self, now: float) -> None:
|
|
509
|
+
"""Resume held orders whose 35=G never came (amend_ratio < 1, client
|
|
510
|
+
cap reached, or the amend was rejected upstream)."""
|
|
511
|
+
for corr, entry in self._venue_orders.items():
|
|
512
|
+
if entry["waiting"] and now >= entry["wait_deadline"]:
|
|
513
|
+
entry["waiting"] = False
|
|
514
|
+
entry["pauses_done"] += 1 # this trigger point is consumed
|
|
515
|
+
self.stats.amend_waits_expired += 1
|
|
516
|
+
self._fill_runnable.append(corr)
|
|
432
517
|
|
|
433
518
|
async def _handle_amend(self, of: dict) -> None:
|
|
434
519
|
"""Venue side of 35=G: reply Replaced (150=5/39=5), optionally preceded
|
|
435
|
-
by Pending Replace (150=E/39=E).
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
520
|
+
by Pending Replace (150=E/39=E). With the order in the book the reply
|
|
521
|
+
carries the REAL CumQty/LeavesQty, the order's price is updated so the
|
|
522
|
+
remaining fills go out at the amended price, and a held order resumes
|
|
523
|
+
immediately. Unknown orders (already completed, a late G, or venue
|
|
524
|
+
mode against an external client) fall back to the stateless reply
|
|
525
|
+
(CumQty=0). Like acks, replies are immediate, not fill-rate-limited."""
|
|
439
526
|
cfg = self.config
|
|
440
527
|
self.stats.amends_received += 1
|
|
441
|
-
|
|
528
|
+
corr = of.get(cfg.correlation_tag)
|
|
529
|
+
if corr is None:
|
|
530
|
+
# Can't reply — there is nothing to echo for the client to correlate.
|
|
531
|
+
# Classic cause: the OMS rebuilds the 35=G downstream and drops the
|
|
532
|
+
# custom tag it passes through fine on NewOrderSingle.
|
|
533
|
+
self.stats.amends_uncorrelated += 1
|
|
534
|
+
if self.stats.amends_uncorrelated == 1:
|
|
535
|
+
self.warnings.append(
|
|
536
|
+
f"venue received 35=G without correlation tag "
|
|
537
|
+
f"{cfg.correlation_tag} — no replace ack sent; check the "
|
|
538
|
+
f"counterparty passes the tag through on amends")
|
|
442
539
|
return
|
|
443
540
|
order_qty = _to_int(of.get(T_ORDERQTY), 0)
|
|
444
|
-
px = of.get(T_PRICE) or "0"
|
|
445
541
|
orig = of.get(T_ORIGCLORDID)
|
|
542
|
+
entry = self._venue_orders.get(corr)
|
|
543
|
+
if entry is not None:
|
|
544
|
+
new_px = of.get(T_PRICE)
|
|
545
|
+
if new_px:
|
|
546
|
+
entry["px"] = new_px
|
|
547
|
+
cum = entry["cum"]
|
|
548
|
+
leaves = max(0, entry["order_qty"] - cum)
|
|
549
|
+
px = entry["px"]
|
|
550
|
+
entry["pauses_done"] += 1
|
|
551
|
+
if entry["waiting"]:
|
|
552
|
+
entry["waiting"] = False
|
|
553
|
+
self._fill_runnable.append(corr)
|
|
554
|
+
self._fill_wakeup.set()
|
|
555
|
+
else:
|
|
556
|
+
cum, leaves, px = 0, order_qty, of.get(T_PRICE) or "0"
|
|
446
557
|
if cfg.amend.pending_replace:
|
|
447
558
|
er = self._build_exec(of, exec_type="E", ord_status="E", last_qty=0,
|
|
448
|
-
cum_qty=
|
|
559
|
+
cum_qty=cum, leaves_qty=leaves, px=px,
|
|
449
560
|
orig_clordid=orig)
|
|
450
561
|
if er is not None:
|
|
451
562
|
await self._sm.send_message(cfg.venue_session_id, er)
|
|
452
563
|
er = self._build_exec(of, exec_type="5", ord_status="5", last_qty=0,
|
|
453
|
-
cum_qty=
|
|
564
|
+
cum_qty=cum, leaves_qty=leaves, px=px,
|
|
454
565
|
orig_clordid=orig)
|
|
455
566
|
if er is not None:
|
|
456
567
|
await self._sm.send_message(cfg.venue_session_id, er)
|
|
@@ -540,10 +651,33 @@ class PerfRun:
|
|
|
540
651
|
sub = self._client_sub
|
|
541
652
|
while True:
|
|
542
653
|
inb = await sub.get()
|
|
654
|
+
if inb.msg_type == "9":
|
|
655
|
+
self._on_cancel_reject(inb.fields)
|
|
656
|
+
continue
|
|
543
657
|
if inb.msg_type != "8":
|
|
544
658
|
continue
|
|
545
659
|
self._on_exec_report(inb.fields, inb.recv_perf_ns or time.perf_counter_ns())
|
|
546
660
|
|
|
661
|
+
def _on_cancel_reject(self, f: dict) -> None:
|
|
662
|
+
"""OrderCancelReject (35=9): the counterparty refused the amend. Count
|
|
663
|
+
it (these would otherwise be invisible — only 35=8 is correlated) and
|
|
664
|
+
release the in-flight amend so the order finalizes normally instead of
|
|
665
|
+
being reaped as amends_lost."""
|
|
666
|
+
self.stats.amend_rejects += 1
|
|
667
|
+
if self.stats.amend_rejects == 1:
|
|
668
|
+
self.warnings.append(
|
|
669
|
+
"counterparty rejected an amend (35=9 OrderCancelReject) — "
|
|
670
|
+
"it may not match OrigClOrdID(41) against its own order chain")
|
|
671
|
+
corr = f.get(self.config.correlation_tag)
|
|
672
|
+
entry = self._pending.get(corr) if corr is not None else None
|
|
673
|
+
if entry is None or not entry.get("amend_inflight"):
|
|
674
|
+
return
|
|
675
|
+
entry["amend_inflight"] = False
|
|
676
|
+
entry["pending_clordid"] = None # chain tip stays at the last accepted
|
|
677
|
+
if entry.get("completed"):
|
|
678
|
+
self._pending.pop(corr, None)
|
|
679
|
+
self._finalize(entry, "filled")
|
|
680
|
+
|
|
547
681
|
def _on_exec_report(self, f: dict, now_ns: int) -> None:
|
|
548
682
|
cfg = self.config
|
|
549
683
|
corr = f.get(cfg.correlation_tag)
|
|
@@ -722,6 +856,7 @@ class PerfRun:
|
|
|
722
856
|
amends_sent=self.stats.amends_sent,
|
|
723
857
|
replaces_received=self.stats.replaces_received,
|
|
724
858
|
amends_lost=self.stats.amends_lost,
|
|
859
|
+
amend_rejects=self.stats.amend_rejects,
|
|
725
860
|
aps_live=round(aps, 1),
|
|
726
861
|
scenarios_dispatched=self.stats.scenarios_dispatched,
|
|
727
862
|
scenarios_per_sec=round(sps, 1),
|
|
@@ -735,7 +870,9 @@ class PerfRun:
|
|
|
735
870
|
fills_sent=self.stats.fills_sent, fps_live=round(fps, 1),
|
|
736
871
|
fill_ratio=round(self.stats.fill_ratio(), 3),
|
|
737
872
|
unfilled=max(0, self.stats.orders_sent - self.stats.completions),
|
|
738
|
-
amends_received=self.stats.amends_received
|
|
873
|
+
amends_received=self.stats.amends_received,
|
|
874
|
+
amends_uncorrelated=self.stats.amends_uncorrelated,
|
|
875
|
+
amend_waits_expired=self.stats.amend_waits_expired)
|
|
739
876
|
snap = LiveSnapshot(
|
|
740
877
|
run_id=self.run_id, status=self.status, mode=self.config.mode,
|
|
741
878
|
elapsed_s=round(self._elapsed_s(), 1), duration_s=self.config.test.duration,
|
|
@@ -66,13 +66,22 @@ class AmendConfig(BaseModel):
|
|
|
66
66
|
the price by ± `price_offset` (alternating). `pending_replace` makes the
|
|
67
67
|
in-process venue reply 150=E (Pending Replace) before 150=5 (Replaced),
|
|
68
68
|
matching common real-venue behaviour. Amends never fire on the final fill
|
|
69
|
-
— use partial fills or fills_per_order > 1 to leave the order working.
|
|
69
|
+
— use partial fills or fills_per_order > 1 to leave the order working.
|
|
70
|
+
|
|
71
|
+
When enabled, the in-process venue is amend-aware: it holds an order's
|
|
72
|
+
remaining fills at each predicted trigger point (every nth fill) for up
|
|
73
|
+
to `amend_wait_ms`, so the 35=G is answered while the order is still
|
|
74
|
+
working — with the real CumQty — and post-replace fills go out at the
|
|
75
|
+
amended price. Orders whose amend never arrives (amend_ratio < 1, cap
|
|
76
|
+
reached, or rejected upstream) resume after the grace. Disabled → no
|
|
77
|
+
holds, fills flow exactly as before."""
|
|
70
78
|
enabled: bool = False
|
|
71
79
|
every_n_fills: int = Field(default=1, ge=1)
|
|
72
80
|
amend_ratio: float = Field(default=1.0, ge=0.0, le=1.0)
|
|
73
81
|
max_per_order: int = Field(default=1, ge=0)
|
|
74
82
|
price_offset: float = Field(default=0.01, gt=0)
|
|
75
83
|
pending_replace: bool = False
|
|
84
|
+
amend_wait_ms: int = Field(default=2000, gt=0)
|
|
76
85
|
|
|
77
86
|
|
|
78
87
|
class OrderPayloadConfig(BaseModel):
|
|
@@ -199,6 +208,7 @@ class ClientLeg(BaseModel):
|
|
|
199
208
|
amends_sent: int = 0
|
|
200
209
|
replaces_received: int = 0
|
|
201
210
|
amends_lost: int = 0
|
|
211
|
+
amend_rejects: int = 0
|
|
202
212
|
aps_live: float = 0.0
|
|
203
213
|
scenarios_dispatched: int = 0
|
|
204
214
|
scenarios_per_sec: float = 0.0
|
|
@@ -215,6 +225,8 @@ class VenueLeg(BaseModel):
|
|
|
215
225
|
unfilled: int = 0
|
|
216
226
|
venue_proc_us_p50: float = 0.0
|
|
217
227
|
amends_received: int = 0
|
|
228
|
+
amends_uncorrelated: int = 0
|
|
229
|
+
amend_waits_expired: int = 0
|
|
218
230
|
|
|
219
231
|
|
|
220
232
|
class SnapshotErrors(BaseModel):
|
|
@@ -122,7 +122,10 @@ class PerfStats:
|
|
|
122
122
|
self.amends_sent = 0 # client sent a 35=G
|
|
123
123
|
self.replaces_received = 0 # client got a Replaced ack (150=5)
|
|
124
124
|
self.amends_lost = 0 # amend in flight when its order was reaped
|
|
125
|
+
self.amend_rejects = 0 # client got an OrderCancelReject (35=9)
|
|
125
126
|
self.amends_received = 0 # venue saw an inbound 35=G
|
|
127
|
+
self.amends_uncorrelated = 0 # inbound 35=G lacked the correlation tag (no reply)
|
|
128
|
+
self.amend_waits_expired = 0 # venue fill-hold released by grace, not by a 35=G
|
|
126
129
|
self.lost_timeout = 0
|
|
127
130
|
self.dropped = 0
|
|
128
131
|
self.rejected = 0
|
|
@@ -179,7 +182,10 @@ class PerfStats:
|
|
|
179
182
|
"amends_sent": self.amends_sent,
|
|
180
183
|
"replaces_received": self.replaces_received,
|
|
181
184
|
"amends_lost": self.amends_lost,
|
|
185
|
+
"amend_rejects": self.amend_rejects,
|
|
182
186
|
"amends_received": self.amends_received,
|
|
187
|
+
"amends_uncorrelated": self.amends_uncorrelated,
|
|
188
|
+
"amend_waits_expired": self.amend_waits_expired,
|
|
183
189
|
"lost_timeout": self.lost_timeout,
|
|
184
190
|
"dropped": self.dropped,
|
|
185
191
|
"rejected": self.rejected,
|