fixtureqa 0.4.6__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.6/fixtureqa.egg-info → fixtureqa-0.4.8}/PKG-INFO +1 -1
  2. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/perf_engine.py +146 -15
  3. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/perf_models.py +33 -0
  4. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/perf_payload.py +23 -0
  5. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/perf_stats.py +45 -4
  6. fixtureqa-0.4.8/fixture/static/assets/index-DrmyYeG0.js +102 -0
  7. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/static/index.html +1 -1
  8. {fixtureqa-0.4.6 → fixtureqa-0.4.8/fixtureqa.egg-info}/PKG-INFO +1 -1
  9. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixtureqa.egg-info/SOURCES.txt +1 -1
  10. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/pyproject.toml +1 -1
  11. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_perf_engine.py +292 -1
  12. fixtureqa-0.4.6/fixture/static/assets/index-FGgej6RF.js +0 -102
  13. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/LICENSE +0 -0
  14. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/README.md +0 -0
  15. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/__init__.py +0 -0
  16. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/__main__.py +0 -0
  17. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/api/__init__.py +0 -0
  18. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/api/app.py +0 -0
  19. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/api/connection_manager.py +0 -0
  20. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/api/deps.py +0 -0
  21. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/api/routers/__init__.py +0 -0
  22. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/api/routers/admin.py +0 -0
  23. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/api/routers/auth.py +0 -0
  24. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/api/routers/branding.py +0 -0
  25. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/api/routers/custom_tags.py +0 -0
  26. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/api/routers/fix_spec.py +0 -0
  27. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/api/routers/messages.py +0 -0
  28. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/api/routers/perf.py +0 -0
  29. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/api/routers/scenarios.py +0 -0
  30. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/api/routers/sessions.py +0 -0
  31. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/api/routers/setup.py +0 -0
  32. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/api/routers/spec_overlay.py +0 -0
  33. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/api/routers/templates.py +0 -0
  34. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/api/routers/ws.py +0 -0
  35. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/api/schemas.py +0 -0
  36. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/config/__init__.py +0 -0
  37. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/__init__.py +0 -0
  38. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/atomic_io.py +0 -0
  39. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/auth.py +0 -0
  40. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/config_store.py +0 -0
  41. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/custom_tag_store.py +0 -0
  42. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/db_migrations.py +0 -0
  43. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/events.py +0 -0
  44. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/exec_csv_writer.py +0 -0
  45. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/fix_application.py +0 -0
  46. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/fix_builder.py +0 -0
  47. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/fix_parser.py +0 -0
  48. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/fix_spec_parser.py +0 -0
  49. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/fix_tags.py +0 -0
  50. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/fix_time.py +0 -0
  51. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/housekeeping.py +0 -0
  52. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/inbound.py +0 -0
  53. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/json_store.py +0 -0
  54. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/message_log.py +0 -0
  55. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/message_store.py +0 -0
  56. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/models.py +0 -0
  57. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/perf_store.py +0 -0
  58. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/perf_writer.py +0 -0
  59. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/scenario_runner.py +0 -0
  60. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/scenario_store.py +0 -0
  61. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/session.py +0 -0
  62. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/session_manager.py +0 -0
  63. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/spec_overlay_store.py +0 -0
  64. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/template_store.py +0 -0
  65. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/user_store.py +0 -0
  66. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/venue_responses.py +0 -0
  67. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/fix_specs/FIX42.xml +0 -0
  68. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/fix_specs/FIX44.xml +0 -0
  69. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/server.py +0 -0
  70. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
  71. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/static/assets/index-BwQf-cei.css +0 -0
  72. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/static/assets/index-CyNOPa0n.js +0 -0
  73. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
  74. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/static/favicon.svg +0 -0
  75. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/ui/__init__.py +0 -0
  76. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixtureqa.egg-info/dependency_links.txt +0 -0
  77. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixtureqa.egg-info/entry_points.txt +0 -0
  78. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixtureqa.egg-info/requires.txt +0 -0
  79. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixtureqa.egg-info/top_level.txt +0 -0
  80. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/setup.cfg +0 -0
  81. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_atomic_io.py +0 -0
  82. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_auth.py +0 -0
  83. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_config_store.py +0 -0
  84. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_connection_manager.py +0 -0
  85. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_db_migrations.py +0 -0
  86. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_fix_builder.py +0 -0
  87. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_health.py +0 -0
  88. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_inbound.py +0 -0
  89. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_inbound_validation.py +0 -0
  90. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_message_store.py +0 -0
  91. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_perf_api.py +0 -0
  92. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_perf_models.py +0 -0
  93. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_perf_payload.py +0 -0
  94. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_perf_rehydrate.py +0 -0
  95. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_scenarios.py +0 -0
  96. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_session_lifecycle.py +0 -0
  97. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_session_manager_concurrency.py +0 -0
  98. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_sessions.py +0 -0
  99. {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_templates.py +0 -0
  100. {fixtureqa-0.4.6 → 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.6
3
+ Version: 0.4.8
4
4
  Summary: FIXture — FIX Protocol Testing Tool
5
5
  Requires-Python: >=3.10
6
6
  License-File: LICENSE
@@ -35,14 +35,14 @@ from .perf_models import (
35
35
  RunConfig, RunStatus, LiveSnapshot, ClientLeg, VenueLeg, SnapshotErrors, LatencyStats,
36
36
  )
37
37
  from .perf_payload import PayloadFactory, ScenarioSelector
38
- from .perf_stats import PerfStats, TokenBucket
38
+ from .perf_stats import BurstGate, PerfStats, TokenBucket
39
39
  from .perf_writer import PerfWriter
40
40
  from .session_manager import SessionManager
41
41
 
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,
@@ -115,10 +119,15 @@ class PerfRun:
115
119
  if config.payload.scenarios else None)
116
120
 
117
121
  r = config.rate
118
- self._order_bucket = TokenBucket(r.orders_per_window, r.order_window_ms,
119
- r.allow_burst, r.max_burst_multiplier)
120
- self._fill_bucket = TokenBucket(r.fills_per_window, r.fill_window_ms,
121
- r.allow_burst, r.max_burst_multiplier)
122
+
123
+ def _pacer(per_window: int, window_ms: int, mode: str):
124
+ if mode == "burst":
125
+ return BurstGate(per_window, window_ms)
126
+ return TokenBucket(per_window, window_ms,
127
+ r.allow_burst, r.max_burst_multiplier)
128
+
129
+ self._order_bucket = _pacer(r.orders_per_window, r.order_window_ms, r.dispatch)
130
+ self._fill_bucket = _pacer(r.fills_per_window, r.fill_window_ms, r.fill_dispatch)
122
131
 
123
132
  self._client_sub = None
124
133
  self._venue_sub = None
@@ -146,6 +155,10 @@ class PerfRun:
146
155
  else:
147
156
  self._exec_csv = ExecCsvWriter(
148
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")
149
162
  if self._has_client:
150
163
  self._client_sub = self._sm.subscribe_session(self.config.client_session_id)
151
164
  if self._has_venue:
@@ -197,11 +210,13 @@ class PerfRun:
197
210
  await asyncio.sleep(0.02)
198
211
  else:
199
212
  await asyncio.sleep(_GRACE_DRAIN_S)
200
- for t in self._tasks:
213
+ for t in list(self._tasks) + list(self._amend_tasks):
201
214
  t.cancel()
202
- await asyncio.gather(*self._tasks, return_exceptions=True)
215
+ await asyncio.gather(*self._tasks, *self._amend_tasks, return_exceptions=True)
203
216
  for entry in list(self._pending.values()):
204
- 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")
205
220
  self._pending.clear()
206
221
  if self._client_sub:
207
222
  self._client_sub.close()
@@ -311,6 +326,12 @@ class PerfRun:
311
326
  meta["sent_at"] = time.time()
312
327
  meta["responded"] = False
313
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
314
335
  if resp_event is not None:
315
336
  meta["resp_event"] = resp_event
316
337
  self._pending[meta["corr_id"]] = meta
@@ -378,6 +399,9 @@ class PerfRun:
378
399
  sub = self._venue_sub
379
400
  while True:
380
401
  inb = await sub.get()
402
+ if inb.msg_type == "G":
403
+ await self._handle_amend(inb.fields)
404
+ continue
381
405
  if inb.msg_type != "D":
382
406
  continue
383
407
  self.stats.orders_received += 1
@@ -404,6 +428,31 @@ class PerfRun:
404
428
  if er is not None and await self._sm.send_message(cfg.venue_session_id, er):
405
429
  self.stats.fills_sent += 1
406
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
+
407
456
  def _exec(self, of: dict, **kw) -> Optional[Message]:
408
457
  """Build one ExecReport via the exec template when configured, else the
409
458
  built-in builder (Phase B). Both enforce the standard correlation tags."""
@@ -445,8 +494,10 @@ class PerfRun:
445
494
  return schedule
446
495
 
447
496
  def _build_exec(self, of: dict, *, exec_type: str, ord_status: str,
448
- last_qty: int, cum_qty: int, leaves_qty: int, px: str) -> Optional[Message]:
449
- """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."""
450
501
  cfg = self.config
451
502
  corr = of.get(cfg.correlation_tag)
452
503
  if corr is None:
@@ -457,6 +508,8 @@ class PerfRun:
457
508
  er.header.set(T_MSGTYPE, "8")
458
509
  if of.get(T_CLORDID):
459
510
  er.set_field(T_CLORDID, of[T_CLORDID])
511
+ if orig_clordid:
512
+ er.set_field(T_ORIGCLORDID, orig_clordid)
460
513
  er.set_field(cfg.correlation_tag, corr)
461
514
  er.set_field(cfg.exec_id_tag, _uuid())
462
515
  er.set_field(T_ORDERID, "O" + _guid()[:8].upper())
@@ -515,6 +568,14 @@ class PerfRun:
515
568
  ((now_ns - prev_ns) / 1000.0) if prev_ns is not None else None,
516
569
  ))
