fixtureqa 0.4.7__tar.gz → 0.4.8__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 (100) hide show
  1. {fixtureqa-0.4.7/fixtureqa.egg-info → fixtureqa-0.4.8}/PKG-INFO +1 -1
  2. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/core/perf_engine.py +136 -10
  3. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/core/perf_models.py +25 -0
  4. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/core/perf_payload.py +23 -0
  5. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/core/perf_stats.py +17 -4
  6. fixtureqa-0.4.8/fixture/static/assets/index-DrmyYeG0.js +102 -0
  7. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/static/index.html +1 -1
  8. {fixtureqa-0.4.7 → fixtureqa-0.4.8/fixtureqa.egg-info}/PKG-INFO +1 -1
  9. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixtureqa.egg-info/SOURCES.txt +1 -1
  10. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/pyproject.toml +1 -1
  11. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/tests/test_perf_engine.py +210 -1
  12. fixtureqa-0.4.7/fixture/static/assets/index-DISOjvhu.js +0 -102
  13. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/LICENSE +0 -0
  14. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/README.md +0 -0
  15. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/__init__.py +0 -0
  16. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/__main__.py +0 -0
  17. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/api/__init__.py +0 -0
  18. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/api/app.py +0 -0
  19. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/api/connection_manager.py +0 -0
  20. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/api/deps.py +0 -0
  21. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/api/routers/__init__.py +0 -0
  22. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/api/routers/admin.py +0 -0
  23. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/api/routers/auth.py +0 -0
  24. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/api/routers/branding.py +0 -0
  25. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/api/routers/custom_tags.py +0 -0
  26. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/api/routers/fix_spec.py +0 -0
  27. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/api/routers/messages.py +0 -0
  28. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/api/routers/perf.py +0 -0
  29. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/api/routers/scenarios.py +0 -0
  30. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/api/routers/sessions.py +0 -0
  31. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/api/routers/setup.py +0 -0
  32. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/api/routers/spec_overlay.py +0 -0
  33. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/api/routers/templates.py +0 -0
  34. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/api/routers/ws.py +0 -0
  35. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/api/schemas.py +0 -0
  36. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/config/__init__.py +0 -0
  37. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/core/__init__.py +0 -0
  38. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/core/atomic_io.py +0 -0
  39. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/core/auth.py +0 -0
  40. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/core/config_store.py +0 -0
  41. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/core/custom_tag_store.py +0 -0
  42. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/core/db_migrations.py +0 -0
  43. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/core/events.py +0 -0
  44. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/core/exec_csv_writer.py +0 -0
  45. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/core/fix_application.py +0 -0
  46. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/core/fix_builder.py +0 -0
  47. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/core/fix_parser.py +0 -0
  48. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/core/fix_spec_parser.py +0 -0
  49. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/core/fix_tags.py +0 -0
  50. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/core/fix_time.py +0 -0
  51. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/core/housekeeping.py +0 -0
  52. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/core/inbound.py +0 -0
  53. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/core/json_store.py +0 -0
  54. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/core/message_log.py +0 -0
  55. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/core/message_store.py +0 -0
  56. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/core/models.py +0 -0
  57. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/core/perf_store.py +0 -0
  58. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/core/perf_writer.py +0 -0
  59. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/core/scenario_runner.py +0 -0
  60. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/core/scenario_store.py +0 -0
  61. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/core/session.py +0 -0
  62. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/core/session_manager.py +0 -0
  63. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/core/spec_overlay_store.py +0 -0
  64. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/core/template_store.py +0 -0
  65. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/core/user_store.py +0 -0
  66. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/core/venue_responses.py +0 -0
  67. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/fix_specs/FIX42.xml +0 -0
  68. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/fix_specs/FIX44.xml +0 -0
  69. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/server.py +0 -0
  70. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
  71. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/static/assets/index-BwQf-cei.css +0 -0
  72. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/static/assets/index-CyNOPa0n.js +0 -0
  73. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
  74. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/static/favicon.svg +0 -0
  75. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixture/ui/__init__.py +0 -0
  76. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixtureqa.egg-info/dependency_links.txt +0 -0
  77. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixtureqa.egg-info/entry_points.txt +0 -0
  78. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixtureqa.egg-info/requires.txt +0 -0
  79. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/fixtureqa.egg-info/top_level.txt +0 -0
  80. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/setup.cfg +0 -0
  81. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/tests/test_atomic_io.py +0 -0
  82. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/tests/test_auth.py +0 -0
  83. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/tests/test_config_store.py +0 -0
  84. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/tests/test_connection_manager.py +0 -0
  85. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/tests/test_db_migrations.py +0 -0
  86. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/tests/test_fix_builder.py +0 -0
  87. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/tests/test_health.py +0 -0
  88. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/tests/test_inbound.py +0 -0
  89. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/tests/test_inbound_validation.py +0 -0
  90. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/tests/test_message_store.py +0 -0
  91. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/tests/test_perf_api.py +0 -0
  92. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/tests/test_perf_models.py +0 -0
  93. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/tests/test_perf_payload.py +0 -0
  94. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/tests/test_perf_rehydrate.py +0 -0
  95. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/tests/test_scenarios.py +0 -0
  96. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/tests/test_session_lifecycle.py +0 -0
  97. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/tests/test_session_manager_concurrency.py +0 -0
  98. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/tests/test_sessions.py +0 -0
  99. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/tests/test_templates.py +0 -0
  100. {fixtureqa-0.4.7 → fixtureqa-0.4.8}/tests/test_ws.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fixtureqa
