fixtureqa 0.4.7__tar.gz → 0.4.9__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.9}/PKG-INFO +1 -1
  2. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/core/perf_engine.py +140 -12
  3. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/core/perf_models.py +25 -0
  4. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/core/perf_payload.py +23 -0
  5. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/core/perf_stats.py +17 -4
  6. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/core/perf_store.py +19 -2
  7. fixtureqa-0.4.9/fixture/static/assets/index-DrmyYeG0.js +102 -0
  8. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/static/index.html +1 -1
  9. {fixtureqa-0.4.7 → fixtureqa-0.4.9/fixtureqa.egg-info}/PKG-INFO +1 -1
  10. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixtureqa.egg-info/SOURCES.txt +1 -1
  11. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/pyproject.toml +1 -1
  12. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/tests/test_perf_api.py +29 -0
  13. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/tests/test_perf_engine.py +231 -1
  14. fixtureqa-0.4.7/fixture/static/assets/index-DISOjvhu.js +0 -102
  15. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/LICENSE +0 -0
  16. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/README.md +0 -0
  17. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/__init__.py +0 -0
  18. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/__main__.py +0 -0
  19. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/api/__init__.py +0 -0
  20. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/api/app.py +0 -0
  21. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/api/connection_manager.py +0 -0
  22. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/api/deps.py +0 -0
  23. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/api/routers/__init__.py +0 -0
  24. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/api/routers/admin.py +0 -0
  25. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/api/routers/auth.py +0 -0
  26. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/api/routers/branding.py +0 -0
  27. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/api/routers/custom_tags.py +0 -0
  28. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/api/routers/fix_spec.py +0 -0
  29. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/api/routers/messages.py +0 -0
  30. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/api/routers/perf.py +0 -0
  31. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/api/routers/scenarios.py +0 -0
  32. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/api/routers/sessions.py +0 -0
  33. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/api/routers/setup.py +0 -0
  34. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/api/routers/spec_overlay.py +0 -0
  35. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/api/routers/templates.py +0 -0
  36. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/api/routers/ws.py +0 -0
  37. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/api/schemas.py +0 -0
  38. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/config/__init__.py +0 -0
  39. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/core/__init__.py +0 -0
  40. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/core/atomic_io.py +0 -0
  41. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/core/auth.py +0 -0
  42. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/core/config_store.py +0 -0
  43. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/core/custom_tag_store.py +0 -0
  44. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/core/db_migrations.py +0 -0
  45. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/core/events.py +0 -0
  46. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/core/exec_csv_writer.py +0 -0
  47. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/core/fix_application.py +0 -0
  48. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/core/fix_builder.py +0 -0
  49. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/core/fix_parser.py +0 -0
  50. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/core/fix_spec_parser.py +0 -0
  51. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/core/fix_tags.py +0 -0
  52. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/core/fix_time.py +0 -0
  53. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/core/housekeeping.py +0 -0
  54. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/core/inbound.py +0 -0
  55. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/core/json_store.py +0 -0
  56. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/core/message_log.py +0 -0
  57. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/core/message_store.py +0 -0
  58. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/core/models.py +0 -0
  59. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/core/perf_writer.py +0 -0
  60. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/core/scenario_runner.py +0 -0
  61. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/core/scenario_store.py +0 -0
  62. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/core/session.py +0 -0
  63. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/core/session_manager.py +0 -0
  64. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/core/spec_overlay_store.py +0 -0
  65. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/core/template_store.py +0 -0
  66. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/core/user_store.py +0 -0
  67. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/core/venue_responses.py +0 -0
  68. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/fix_specs/FIX42.xml +0 -0
  69. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/fix_specs/FIX44.xml +0 -0
  70. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/server.py +0 -0
  71. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
  72. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/static/assets/index-BwQf-cei.css +0 -0
  73. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/static/assets/index-CyNOPa0n.js +0 -0
  74. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
  75. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/static/favicon.svg +0 -0
  76. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixture/ui/__init__.py +0 -0
  77. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixtureqa.egg-info/dependency_links.txt +0 -0
  78. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixtureqa.egg-info/entry_points.txt +0 -0
  79. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixtureqa.egg-info/requires.txt +0 -0
  80. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/fixtureqa.egg-info/top_level.txt +0 -0
  81. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/setup.cfg +0 -0
  82. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/tests/test_atomic_io.py +0 -0
  83. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/tests/test_auth.py +0 -0
  84. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/tests/test_config_store.py +0 -0
  85. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/tests/test_connection_manager.py +0 -0
  86. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/tests/test_db_migrations.py +0 -0
  87. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/tests/test_fix_builder.py +0 -0
  88. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/tests/test_health.py +0 -0
  89. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/tests/test_inbound.py +0 -0
  90. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/tests/test_inbound_validation.py +0 -0
  91. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/tests/test_message_store.py +0 -0
  92. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/tests/test_perf_models.py +0 -0
  93. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/tests/test_perf_payload.py +0 -0
  94. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/tests/test_perf_rehydrate.py +0 -0
  95. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/tests/test_scenarios.py +0 -0
  96. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/tests/test_session_lifecycle.py +0 -0
  97. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/tests/test_session_manager_concurrency.py +0 -0
  98. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/tests/test_sessions.py +0 -0
  99. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/tests/test_templates.py +0 -0
  100. {fixtureqa-0.4.7 → fixtureqa-0.4.9}/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.9
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:
@@ -198,15 +206,17 @@ class PerfRun:
198
206
  # client mode, count-bounded: external venue may never fill — bounded settle.