517
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
+
518
579
  if not entry["responded"]:
519
580
  entry["responded"] = True
520
581
  entry["first_resp_at"] = time.time()
@@ -527,7 +588,24 @@ class PerfRun:
527
588
  ev.set()
528
589
 
529
590
  cum = _to_int(f.get(T_CUMQTY), 0)
530
- 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"):
531
609
  lat_us = (now_ns - entry["sent_ns"]) / 1000.0
532
610
  entry["fill_latency_us"] = lat_us
533
611
  entry["filled_at"] = time.time()
@@ -535,9 +613,48 @@ class PerfRun:
535
613
  entry["fill_price"] = f.get(T_LASTPX) or f.get(T_AVGPX)
536
614
  self.stats.completions += 1
537
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"):
538
638
  self._pending.pop(corr, None)
539
639
  self._finalize(entry, "filled")
540
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
+
541
658
  # ------------------------------------------------------------------
542
659
  # Timeout sweeper
543
660
  # ------------------------------------------------------------------
@@ -554,7 +671,15 @@ class PerfRun:
554
671
  expired = [c for c, e in self._pending.items() if now - e["sent_ns"] > timeout_ns]
555
672
  for c in expired:
556
673
  entry = self._pending.pop(c, None)
557
- 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:
558
683
  self.stats.lost_timeout += 1