3
- Version: 0.4.7
3
+ Version: 0.4.8
4
4
  Summary: FIXture — FIX Protocol Testing Tool
5
5
  Requires-Python: >=3.10
6
6
  License-File: LICENSE
@@ -42,7 +42,7 @@ from .session_manager import SessionManager
42
42
  logger = logging.getLogger(__name__)
43
43
 
44
44
  # FIX tags
45
- T_MSGTYPE, T_CLORDID, T_SYMBOL, T_SIDE = 35, 11, 55, 54
45
+ T_MSGTYPE, T_CLORDID, T_ORIGCLORDID, T_SYMBOL, T_SIDE = 35, 11, 41, 55, 54
46
46
  T_ORDERQTY, T_ORDTYPE, T_PRICE, T_TIF, T_TRANSACTTIME = 38, 40, 44, 59, 60
47
47
  T_ORDERID, T_EXECID, T_EXECTRANSTYPE, T_EXECTYPE, T_ORDSTATUS = 37, 17, 20, 150, 39
48
48
  T_LASTQTY, T_LASTPX, T_CUMQTY, T_LEAVESQTY, T_AVGPX = 32, 31, 14, 151, 6
@@ -107,6 +107,10 @@ class PerfRun:
107
107
  self._pending: dict[str, dict] = {}
108
108
  self._order_seq = 0
109
109
 
110
+ # Amend injection is fill-triggered on the client leg (35=D orders only).
111
+ self._amend_on = config.amend.enabled and self._has_client
112
+ self._amend_tasks: set[asyncio.Task] = set()
113
+
110
114
  # PayloadFactory validates templates/tokens now — raises PerfConfigError
111
115
  # (surfaced as 422) before any task starts.
