fixtureqa 0.4.10__tar.gz → 0.4.12__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.10/fixtureqa.egg-info → fixtureqa-0.4.12}/PKG-INFO +1 -1
  2. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/perf_engine.py +127 -24
  3. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/perf_models.py +11 -1
  4. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/perf_payload.py +24 -4
  5. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/perf_stats.py +2 -0
  6. fixtureqa-0.4.10/fixture/static/assets/index-BFPDQdQb.js → fixtureqa-0.4.12/fixture/static/assets/index-DHd_iEnT.js +22 -22
  7. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/static/index.html +1 -1
  8. {fixtureqa-0.4.10 → fixtureqa-0.4.12/fixtureqa.egg-info}/PKG-INFO +1 -1
  9. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixtureqa.egg-info/SOURCES.txt +1 -1
  10. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/pyproject.toml +1 -1
  11. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_perf_engine.py +135 -0
  12. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_perf_payload.py +53 -0
  13. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/LICENSE +0 -0
  14. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/README.md +0 -0
  15. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/__init__.py +0 -0
  16. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/__main__.py +0 -0
  17. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/api/__init__.py +0 -0
  18. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/api/app.py +0 -0
  19. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/api/connection_manager.py +0 -0
  20. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/api/deps.py +0 -0
  21. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/api/routers/__init__.py +0 -0
  22. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/api/routers/admin.py +0 -0
  23. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/api/routers/auth.py +0 -0
  24. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/api/routers/branding.py +0 -0
  25. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/api/routers/custom_tags.py +0 -0
  26. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/api/routers/fix_spec.py +0 -0
  27. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/api/routers/messages.py +0 -0
  28. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/api/routers/perf.py +0 -0
  29. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/api/routers/scenarios.py +0 -0
  30. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/api/routers/sessions.py +0 -0
  31. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/api/routers/setup.py +0 -0
  32. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/api/routers/spec_overlay.py +0 -0
  33. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/api/routers/templates.py +0 -0
  34. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/api/routers/ws.py +0 -0
  35. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/api/schemas.py +0 -0
  36. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/config/__init__.py +0 -0
  37. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/__init__.py +0 -0
  38. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/atomic_io.py +0 -0
  39. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/auth.py +0 -0
  40. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/config_store.py +0 -0
  41. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/custom_tag_store.py +0 -0
  42. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/db_migrations.py +0 -0
  43. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/events.py +0 -0
  44. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/exec_csv_writer.py +0 -0
  45. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/fix_application.py +0 -0
  46. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/fix_builder.py +0 -0
  47. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/fix_parser.py +0 -0
  48. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/fix_spec_parser.py +0 -0
  49. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/fix_tags.py +0 -0
  50. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/fix_time.py +0 -0
  51. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/housekeeping.py +0 -0
  52. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/inbound.py +0 -0
  53. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/json_store.py +0 -0
  54. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/message_log.py +0 -0
  55. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/message_store.py +0 -0
  56. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/models.py +0 -0
  57. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/perf_store.py +0 -0
  58. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/perf_writer.py +0 -0
  59. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/scenario_runner.py +0 -0
  60. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/scenario_store.py +0 -0
  61. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/session.py +0 -0
  62. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/session_manager.py +0 -0
  63. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/spec_overlay_store.py +0 -0
  64. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/template_store.py +0 -0
  65. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/user_store.py +0 -0
  66. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/venue_responses.py +0 -0
  67. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/fix_specs/FIX42.xml +0 -0
  68. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/fix_specs/FIX44.xml +0 -0
  69. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/server.py +0 -0
  70. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
  71. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/static/assets/index-BwQf-cei.css +0 -0
  72. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/static/assets/index-CyNOPa0n.js +0 -0
  73. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
  74. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/static/favicon.svg +0 -0
  75. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/ui/__init__.py +0 -0
  76. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixtureqa.egg-info/dependency_links.txt +0 -0
  77. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixtureqa.egg-info/entry_points.txt +0 -0
  78. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixtureqa.egg-info/requires.txt +0 -0
  79. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixtureqa.egg-info/top_level.txt +0 -0
  80. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/setup.cfg +0 -0
  81. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_atomic_io.py +0 -0
  82. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_auth.py +0 -0
  83. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_config_store.py +0 -0
  84. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_connection_manager.py +0 -0
  85. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_db_migrations.py +0 -0
  86. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_fix_builder.py +0 -0
  87. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_health.py +0 -0
  88. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_inbound.py +0 -0
  89. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_inbound_validation.py +0 -0
  90. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_message_store.py +0 -0
  91. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_perf_api.py +0 -0
  92. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_perf_models.py +0 -0
  93. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_perf_rehydrate.py +0 -0
  94. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_scenarios.py +0 -0
  95. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_session_lifecycle.py +0 -0
  96. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_session_manager_concurrency.py +0 -0
  97. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_sessions.py +0 -0
  98. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_templates.py +0 -0
  99. {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_ws.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fixtureqa
3
- Version: 0.4.10
3
+ Version: 0.4.12
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,25 +441,92 @@ 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:
442
530
  # Can't reply — there is nothing to echo for the client to correlate.
443
531
  # Classic cause: the OMS rebuilds the 35=G downstream and drops the
444
532
  # custom tag it passes through fine on NewOrderSingle.
@@ -450,16 +538,30 @@ class PerfRun:
450
538
  f"counterparty passes the tag through on amends")