199
207
  deadline = time.perf_counter() + max(_GRACE_DRAIN_S,
200
208
  self.config.test.scenario_timeout_ms / 1000.0)
201
- while self._pending and time.perf_counter() < deadline:
209
+ while self._pending and time.perf_counter() < deadline and not self._stopped_by_user:
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()
@@ -242,7 +252,9 @@ class PerfRun:
242
252
  last_activity = time.perf_counter()
243
253
  while True:
244
254
  now = time.perf_counter()
245
- if not self._pending or now >= cap:
255
+ # Stop pressed mid-drain must win immediately — the backlog gets
256
+ # finalized as lost instead of ground through at the fill rate.
257
+ if self._stopped_by_user or not self._pending or now >= cap:
246
258
  break
247
259
  cur_sent = self.stats.fills_sent + self.stats.acks_sent
248
260
  if cur_sent != last_sent:
@@ -316,6 +328,12 @@ class PerfRun:
316
328
  meta["sent_at"] = time.time()
317
329
  meta["responded"] = False
318
330
  meta["seen_exec"] = set()
331
+ if self._amend_on and meta.get("msg_type") == "D":
332
+ meta["amend_eligible"] = random.random() < self.config.amend.amend_ratio
333
+ meta["fills_seen"] = 0
334
+ meta["amends_sent"] = 0
335
+ meta["amend_inflight"] = False
336
+ meta["completed"] = False
319
337
  if resp_event is not None:
320
338
  meta["resp_event"] = resp_event
321
339
  self._pending[meta["corr_id"]] = meta
@@ -383,6 +401,9 @@ class PerfRun:
383
401
  sub = self._venue_sub
384
402
  while True:
385
403
  inb = await sub.get()
404
+ if inb.msg_type == "G":
405
+ await self._handle_amend(inb.fields)
406
+ continue
386
407
  if inb.msg_type != "D":
387
408
  continue
388
409
  self.stats.orders_received += 1
@@ -409,6 +430,31 @@ class PerfRun:
409
430
  if er is not None and await self._sm.send_message(cfg.venue_session_id, er):
410
431
  self.stats.fills_sent += 1
411
432
 