112
116
  self._factory = PayloadFactory(config, template_store, owner_uid,
@@ -151,6 +155,10 @@ class PerfRun:
151
155
  else:
152
156
  self._exec_csv = ExecCsvWriter(
153
157
  os.path.join(self._execs_dir, f"{self.run_id}.csv.gz"))
158
+ if self.config.amend.enabled and not self._has_client:
159
+ self.warnings.append(
160
+ "amend injection has no effect in venue mode (no client leg); "
161
+ "inbound 35=G from the external client is still answered")
154
162
  if self._has_client:
155
163
  self._client_sub = self._sm.subscribe_session(self.config.client_session_id)
156
164
  if self._has_venue:
@@ -202,11 +210,13 @@ class PerfRun:
202
210
  await asyncio.sleep(0.02)
203
211
  else:
204
212
  await asyncio.sleep(_GRACE_DRAIN_S)
205
- for t in self._tasks:
213
+ for t in list(self._tasks) + list(self._amend_tasks):
206
214
  t.cancel()
207
- await asyncio.gather(*self._tasks, return_exceptions=True)
215
+ await asyncio.gather(*self._tasks, *self._amend_tasks, return_exceptions=True)
208
216
  for entry in list(self._pending.values()):
209
- self._finalize(entry, "lost")
217
+ if entry.get("amend_inflight"):
218
+ self.stats.amends_lost += 1
219
+ self._finalize(entry, "filled" if entry.get("completed") else "lost")
210
220
  self._pending.clear()
211
221
  if self._client_sub:
212
222
  self._client_sub.close()
@@ -316,6 +326,12 @@ class PerfRun:
316
326
  meta["sent_at"] = time.time()
317
327
  meta["responded"] = False
318
328
  meta["seen_exec"] = set()
329
+ if self._amend_on and meta.get("msg_type") == "D":
330
+ meta["amend_eligible"] = random.random() < self.config.amend.amend_ratio
331
+ meta["fills_seen"] = 0
332
+ meta["amends_sent"] = 0
333
+ meta["amend_inflight"] = False
334
+ meta["completed"] = False
319
335
  if resp_event is not None:
320
336
  meta["resp_event"] = resp_event
321
337
  self._pending[meta["corr_id"]] = meta
@@ -383,6 +399,9 @@ class PerfRun:
383
399
  sub = self._venue_sub
384
400
  while True:
385
401
  inb = await sub.get()
402
+ if inb.msg_type == "G":
403
+ await self._handle_amend(inb.fields)
404
+ continue
386
405
  if inb.msg_type != "D":
387
406
  continue
388
407
  self.stats.orders_received += 1
@@ -409,6 +428,31 @@ class PerfRun:
409
428
  if er is not None and await self._sm.send_message(cfg.venue_session_id, er):
410
429
  self.stats.fills_sent += 1
411
430
 
431
+ async def _handle_amend(self, of: dict) -> None:
432
+ """Venue side of 35=G: reply Replaced (150=5/39=5), optionally preceded
433
+ by Pending Replace (150=E/39=E). Stateless — built from the request's
434
+ own fields with no order book, so CumQty reads 0 even when the order is
435
+ partially filled (the OMS under test reads 150/39 + the ClOrdID chain).
436
+ Like acks, replies are immediate, not fill-rate-limited."""
437
+ cfg = self.config
438
+ self.stats.amends_received += 1
439
+ if of.get(cfg.correlation_tag) is None:
440
+ return
441
+ order_qty = _to_int(of.get(T_ORDERQTY), 0)
442
+ px = of.get(T_PRICE) or "0"
443
+ orig = of.get(T_ORIGCLORDID)
444
+ if cfg.amend.pending_replace:
445
+ er = self._build_exec(of, exec_type="E", ord_status="E", last_qty=0,
446
+ cum_qty=0, leaves_qty=order_qty, px=px,
447
+ orig_clordid=orig)
448
+ if er is not None:
449
+ await self._sm.send_message(cfg.venue_session_id, er)
450
+ er = self._build_exec(of, exec_type="5", ord_status="5", last_qty=0,
451
+ cum_qty=0, leaves_qty=order_qty, px=px,
452
+ orig_clordid=orig)
453
+ if er is not None:
454
+ await self._sm.send_message(cfg.venue_session_id, er)
455
+
412
456
  def _exec(self, of: dict, **kw) -> Optional[Message]:
413
457
  """Build one ExecReport via the exec template when configured, else the
414
458
  built-in builder (Phase B). Both enforce the standard correlation tags."""
@@ -450,8 +494,10 @@ class PerfRun:
450
494
  return schedule
451
495
 
452
496
  def _build_exec(self, of: dict, *, exec_type: str, ord_status: str,
453
- last_qty: int, cum_qty: int, leaves_qty: int, px: str) -> Optional[Message]:
454
- """Build one ExecutionReport (ack or fill) from an inbound order's fields."""
497
+ last_qty: int, cum_qty: int, leaves_qty: int, px: str,
498
+ orig_clordid: Optional[str] = None) -> Optional[Message]:
499
+ """Build one ExecutionReport (ack, fill or replace ack) from an inbound
500
+ message's fields."""
455
501
  cfg = self.config
456
502
  corr = of.get(cfg.correlation_tag)
457
503
  if corr is None:
@@ -462,6 +508,8 @@ class PerfRun:
462
508
  er.header.set(T_MSGTYPE, "8")
463
509
  if of.get(T_CLORDID):
464
510
  er.set_field(T_CLORDID, of[T_CLORDID])
511
+ if orig_clordid:
512
+ er.set_field(T_ORIGCLORDID, orig_clordid)
465
513
  er.set_field(cfg.correlation_tag, corr)
466
514
  er.set_field(cfg.exec_id_tag, _uuid())
467
515
  er.set_field(T_ORDERID, "O" + _guid()[:8].upper())
@@ -520,6 +568,14 @@ class PerfRun:
520
568
  ((now_ns - prev_ns) / 1000.0) if prev_ns is not None else None,
521
569
  ))
522
570
 
571
+ # Amend responses (Pending Replace / Replaced) are not order responses:
572
+ # they must not count as first response, a fill, or a completion.
573
+ exec_type = f.get(T_EXECTYPE)
574
+ if exec_type in ("E", "5"):
575
+ if exec_type == "5":
576
+ self._on_replaced(corr, entry, now_ns)
577
+ return
578
+
523
579
  if not entry["responded"]:
524
580
  entry["responded"] = True
525
581
  entry["first_resp_at"] = time.time()
@@ -532,7 +588,24 @@ class PerfRun:
532
588
  ev.set()
533
589
 
534
590
  cum = _to_int(f.get(T_CUMQTY), 0)
535
- if cum >= entry["order_qty"]:
591
+
592
+ # Amend trigger: every nth fill of a still-working order. Counting cum
593
+ # increases (not ExecType values) keeps it venue-agnostic (4.2's 150=1/2
594
+ # and 4.4's 150=F alike). Never fires on the final fill.
595
+ if self._amend_on and entry.get("amend_eligible") and cum > entry.get("cum_seen", 0):
596
+ entry["cum_seen"] = cum
597
+ entry["fills_seen"] += 1
598
+ a = self.config.amend
599
+ if (self._injecting and not entry["amend_inflight"]
600
+ and cum < entry["order_qty"]
601
+ and entry["fills_seen"] % a.every_n_fills == 0
602
+ and (a.max_per_order == 0 or entry["amends_sent"] < a.max_per_order)):
603
+ entry["amend_inflight"] = True
604
+ t = asyncio.create_task(self._send_amend(entry))
605
+ self._amend_tasks.add(t)
606
+ t.add_done_callback(self._amend_tasks.discard)
607
+
608
+ if cum >= entry["order_qty"] and not entry.get("completed"):
536
609
  lat_us = (now_ns - entry["sent_ns"]) / 1000.0
537
610
  entry["fill_latency_us"] = lat_us
538
611
  entry["filled_at"] = time.time()
@@ -540,9 +613,48 @@ class PerfRun:
540
613
  entry["fill_price"] = f.get(T_LASTPX) or f.get(T_AVGPX)
541
614
  self.stats.completions += 1
542
615
  self.stats.fill_completion_latency.add(lat_us)
616
+ if entry.get("amend_inflight"):
617
+ # The venue dispatches an order's fills before it reads the 35=G,
618
+ # so the Replaced ack usually lands after the final fill. Keep
619
+ # the entry so the ack still correlates; _on_replaced (or the
620
+ # sweeper / shutdown) finalizes it.
621
+ entry["completed"] = True
622
+ else:
623
+ entry["completed"] = True
624
+ self._pending.pop(corr, None)
625
+ self._finalize(entry, "filled")
626
+
627
+ def _on_replaced(self, corr: str, entry: dict, now_ns: int) -> None:
628
+ """Client got a Replaced ack (150=5): close the in-flight amend."""
629
+ self.stats.replaces_received += 1
630
+ sent_ns = entry.get("amend_sent_ns")
631
+ if entry.get("amend_inflight") and sent_ns:
632
+ self.stats.amend_latency.add((now_ns - sent_ns) / 1000.0)
633
+ if entry.get("pending_clordid"):
634
+ entry["clordid"] = entry["pending_clordid"]
635
+ entry["pending_clordid"] = None
636
+ entry["amend_inflight"] = False
637
+ if entry.get("completed"):
543
638
  self._pending.pop(corr, None)