451
539
  return
452
540
  order_qty = _to_int(of.get(T_ORDERQTY), 0)
453
- px = of.get(T_PRICE) or "0"
454
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"
455
557
  if cfg.amend.pending_replace:
456
558
  er = self._build_exec(of, exec_type="E", ord_status="E", last_qty=0,
457
- cum_qty=0, leaves_qty=order_qty, px=px,
559
+ cum_qty=cum, leaves_qty=leaves, px=px,
458
560
  orig_clordid=orig)
459
561
  if er is not None:
460
562
  await self._sm.send_message(cfg.venue_session_id, er)
461
563
  er = self._build_exec(of, exec_type="5", ord_status="5", last_qty=0,
462
- cum_qty=0, leaves_qty=order_qty, px=px,
564
+ cum_qty=cum, leaves_qty=leaves, px=px,
463
565
  orig_clordid=orig)
464
566
  if er is not None:
465
567
  await self._sm.send_message(cfg.venue_session_id, er)
@@ -769,7 +871,8 @@ class PerfRun:
769
871
  fill_ratio=round(self.stats.fill_ratio(), 3),
770
872
  unfilled=max(0, self.stats.orders_sent - self.stats.completions),
771
873
  amends_received=self.stats.amends_received,
772
- amends_uncorrelated=self.stats.amends_uncorrelated)
874
+ amends_uncorrelated=self.stats.amends_uncorrelated,
875
+ amend_waits_expired=self.stats.amend_waits_expired)
773
876
  snap = LiveSnapshot(
774
877
  run_id=self.run_id, status=self.status, mode=self.config.mode,
775
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):
@@ -217,6 +226,7 @@ class VenueLeg(BaseModel):
217
226
  venue_proc_us_p50: float = 0.0
218
227
  amends_received: int = 0
219
228
  amends_uncorrelated: int = 0
229
+ amend_waits_expired: int = 0
220
230
 
221
231
 
222
232
  class SnapshotErrors(BaseModel):
@@ -59,6 +59,13 @@ _EXEC_TOKENS = {"cum_qty", "leaves_qty", "last_qty", "order_qty", "ord_status",
59
59
 
60
60
  _SIDE_BUY, _SIDE_SELL = "1", "2"
61
61
 
62
+ # Routing/identity tags mirrored from an order onto every 35=G in its chain —
63
+ # endpoints commonly require these to match the original order. Header tags
64
+ # 50/57/115/116/128/129/142/143/144/145 plus ClientID(109), the FIX 4.2 body
65
+ # tag for the same purpose. CompIDs (49/56) are transport-owned and excluded
66
+ # (the session stamps them).
67
+ AMEND_MIRROR_TAGS = (50, 57, 109, 115, 116, 128, 129, 142, 143, 144, 145)
68
+
62
69
 
63
70
  class PerfConfigError(Exception):
64
71
  """Raised for bad templates/tokens at run start → surfaced as 422."""
@@ -314,7 +321,8 @@ class PayloadFactory:
314
321
  self._apply_expiry(msg, self._gen_rules)
315
322
  return msg, {"corr_id": corr, "clordid": clordid, "symbol": ctx["symbol"],
316
323
  "side": ctx["side"], "qty": ctx["qty"], "price": ctx["price"],
317
- "order_qty": ctx["qty"], "msg_type": "D"}
324
+ "order_qty": ctx["qty"], "msg_type": "D",
325
+ "mirror_tags": self._mirror_tags(msg)}
318
326
 