433
+ async def _handle_amend(self, of: dict) -> None:
434
+ """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."""
439
+ cfg = self.config
440
+ self.stats.amends_received += 1
441
+ if of.get(cfg.correlation_tag) is None:
442
+ return
443
+ order_qty = _to_int(of.get(T_ORDERQTY), 0)
444
+ px = of.get(T_PRICE) or "0"
445
+ orig = of.get(T_ORIGCLORDID)
446
+ if cfg.amend.pending_replace:
447
+ er = self._build_exec(of, exec_type="E", ord_status="E", last_qty=0,
448
+ cum_qty=0, leaves_qty=order_qty, px=px,
449
+ orig_clordid=orig)
450
+ if er is not None:
451
+ await self._sm.send_message(cfg.venue_session_id, er)
452
+ er = self._build_exec(of, exec_type="5", ord_status="5", last_qty=0,
453
+ cum_qty=0, leaves_qty=order_qty, px=px,
454
+ orig_clordid=orig)
455
+ if er is not None:
456
+ await self._sm.send_message(cfg.venue_session_id, er)
457
+
412
458
  def _exec(self, of: dict, **kw) -> Optional[Message]:
413
459
  """Build one ExecReport via the exec template when configured, else the
414
460
  built-in builder (Phase B). Both enforce the standard correlation tags."""
@@ -450,8 +496,10 @@ class PerfRun:
450
496
  return schedule
451
497
 
452
498
  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."""
499
+ last_qty: int, cum_qty: int, leaves_qty: int, px: str,
500
+ orig_clordid: Optional[str] = None) -> Optional[Message]:
501
+ """Build one ExecutionReport (ack, fill or replace ack) from an inbound
502
+ message's fields."""
455
503
  cfg = self.config
456
504
  corr = of.get(cfg.correlation_tag)
457
505
  if corr is None:
@@ -462,6 +510,8 @@ class PerfRun:
462
510
  er.header.set(T_MSGTYPE, "8")
463
511
  if of.get(T_CLORDID):
464
512
  er.set_field(T_CLORDID, of[T_CLORDID])
513
+ if orig_clordid:
514
+ er.set_field(T_ORIGCLORDID, orig_clordid)
465
515
  er.set_field(cfg.correlation_tag, corr)
466
516
  er.set_field(cfg.exec_id_tag, _uuid())
467
517
  er.set_field(T_ORDERID, "O" + _guid()[:8].upper())
@@ -520,6 +570,14 @@ class PerfRun:
520
570
  ((now_ns - prev_ns) / 1000.0) if prev_ns is not None else None,
521
571
  ))
522
572
 
573
+ # Amend responses (Pending Replace / Replaced) are not order responses:
574
+ # they must not count as first response, a fill, or a completion.
575
+ exec_type = f.get(T_EXECTYPE)
576
+ if exec_type in ("E", "5"):
577
+ if exec_type == "5":
578
+ self._on_replaced(corr, entry, now_ns)
579
+ return
580
+
523
581
  if not entry["responded"]:
524
582
  entry["responded"] = True
525
583
  entry["first_resp_at"] = time.time()
@@ -532,7 +590,24 @@ class PerfRun:
532
590
  ev.set()
533
591
 
534
592
  cum = _to_int(f.get(T_CUMQTY), 0)
535
- if cum >= entry["order_qty"]:
593
+
594
+ # Amend trigger: every nth fill of a still-working order. Counting cum
595
+ # increases (not ExecType values) keeps it venue-agnostic (4.2's 150=1/2
596
+ # and 4.4's 150=F alike). Never fires on the final fill.
597
+ if self._amend_on and entry.get("amend_eligible") and cum > entry.get("cum_seen", 0):
598
+ entry["cum_seen"] = cum
599
+ entry["fills_seen"] += 1
600
+ a = self.config.amend
601
+ if (self._injecting and not entry["amend_inflight"]
602
+ and cum < entry["order_qty"]
603
+ and entry["fills_seen"] % a.every_n_fills == 0
604
+ and (a.max_per_order == 0 or entry["amends_sent"] < a.max_per_order)):
605
+ entry["amend_inflight"] = True
606
+ t = asyncio.create_task(self._send_amend(entry))
607
+ self._amend_tasks.add(t)
608
+ t.add_done_callback(self._amend_tasks.discard)
609
+
610
+ if cum >= entry["order_qty"] and not entry.get("completed"):
536
611
  lat_us = (now_ns - entry["sent_ns"]) / 1000.0