544
639
  self._finalize(entry, "filled")
545
640
 
641
+ async def _send_amend(self, entry: dict) -> None:
642
+ """Build and send one price amend (alternating ± offset). Demand-driven
643
+ by fills (rate ≈ fills/s ÷ every_n_fills) — no pacer of its own."""
644
+ a = self.config.amend
645
+ n = entry["amends_sent"]
646
+ base = _to_float(entry.get("price")) or 100.0
647
+ new_price = round(base + a.price_offset * (1 if n % 2 == 0 else -1), 4)
648
+ msg, new_clordid = self._factory.build_amend(entry, new_price)
649
+ entry["pending_clordid"] = new_clordid
650
+ entry["amends_sent"] = n + 1
651
+ entry["amend_sent_ns"] = time.perf_counter_ns()
652
+ if await self._sm.send_message(self.config.client_session_id, msg):
653
+ self.stats.amends_sent += 1
654
+ else:
655
+ entry["amend_inflight"] = False
656
+ entry["pending_clordid"] = None
657
+
546
658
  # ------------------------------------------------------------------
547
659
  # Timeout sweeper
548
660
  # ------------------------------------------------------------------
@@ -559,7 +671,15 @@ class PerfRun:
559
671
  expired = [c for c, e in self._pending.items() if now - e["sent_ns"] > timeout_ns]
560
672
  for c in expired:
561
673
  entry = self._pending.pop(c, None)
562
- if entry is not None:
674
+ if entry is None:
675
+ continue
676
+ if entry.get("amend_inflight"):
677
+ self.stats.amends_lost += 1
678
+ if entry.get("completed"):
679
+ # Order filled; only the amend's Replaced ack never came —
680
+ # don't double-count a completed order as lost.
681
+ self._finalize(entry, "filled")
682
+ else:
563
683
  self.stats.lost_timeout += 1
564
684
  self._finalize(entry, "lost")
565
685
 
@@ -591,12 +711,16 @@ class PerfRun:
591
711
  return (time.perf_counter_ns() - self._start_ns) / 1e9 if self._start_ns else 0.0
592
712
 
593
713
  def _build_snapshot(self) -> None:
594
- ops, fps, sps = self.stats.rates()
714
+ ops, fps, sps, aps = self.stats.rates()
595
715
  client = None
596
716
  if self._has_client:
597
717
  client = ClientLeg(
598
718
  orders_sent=self.stats.orders_sent, ops_live=round(ops, 1),
599
719
  pending=len(self._pending), dropped=self.stats.dropped,
720
+ amends_sent=self.stats.amends_sent,
721
+ replaces_received=self.stats.replaces_received,
722
+ amends_lost=self.stats.amends_lost,
723
+ aps_live=round(aps, 1),
600
724
  scenarios_dispatched=self.stats.scenarios_dispatched,
601
725
  scenarios_per_sec=round(sps, 1),
602
726
  scenarios_completed=self.stats.scenarios_completed,
@@ -608,7 +732,8 @@ class PerfRun:
608
732
  acks_sent=self.stats.acks_sent,
609
733
  fills_sent=self.stats.fills_sent, fps_live=round(fps, 1),
610
734
  fill_ratio=round(self.stats.fill_ratio(), 3),
611
- unfilled=max(0, self.stats.orders_sent - self.stats.completions))
735
+ unfilled=max(0, self.stats.orders_sent - self.stats.completions),
736
+ amends_received=self.stats.amends_received)
612
737
  snap = LiveSnapshot(
613
738
  run_id=self.run_id, status=self.status, mode=self.config.mode,
614
739
  elapsed_s=round(self._elapsed_s(), 1), duration_s=self.config.test.duration,
@@ -616,6 +741,7 @@ class PerfRun:
616
741
  client=client, venue=venue,
617
742
  response_latency=LatencyStats(**self.stats.response_latency.stats()),
618
743
  fill_completion_latency=LatencyStats(**self.stats.fill_completion_latency.stats()),
744
+ amend_latency=LatencyStats(**self.stats.amend_latency.stats()),
619
745
  scenario_latency=LatencyStats(**self.stats.scenario_latency.stats()),
620
746
  errors=SnapshotErrors(lost_timeout=self.stats.lost_timeout,
621
747
  rejected=self.stats.rejected,
@@ -57,6 +57,24 @@ class TestConfig(BaseModel):
57
57
  record_execs: bool = False
58
58
 
59
59
 
60
+ class AmendConfig(BaseModel):
61
+ """Client-leg amend traffic (35=G OrderCancelReplaceRequest), price-only.
62
+
63
+ Triggered every `every_n_fills` fills of a working order (so amend rate ≈
64
+ fills/s ÷ n); `amend_ratio` picks the eligible fraction of orders and
65
+ `max_per_order` caps the chain length (0 = unlimited). Each amend nudges
66
+ the price by ± `price_offset` (alternating). `pending_replace` makes the
67
+ in-process venue reply 150=E (Pending Replace) before 150=5 (Replaced),
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."""
70
+ enabled: bool = False
71
+ every_n_fills: int = Field(default=1, ge=1)
72
+ amend_ratio: float = Field(default=1.0, ge=0.0, le=1.0)
73
+ max_per_order: int = Field(default=1, ge=0)
74
+ price_offset: float = Field(default=0.01, gt=0)
75
+ pending_replace: bool = False
76
+
77
+
60
78
  class OrderPayloadConfig(BaseModel):
61
79
  symbols: list[str] = Field(min_length=1)
62
80
  side: Literal["fixed_buy", "fixed_sell", "alternate", "random"] = "alternate"
@@ -146,6 +164,7 @@ class RunConfig(BaseModel):
146
164
  exec_id_tag: int = Field(default=25116, gt=0)
147
165
  rate: RateConfig
148
166
  test: TestConfig = Field(default_factory=TestConfig)
167
+ amend: AmendConfig = Field(default_factory=AmendConfig)
149
168
  payload: PayloadConfig
150
169
 
151
170
  @model_validator(mode="after")
@@ -177,6 +196,10 @@ class ClientLeg(BaseModel):
177
196
  ops_live: float = 0.0
178
197
  pending: int = 0
179
198
  dropped: int = 0
199
+ amends_sent: int = 0
200
+ replaces_received: int = 0
201
+ amends_lost: int = 0
202
+ aps_live: float = 0.0
180
203
  scenarios_dispatched: int = 0
181
204
  scenarios_per_sec: float = 0.0
182
205
  scenarios_completed: int = 0
@@ -191,6 +214,7 @@ class VenueLeg(BaseModel):
191
214
  fill_ratio: float = 0.0
192
215
  unfilled: int = 0
193
216
  venue_proc_us_p50: float = 0.0
217
+ amends_received: int = 0
194
218
 
195
219
 
196
220
  class SnapshotErrors(BaseModel):
@@ -210,6 +234,7 @@ class LiveSnapshot(BaseModel):
210
234
  venue: Optional[VenueLeg] = None
211
235
  response_latency: LatencyStats = Field(default_factory=LatencyStats)
212
236
  fill_completion_latency: LatencyStats = Field(default_factory=LatencyStats)
237
+ amend_latency: LatencyStats = Field(default_factory=LatencyStats)
213
238
  scenario_latency: LatencyStats = Field(default_factory=LatencyStats)
214
239
  errors: SnapshotErrors = Field(default_factory=SnapshotErrors)
215
240
 
@@ -385,6 +385,29 @@ class PayloadFactory:
385
385
  def _clordid(self) -> str:
386
386
  return "PCL-" + _guid()[:12].upper()
387
387
 
388
+ # -- amend (35=G OrderCancelReplaceRequest, price-only) -------------
389
+
390
+ def build_amend(self, meta: dict, new_price: float) -> tuple[Message, str]:
391
+ """Re-price a working order. Carries the order's correlation id so the
392
+ replace acks route to the same pending entry; OrigClOrdID(41) is the
393
+ current chain tip, ClOrdID(11) is fresh — returned so the engine can
394
+ promote the chain once the venue confirms (150=5)."""
395
+ p = self.config.payload.order
396
+ new_clordid = self._clordid()
397
+ msg = Message()
398
+ msg.header.set(35, "G")
399
+ msg.set_field(11, new_clordid)
400
+ msg.set_field(41, meta["clordid"])
401
+ msg.set_field(self.config.correlation_tag, meta["corr_id"])
402
+ msg.set_field(55, meta["symbol"])
403
+ msg.set_field(54, meta["side"])
404
+ msg.set_field(38, str(meta["order_qty"]))
405
+ msg.set_field(40, "2") # re-pricing → limit, whatever the original was
406
+ msg.set_field(44, f"{new_price:.2f}")
407
+ msg.set_field(59, p.time_in_force)
408
+ msg.set_field(60, _utc_ms())
409
+ return msg, new_clordid
410
+
388
411
  def _apply_expiry(self, msg: Message, fields: dict) -> None:
389
412
  """Rewrite ExpireTime(126)/ExpireDate(432) to a shared future instant —
390
413
  only for tags the message already carries (never inserts), exactly like
@@ -119,6 +119,10 @@ class PerfStats:
119
119
  self.fills_sent = 0 # venue sent a fill ExecutionReport
120
120
  self.responses = 0 # client got first response for an order
121
121
  self.completions = 0 # client saw a full fill
122
+ self.amends_sent = 0 # client sent a 35=G
123
+ self.replaces_received = 0 # client got a Replaced ack (150=5)
124
+ self.amends_lost = 0 # amend in flight when its order was reaped
125
+ self.amends_received = 0 # venue saw an inbound 35=G
122
126
  self.lost_timeout = 0
123
127
  self.dropped = 0
124
128
  self.rejected = 0
@@ -130,27 +134,31 @@ class PerfStats:
130
134
 
131
135
  self.response_latency = _Reservoir(reservoir_size)
132
136
  self.fill_completion_latency = _Reservoir(reservoir_size)
137
+ self.amend_latency = _Reservoir(reservoir_size)
133
138
  self.scenario_latency = _Reservoir(reservoir_size)
134
139
 
135
140
  self._last_t = time.monotonic()
136
141
  self._last_orders = 0
137
142
  self._last_fills = 0
138
143
  self._last_scen = 0
144
+ self._last_amends = 0
139
145
 
140
- def rates(self) -> tuple[float, float, float]:
141
- """(orders/s, fills/s, scenarios/s) since the last call (delta-based)."""
146
+ def rates(self) -> tuple[float, float, float, float]:
147
+ """(orders/s, fills/s, scenarios/s, amends/s) since the last call (delta-based)."""
142
148
  now = time.monotonic()
143
149
  dt = now - self._last_t
144
150
  if dt <= 0:
145
- return 0.0, 0.0, 0.0
151
+ return 0.0, 0.0, 0.0, 0.0
146
152
  ops = (self.orders_sent - self._last_orders) / dt
147
153
  fps = (self.fills_sent - self._last_fills) / dt
148
154
  sps = (self.scenarios_dispatched - self._last_scen) / dt
155
+ aps = (self.amends_sent - self._last_amends) / dt
149
156
  self._last_t = now
150
157
  self._last_orders = self.orders_sent
151
158
  self._last_fills = self.fills_sent
152
159
  self._last_scen = self.scenarios_dispatched
153
- return ops, fps, sps
160
+ self._last_amends = self.amends_sent
161
+ return ops, fps, sps, aps
154
162
 
155
163
  def fill_ratio(self) -> float:
156
164
  # Fraction of orders fully filled (cumQty >= orderQty). Uses completions,
@@ -168,6 +176,10 @@ class PerfStats:
168
176
  "fills_sent": self.fills_sent,
169
177
  "responses": self.responses,
170
178
  "completions": self.completions,
179
+ "amends_sent": self.amends_sent,
180
+ "replaces_received": self.replaces_received,
181
+ "amends_lost": self.amends_lost,
182
+ "amends_received": self.amends_received,
171
183
  "lost_timeout": self.lost_timeout,
172
184
  "dropped": self.dropped,
173
185
  "rejected": self.rejected,
@@ -177,5 +189,6 @@ class PerfStats:
177
189
  "fill_ratio": self.fill_ratio(),
178
190
  "response_latency": self.response_latency.stats(),
179
191
  "fill_completion_latency": self.fill_completion_latency.stats(),
192
+ "amend_latency": self.amend_latency.stats(),
180
193
  "scenario_latency": self.scenario_latency.stats(),
181
194
  }