319
327
  # -- template message (scenarios) ----------------------------------
320
328
 
@@ -340,7 +348,8 @@ class PayloadFactory:
340
348
  refs[tpl_id] = clordid
341
349
  return msg, {"corr_id": corr, "clordid": clordid, "symbol": ctx["symbol"],
342
350
  "side": ctx["side"], "qty": ctx["qty"], "price": ctx["price"],
343
- "order_qty": ctx["qty"], "msg_type": mtype}
351
+ "order_qty": ctx["qty"], "msg_type": mtype,
352
+ "mirror_tags": self._mirror_tags(msg)}
344
353
 
345
354
  def _resolve(self, value: str, ctx: dict, corr: str, clordid: str, refs: dict,
346
355
  tag: int = 0) -> str:
@@ -385,13 +394,21 @@ class PayloadFactory:
385
394
  def _clordid(self) -> str:
386
395
  return "PCL-" + _guid()[:12].upper()
387
396
 
397
+ @staticmethod
398
+ def _mirror_tags(msg: Message) -> dict[int, str]:
399
+ """Rendered routing/identity header tags present on this order — stamped
400
+ verbatim onto every amend in its chain."""
401
+ return {t: v for t in AMEND_MIRROR_TAGS if (v := msg.get_field_or(t, ""))}
402
+
388
403
  # -- amend (35=G OrderCancelReplaceRequest, price-only) -------------
389
404
 
390
405
  def build_amend(self, meta: dict, new_price: float) -> tuple[Message, str]:
391
406
  """Re-price a working order. Carries the order's correlation id so the
392
407
  replace acks route to the same pending entry; OrigClOrdID(41) is the
393
408
  current chain tip, ClOrdID(11) is fresh — returned so the engine can
394
- promote the chain once the venue confirms (150=5)."""
409
+ promote the chain once the venue confirms (150=5). Routing/identity
410
+ header tags (AMEND_MIRROR_TAGS) are echoed verbatim from the order —
411
+ endpoints commonly require them to match across the chain."""
395
412
  p = self.config.payload.order
396
413
  new_clordid = self._clordid()
397
414
  msg = Message()
@@ -399,6 +416,8 @@ class PayloadFactory:
399
416
  msg.set_field(11, new_clordid)
400
417
  msg.set_field(41, meta["clordid"])
401
418
  msg.set_field(self.config.correlation_tag, meta["corr_id"])
419
+ for tag, val in (meta.get("mirror_tags") or {}).items():
420
+ msg.set_field(tag, val)
402
421
  msg.set_field(55, meta["symbol"])
403
422
  msg.set_field(54, meta["side"])
404
423
  msg.set_field(38, str(meta["order_qty"]))
@@ -463,7 +482,8 @@ class PayloadFactory:
463
482
  self._apply_expiry(msg, fields)
464
483
  return msg, {"corr_id": corr, "clordid": clordid, "symbol": ctx["symbol"],
465
484
  "side": ctx["side"], "qty": ctx["qty"], "price": ctx["price"],
466
- "order_qty": order_qty, "msg_type": mtype}
485
+ "order_qty": order_qty, "msg_type": mtype,
486
+ "mirror_tags": self._mirror_tags(msg)}
467
487
 
468
488
  # -- Phase B: templated ExecutionReport (venue ack/fill) ----------------
469
489
 
@@ -125,6 +125,7 @@ class PerfStats:
125
125
  self.amend_rejects = 0 # client got an OrderCancelReject (35=9)
126
126
  self.amends_received = 0 # venue saw an inbound 35=G
127
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
128
129
  self.lost_timeout = 0
129
130
  self.dropped = 0
130
131
  self.rejected = 0
@@ -184,6 +185,7 @@ class PerfStats:
184
185
  "amend_rejects": self.amend_rejects,
185
186
  "amends_received": self.amends_received,
186
187
  "amends_uncorrelated": self.amends_uncorrelated,
188
+ "amend_waits_expired": self.amend_waits_expired,
187
189
  "lost_timeout": self.lost_timeout,
188
190
  "dropped": self.dropped,
189
191
  "rejected": self.rejected,