537
612
  entry["fill_latency_us"] = lat_us
538
613
  entry["filled_at"] = time.time()
@@ -540,9 +615,48 @@ class PerfRun:
540
615
  entry["fill_price"] = f.get(T_LASTPX) or f.get(T_AVGPX)
541
616
  self.stats.completions += 1
542
617
  self.stats.fill_completion_latency.add(lat_us)
618
+ if entry.get("amend_inflight"):
619
+ # The venue dispatches an order's fills before it reads the 35=G,
620
+ # so the Replaced ack usually lands after the final fill. Keep
621
+ # the entry so the ack still correlates; _on_replaced (or the
622
+ # sweeper / shutdown) finalizes it.
623
+ entry["completed"] = True
624
+ else:
625
+ entry["completed"] = True
626
+ self._pending.pop(corr, None)
627
+ self._finalize(entry, "filled")
628
+
629
+ def _on_replaced(self, corr: str, entry: dict, now_ns: int) -> None:
630
+ """Client got a Replaced ack (150=5): close the in-flight amend."""
631
+ self.stats.replaces_received += 1
632
+ sent_ns = entry.get("amend_sent_ns")
633
+ if entry.get("amend_inflight") and sent_ns:
634
+ self.stats.amend_latency.add((now_ns - sent_ns) / 1000.0)
635
+ if entry.get("pending_clordid"):
636
+ entry["clordid"] = entry["pending_clordid"]
637
+ entry["pending_clordid"] = None
638
+ entry["amend_inflight"] = False
639
+ if entry.get("completed"):
543
640
  self._pending.pop(corr, None)
544
641
  self._finalize(entry, "filled")
545
642
 
643
+ async def _send_amend(self, entry: dict) -> None:
644
+ """Build and send one price amend (alternating ± offset). Demand-driven
645
+ by fills (rate ≈ fills/s ÷ every_n_fills) — no pacer of its own."""
646
+ a = self.config.amend
647
+ n = entry["amends_sent"]
648
+ base = _to_float(entry.get("price")) or 100.0
649
+ new_price = round(base + a.price_offset * (1 if n % 2 == 0 else -1), 4)
650
+ msg, new_clordid = self._factory.build_amend(entry, new_price)
651
+ entry["pending_clordid"] = new_clordid
652
+ entry["amends_sent"] = n + 1
653
+ entry["amend_sent_ns"] = time.perf_counter_ns()
654
+ if await self._sm.send_message(self.config.client_session_id, msg):
655
+ self.stats.amends_sent += 1
656
+ else:
657
+ entry["amend_inflight"] = False
658
+ entry["pending_clordid"] = None
659
+
546
660
  # ------------------------------------------------------------------
547
661
  # Timeout sweeper
548
662
  # ------------------------------------------------------------------
@@ -559,7 +673,15 @@ class PerfRun:
559
673
  expired = [c for c, e in self._pending.items() if now - e["sent_ns"] > timeout_ns]
560
674
  for c in expired:
561
675
  entry = self._pending.pop(c, None)
562
- if entry is not None:
676
+ if entry is None:
677
+ continue
678
+ if entry.get("amend_inflight"):
679
+ self.stats.amends_lost += 1
680
+ if entry.get("completed"):
681
+ # Order filled; only the amend's Replaced ack never came —
682
+ # don't double-count a completed order as lost.
683
+ self._finalize(entry, "filled")
684
+ else:
563
685
  self.stats.lost_timeout += 1
