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.
Files changed (99) hide show
  1. {fixtureqa-0.4.9/fixtureqa.egg-info → fixtureqa-0.4.11}/PKG-INFO +1 -1
  2. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/perf_engine.py +161 -24
  3. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/perf_models.py +13 -1
  4. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/perf_stats.py +6 -0
  5. fixtureqa-0.4.9/fixture/static/assets/index-DrmyYeG0.js → fixtureqa-0.4.11/fixture/static/assets/index-DHd_iEnT.js +22 -22
  6. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/static/index.html +1 -1
  7. {fixtureqa-0.4.9 → fixtureqa-0.4.11/fixtureqa.egg-info}/PKG-INFO +1 -1
  8. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixtureqa.egg-info/SOURCES.txt +1 -1
  9. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/pyproject.toml +1 -1
  10. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_perf_engine.py +189 -0
  11. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/LICENSE +0 -0
  12. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/README.md +0 -0
  13. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/__init__.py +0 -0
  14. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/__main__.py +0 -0
  15. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/api/__init__.py +0 -0
  16. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/api/app.py +0 -0
  17. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/api/connection_manager.py +0 -0
  18. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/api/deps.py +0 -0
  19. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/api/routers/__init__.py +0 -0
  20. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/api/routers/admin.py +0 -0
  21. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/api/routers/auth.py +0 -0
  22. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/api/routers/branding.py +0 -0
  23. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/api/routers/custom_tags.py +0 -0
  24. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/api/routers/fix_spec.py +0 -0
  25. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/api/routers/messages.py +0 -0
  26. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/api/routers/perf.py +0 -0
  27. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/api/routers/scenarios.py +0 -0
  28. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/api/routers/sessions.py +0 -0
  29. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/api/routers/setup.py +0 -0
  30. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/api/routers/spec_overlay.py +0 -0
  31. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/api/routers/templates.py +0 -0
  32. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/api/routers/ws.py +0 -0
  33. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/api/schemas.py +0 -0
  34. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/config/__init__.py +0 -0
  35. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/__init__.py +0 -0
  36. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/atomic_io.py +0 -0
  37. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/auth.py +0 -0
  38. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/config_store.py +0 -0
  39. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/custom_tag_store.py +0 -0
  40. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/db_migrations.py +0 -0
  41. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/events.py +0 -0
  42. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/exec_csv_writer.py +0 -0
  43. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/fix_application.py +0 -0
  44. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/fix_builder.py +0 -0
  45. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/fix_parser.py +0 -0
  46. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/fix_spec_parser.py +0 -0
  47. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/fix_tags.py +0 -0
  48. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/fix_time.py +0 -0
  49. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/housekeeping.py +0 -0
  50. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/inbound.py +0 -0
  51. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/json_store.py +0 -0
  52. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/message_log.py +0 -0
  53. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/message_store.py +0 -0
  54. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/models.py +0 -0
  55. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/perf_payload.py +0 -0
  56. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/perf_store.py +0 -0
  57. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/perf_writer.py +0 -0
  58. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/scenario_runner.py +0 -0
  59. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/scenario_store.py +0 -0
  60. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/session.py +0 -0
  61. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/session_manager.py +0 -0
  62. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/spec_overlay_store.py +0 -0
  63. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/template_store.py +0 -0
  64. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/user_store.py +0 -0
  65. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/core/venue_responses.py +0 -0
  66. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/fix_specs/FIX42.xml +0 -0
  67. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/fix_specs/FIX44.xml +0 -0
  68. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/server.py +0 -0
  69. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
  70. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/static/assets/index-BwQf-cei.css +0 -0
  71. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/static/assets/index-CyNOPa0n.js +0 -0
  72. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
  73. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/static/favicon.svg +0 -0
  74. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixture/ui/__init__.py +0 -0
  75. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixtureqa.egg-info/dependency_links.txt +0 -0
  76. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixtureqa.egg-info/entry_points.txt +0 -0
  77. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixtureqa.egg-info/requires.txt +0 -0
  78. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/fixtureqa.egg-info/top_level.txt +0 -0
  79. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/setup.cfg +0 -0
  80. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_atomic_io.py +0 -0
  81. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_auth.py +0 -0
  82. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_config_store.py +0 -0
  83. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_connection_manager.py +0 -0
  84. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_db_migrations.py +0 -0
  85. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_fix_builder.py +0 -0
  86. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_health.py +0 -0
  87. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_inbound.py +0 -0
  88. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_inbound_validation.py +0 -0
  89. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_message_store.py +0 -0
  90. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_perf_api.py +0 -0
  91. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_perf_models.py +0 -0
  92. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_perf_payload.py +0 -0
  93. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_perf_rehydrate.py +0 -0
  94. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_scenarios.py +0 -0
  95. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_session_lifecycle.py +0 -0
  96. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_session_manager_concurrency.py +0 -0
  97. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_sessions.py +0 -0
  98. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_templates.py +0 -0
  99. {fixtureqa-0.4.9 → fixtureqa-0.4.11}/tests/test_ws.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fixtureqa
3
- Version: 0.4.9
3
+ Version: 0.4.11
4
4
  Summary: FIXture — FIX Protocol Testing Tool
5
5
  Requires-Python: >=3.10
6
6
  License-File: LICENSE
@@ -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
- backlog = self._venue_sub.qsize() * max(1, self.config.payload.fill.fills_per_order)
249
- cap = time.perf_counter() + max(30.0, (backlog / max(1.0, fill_rate)) * 2.0 + 10.0)
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, the client has received
264
- # the fills, and no new fill/ack has gone out for a quiet window
265
- # (covers the rate-limited last order still being filled).
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
- if of.get(cfg.correlation_tag) is None or order_qty <= 0:
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. Fills — gated by fill_ratio (a skipped order stays acked/open).
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
- for et, ost, last_qty, cum_qty, leaves_qty in self._fill_schedule(order_qty):
427
- await self._fill_bucket.acquire()
428
- er = self._exec(of, exec_type=et, ord_status=ost, last_qty=last_qty,
429
- cum_qty=cum_qty, leaves_qty=leaves_qty, px=px)
430
- if er is not None and await self._sm.send_message(cfg.venue_session_id, er):
431
- self.stats.fills_sent += 1
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). Stateless built from the request's
436
- own fields with no order book, so CumQty reads 0 even when the order is
437
- partially filled (the OMS under test reads 150/39 + the ClOrdID chain).
438
- Like acks, replies are immediate, not fill-rate-limited."""
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
- if of.get(cfg.correlation_tag) is None:
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=0, leaves_qty=order_qty, px=px,
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=0, leaves_qty=order_qty, px=px,
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,