559
684
  self._finalize(entry, "lost")
560
685
 
@@ -586,12 +711,16 @@ class PerfRun:
586
711
  return (time.perf_counter_ns() - self._start_ns) / 1e9 if self._start_ns else 0.0
587
712
 
588
713
  def _build_snapshot(self) -> None:
589
- ops, fps, sps = self.stats.rates()
714
+ ops, fps, sps, aps = self.stats.rates()
590
715
  client = None
591
716
  if self._has_client:
592
717
  client = ClientLeg(
593
718
  orders_sent=self.stats.orders_sent, ops_live=round(ops, 1),
594
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),
595
724
  scenarios_dispatched=self.stats.scenarios_dispatched,
596
725
  scenarios_per_sec=round(sps, 1),
597
726
  scenarios_completed=self.stats.scenarios_completed,
@@ -603,7 +732,8 @@ class PerfRun:
603
732
  acks_sent=self.stats.acks_sent,
604
733
  fills_sent=self.stats.fills_sent, fps_live=round(fps, 1),
605
734
  fill_ratio=round(self.stats.fill_ratio(), 3),
606
- 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)
607
737
  snap = LiveSnapshot(
608
738
  run_id=self.run_id, status=self.status, mode=self.config.mode,
609
739
  elapsed_s=round(self._elapsed_s(), 1), duration_s=self.config.test.duration,
@@ -611,6 +741,7 @@ class PerfRun:
611
741
  client=client, venue=venue,
612
742
  response_latency=LatencyStats(**self.stats.response_latency.stats()),
613
743
  fill_completion_latency=LatencyStats(**self.stats.fill_completion_latency.stats()),
744
+ amend_latency=LatencyStats(**self.stats.amend_latency.stats()),
614
745
  scenario_latency=LatencyStats(**self.stats.scenario_latency.stats()),
615
746
  errors=SnapshotErrors(lost_timeout=self.stats.lost_timeout,
616
747
  rejected=self.stats.rejected,
@@ -24,9 +24,17 @@ RunState = Literal["pending", "running", "completed", "stopped", "error", "satur
24
24
  Sequence = Literal["parallel", "sequential", "sequential_timed"]
25
25
 
26
26
 
27
+ DispatchMode = Literal["smooth", "burst"]
28
+
29
+
27
30
  class RateConfig(BaseModel):
28
31
  orders_per_window: int = Field(gt=0)
29
32
  order_window_ms: int = Field(gt=0)
33
+ # smooth: continuous-refill token bucket — sends evenly spaced at the
34
+ # average rate. burst: up to per_window sends back-to-back per window
35
+ # boundary, then silence until the next window (sawtooth throughput).
36
+ dispatch: DispatchMode = "smooth" # order leg
37
+ fill_dispatch: DispatchMode = "smooth" # venue exec leg
30
38
  fills_per_window: int = Field(gt=0)
31
39
  fill_window_ms: int = Field(gt=0)
32
40
  fill_ratio: float = Field(default=1.0, ge=0.0, le=1.0)
@@ -49,6 +57,24 @@ class TestConfig(BaseModel):
49
57
  record_execs: bool = False
50
58
 
51
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
+
52
78
  class OrderPayloadConfig(BaseModel):
53
79
  symbols: list[str] = Field(min_length=1)
54
80
  side: Literal["fixed_buy", "fixed_sell", "alternate", "random"] = "alternate"
@@ -138,6 +164,7 @@ class RunConfig(BaseModel):
138
164
  exec_id_tag: int = Field(default=25116, gt=0)
139
165
  rate: RateConfig
140
166
  test: TestConfig = Field(default_factory=TestConfig)
167
+ amend: AmendConfig = Field(default_factory=AmendConfig)
141
168
  payload: PayloadConfig
142
169
 
143
170
  @model_validator(mode="after")
@@ -169,6 +196,10 @@ class ClientLeg(BaseModel):
169
196
  ops_live: float = 0.0
170
197
  pending: int = 0
171
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
172
203
  scenarios_dispatched: int = 0
173
204
  scenarios_per_sec: float = 0.0
174
205
  scenarios_completed: int = 0
@@ -183,6 +214,7 @@ class VenueLeg(BaseModel):
183
214
  fill_ratio: float = 0.0
184
215
  unfilled: int = 0
185
216
  venue_proc_us_p50: float = 0.0
217
+ amends_received: int = 0
186
218
 
187
219
 
188
220
  class SnapshotErrors(BaseModel):
@@ -202,6 +234,7 @@ class LiveSnapshot(BaseModel):
202
234
  venue: Optional[VenueLeg] = None
203
235
  response_latency: LatencyStats = Field(default_factory=LatencyStats)
204
236
  fill_completion_latency: LatencyStats = Field(default_factory=LatencyStats)
237
+ amend_latency: LatencyStats = Field(default_factory=LatencyStats)
205
238
  scenario_latency: LatencyStats = Field(default_factory=LatencyStats)
206
239
  errors: SnapshotErrors = Field(default_factory=SnapshotErrors)
207
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
@@ -40,6 +40,34 @@ class TokenBucket:
40
40
  await asyncio.sleep((n - self._tokens) / self._rate_per_s)
41
41
 
42
42
 
43
+ class BurstGate:
44
+ """Window-boundary pacer (same `acquire()` contract as TokenBucket).
45
+
46
+ Allows `per_window` acquires back-to-back within each window, then blocks
47
+ everyone until the next boundary — a sawtooth instead of the bucket's even
48
+ spacing. Boundaries stay aligned to the original cadence (no drift).
49
+ Note: it shapes a ceiling — bursts only show when demand inside a window
50
+ exceeds it (e.g. injector loop, or a venue fill backlog)."""
51
+
52
+ def __init__(self, per_window: int, window_ms: int):
53
+ self._per_window = per_window
54
+ self._window_s = window_ms / 1000.0
55
+ self._window_end = time.monotonic() + self._window_s
56
+ self._used = 0
57
+
58
+ async def acquire(self, n: int = 1) -> None:
59
+ while True:
60
+ now = time.monotonic()
61
+ if now >= self._window_end:
62
+ missed = int((now - self._window_end) / self._window_s) + 1
63
+ self._window_end += missed * self._window_s
64
+ self._used = 0
65
+ if self._used + n <= self._per_window:
66
+ self._used += n
67
+ return
68
+ await asyncio.sleep(max(0.0, self._window_end - now))
69
+
70
+
43
71
  class _Reservoir:
44
72
  """Reservoir sampler over latency values (microseconds). Exact count/mean/max;
45
73
  approximate percentiles from a fixed-size representative sample."""
@@ -91,6 +119,10 @@ class PerfStats:
91
119
  self.fills_sent = 0 # venue sent a fill ExecutionReport
92
120
  self.responses = 0 # client got first response for an order
93
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
94
126
  self.lost_timeout = 0
95
127
  self.dropped = 0
96
128
  self.rejected = 0
@@ -102,27 +134,31 @@ class PerfStats:
102
134
 
103
135
  self.response_latency = _Reservoir(reservoir_size)
104
136
  self.fill_completion_latency = _Reservoir(reservoir_size)
137
+ self.amend_latency = _Reservoir(reservoir_size)
105
138
  self.scenario_latency = _Reservoir(reservoir_size)
106
139
 
107
140
  self._last_t = time.monotonic()
108
141
  self._last_orders = 0
109
142
  self._last_fills = 0
110
143
  self._last_scen = 0
144
+ self._last_amends = 0
111
145
 
112
- def rates(self) -> tuple[float, float, float]:
113
- """(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)."""
114
148
  now = time.monotonic()
115
149
  dt = now - self._last_t
116
150
  if dt <= 0:
117
- return 0.0, 0.0, 0.0
151
+ return 0.0, 0.0, 0.0, 0.0
118
152
  ops = (self.orders_sent - self._last_orders) / dt
119
153
  fps = (self.fills_sent - self._last_fills) / dt
120
154
  sps = (self.scenarios_dispatched - self._last_scen) / dt
155
+ aps = (self.amends_sent - self._last_amends) / dt
121
156
  self._last_t = now
122
157
  self._last_orders = self.orders_sent
123
158
  self._last_fills = self.fills_sent
124
159
  self._last_scen = self.scenarios_dispatched
125
- return ops, fps, sps
160
+ self._last_amends = self.amends_sent
161
+ return ops, fps, sps, aps
126
162
 
127
163
  def fill_ratio(self) -> float:
128
164
  # Fraction of orders fully filled (cumQty >= orderQty). Uses completions,
@@ -140,6 +176,10 @@ class PerfStats:
140
176
  "fills_sent": self.fills_sent,
141
177
  "responses": self.responses,
142
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,
143
183
  "lost_timeout": self.lost_timeout,
144
184
  "dropped": self.dropped,
145
185
  "rejected": self.rejected,
@@ -149,5 +189,6 @@ class PerfStats:
149
189
  "fill_ratio": self.fill_ratio(),
150
190
  "response_latency": self.response_latency.stats(),
151
191
  "fill_completion_latency": self.fill_completion_latency.stats(),
192
+ "amend_latency": self.amend_latency.stats(),
152
193
  "scenario_latency": self.scenario_latency.stats(),
153
194
  }