564
686
  self._finalize(entry, "lost")
565
687
 
@@ -591,12 +713,16 @@ class PerfRun:
591
713
  return (time.perf_counter_ns() - self._start_ns) / 1e9 if self._start_ns else 0.0
592
714
 
593
715
  def _build_snapshot(self) -> None:
594
- ops, fps, sps = self.stats.rates()
716
+ ops, fps, sps, aps = self.stats.rates()
595
717
  client = None
596
718
  if self._has_client:
597
719
  client = ClientLeg(
598
720
  orders_sent=self.stats.orders_sent, ops_live=round(ops, 1),
599
721
  pending=len(self._pending), dropped=self.stats.dropped,
722
+ amends_sent=self.stats.amends_sent,
723
+ replaces_received=self.stats.replaces_received,
724
+ amends_lost=self.stats.amends_lost,
725
+ aps_live=round(aps, 1),
600
726
  scenarios_dispatched=self.stats.scenarios_dispatched,
601
727
  scenarios_per_sec=round(sps, 1),
602
728
  scenarios_completed=self.stats.scenarios_completed,
@@ -608,7 +734,8 @@ class PerfRun:
608
734
  acks_sent=self.stats.acks_sent,
609
735
  fills_sent=self.stats.fills_sent, fps_live=round(fps, 1),
610
736
  fill_ratio=round(self.stats.fill_ratio(), 3),
611
- unfilled=max(0, self.stats.orders_sent - self.stats.completions))
737
+ unfilled=max(0, self.stats.orders_sent - self.stats.completions),
738
+ amends_received=self.stats.amends_received)
612
739
  snap = LiveSnapshot(
613
740
  run_id=self.run_id, status=self.status, mode=self.config.mode,
614
741
  elapsed_s=round(self._elapsed_s(), 1), duration_s=self.config.test.duration,
@@ -616,6 +743,7 @@ class PerfRun:
616
743
  client=client, venue=venue,
617
744
  response_latency=LatencyStats(**self.stats.response_latency.stats()),
618
745
  fill_completion_latency=LatencyStats(**self.stats.fill_completion_latency.stats()),
746
+ amend_latency=LatencyStats(**self.stats.amend_latency.stats()),
619
747
  scenario_latency=LatencyStats(**self.stats.scenario_latency.stats()),
620
748
  errors=SnapshotErrors(lost_timeout=self.stats.lost_timeout,
621
749
  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
  }
@@ -6,6 +6,7 @@ parameter is unused.
6
6
  from __future__ import annotations
7
7
 
8
8
  import os
9
+ import uuid
9
10
  from typing import Optional
10
11
 
11
12
  from .json_store import JsonListStore
@@ -28,8 +29,24 @@ class PerfStore(JsonListStore):
28
29
  return item["config"] if item else None
29
30
 
30
31
  def save(self, config: dict) -> str:
31
- item = self.create_item("", {"name": config.get("name", ""), "config": config})
32
- return item["config_id"]
32
+ """Upsert by name: saving a config whose name already exists overwrites
33
+ it in place (same config_id), so the dropdown never accumulates
34
+ duplicates. Pre-existing duplicates of that name are collapsed into the
35
+ first one. One locked load-modify-save so the find+replace is atomic."""
36
+ name = config.get("name", "")
37
+ with self._lock:
38
+ items = self._load("")
39
+ existing = [it for it in items if it.get("name") == name]
40
+ if existing:
41
+ keep = existing[0]
42
+ keep["config"] = config
43
+ items = [it for it in items if it.get("name") != name or it is keep]
44
+ self._save("", items)
45
+ return keep["config_id"]
46
+ item = {"name": name, "config": config, "config_id": str(uuid.uuid4())}
47
+ items.append(item)
48
+ self._save("", items)
49
+ return item["config_id"]
33
50
 
34
51
  def delete(self, config_id: str) -> bool:
35
52
  return self.delete_item("", config_id)