fixtureqa 0.4.14__tar.gz → 0.4.16__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.14/fixtureqa.egg-info → fixtureqa-0.4.16}/PKG-INFO +1 -1
  2. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/core/perf_engine.py +211 -37
  3. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/core/perf_models.py +40 -2
  4. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/core/perf_payload.py +23 -0
  5. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/core/perf_stats.py +14 -0
  6. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/core/session.py +19 -8
  7. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/core/session_manager.py +19 -1
  8. fixtureqa-0.4.16/fixture/static/assets/index-BqX5ek3M.js +102 -0
  9. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/static/index.html +1 -1
  10. {fixtureqa-0.4.14 → fixtureqa-0.4.16/fixtureqa.egg-info}/PKG-INFO +1 -1
  11. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixtureqa.egg-info/SOURCES.txt +1 -1
  12. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/pyproject.toml +1 -1
  13. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/tests/test_perf_engine.py +129 -3
  14. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/tests/test_perf_payload.py +18 -0
  15. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/tests/test_sessions.py +40 -0
  16. fixtureqa-0.4.14/fixture/static/assets/index-DHd_iEnT.js +0 -102
  17. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/LICENSE +0 -0
  18. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/README.md +0 -0
  19. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/__init__.py +0 -0
  20. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/__main__.py +0 -0
  21. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/api/__init__.py +0 -0
  22. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/api/app.py +0 -0
  23. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/api/connection_manager.py +0 -0
  24. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/api/deps.py +0 -0
  25. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/api/routers/__init__.py +0 -0
  26. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/api/routers/admin.py +0 -0
  27. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/api/routers/auth.py +0 -0
  28. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/api/routers/branding.py +0 -0
  29. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/api/routers/custom_tags.py +0 -0
  30. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/api/routers/fix_spec.py +0 -0
  31. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/api/routers/messages.py +0 -0
  32. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/api/routers/perf.py +0 -0
  33. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/api/routers/scenarios.py +0 -0
  34. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/api/routers/sessions.py +0 -0
  35. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/api/routers/setup.py +0 -0
  36. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/api/routers/spec_overlay.py +0 -0
  37. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/api/routers/templates.py +0 -0
  38. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/api/routers/ws.py +0 -0
  39. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/api/schemas.py +0 -0
  40. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/config/__init__.py +0 -0
  41. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/core/__init__.py +0 -0
  42. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/core/atomic_io.py +0 -0
  43. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/core/auth.py +0 -0
  44. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/core/config_store.py +0 -0
  45. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/core/custom_tag_store.py +0 -0
  46. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/core/db_migrations.py +0 -0
  47. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/core/events.py +0 -0
  48. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/core/exec_csv_writer.py +0 -0
  49. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/core/fix_application.py +0 -0
  50. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/core/fix_builder.py +0 -0
  51. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/core/fix_parser.py +0 -0
  52. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/core/fix_spec_parser.py +0 -0
  53. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/core/fix_tags.py +0 -0
  54. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/core/fix_time.py +0 -0
  55. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/core/housekeeping.py +0 -0
  56. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/core/inbound.py +0 -0
  57. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/core/json_store.py +0 -0
  58. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/core/message_log.py +0 -0
  59. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/core/message_store.py +0 -0
  60. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/core/models.py +0 -0
  61. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/core/perf_store.py +0 -0
  62. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/core/perf_writer.py +0 -0
  63. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/core/scenario_runner.py +0 -0
  64. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/core/scenario_store.py +0 -0
  65. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/core/spec_overlay_store.py +0 -0
  66. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/core/template_store.py +0 -0
  67. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/core/user_store.py +0 -0
  68. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/core/venue_responses.py +0 -0
  69. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/fix_specs/FIX42.xml +0 -0
  70. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/fix_specs/FIX44.xml +0 -0
  71. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/server.py +0 -0
  72. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
  73. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/static/assets/index-BwQf-cei.css +0 -0
  74. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/static/assets/index-CyNOPa0n.js +0 -0
  75. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
  76. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/static/favicon.svg +0 -0
  77. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixture/ui/__init__.py +0 -0
  78. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixtureqa.egg-info/dependency_links.txt +0 -0
  79. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixtureqa.egg-info/entry_points.txt +0 -0
  80. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixtureqa.egg-info/requires.txt +0 -0
  81. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/fixtureqa.egg-info/top_level.txt +0 -0
  82. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/setup.cfg +0 -0
  83. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/tests/test_atomic_io.py +0 -0
  84. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/tests/test_auth.py +0 -0
  85. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/tests/test_config_store.py +0 -0
  86. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/tests/test_connection_manager.py +0 -0
  87. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/tests/test_db_migrations.py +0 -0
  88. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/tests/test_fix_builder.py +0 -0
  89. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/tests/test_health.py +0 -0
  90. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/tests/test_inbound.py +0 -0
  91. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/tests/test_inbound_validation.py +0 -0
  92. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/tests/test_message_store.py +0 -0
  93. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/tests/test_perf_api.py +0 -0
  94. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/tests/test_perf_models.py +0 -0
  95. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/tests/test_perf_rehydrate.py +0 -0
  96. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/tests/test_scenarios.py +0 -0
  97. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/tests/test_session_lifecycle.py +0 -0
  98. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/tests/test_session_manager_concurrency.py +0 -0
  99. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/tests/test_templates.py +0 -0
  100. {fixtureqa-0.4.14 → fixtureqa-0.4.16}/tests/test_ws.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fixtureqa
3
- Version: 0.4.14
3
+ Version: 0.4.16
4
4
  Summary: FIXture — FIX Protocol Testing Tool
5
5
  Requires-Python: >=3.10
6
6
  License-File: LICENSE
@@ -108,9 +108,12 @@ class PerfRun:
108
108
  self._pending: dict[str, dict] = {}
109
109
  self._order_seq = 0
110
110
 
111
- # Amend injection is fill-triggered on the client leg (35=D orders only).
111
+ # Amend injection is fill-triggered on the client leg (35=D orders only);
112
+ # cancel injection is ack- or fill-triggered. Both park their send tasks
113
+ # in _aux_tasks (strong refs; cancelled together at shutdown).
112
114
  self._amend_on = config.amend.enabled and self._has_client
113
- self._amend_tasks: set[asyncio.Task] = set()
115
+ self._cancel_on = config.cancel.enabled and self._has_client
116
+ self._aux_tasks: set[asyncio.Task] = set()
114
117
 
115
118
  # Venue order book: corr → resumable fill state. The dispatcher books
116
119
  # orders; _fill_sender drains them round-robin (via _fill_runnable),
@@ -167,6 +170,10 @@ class PerfRun:
167
170
  self.warnings.append(
168
171
  "amend injection has no effect in venue mode (no client leg); "
169
172
  "inbound 35=G from the external client is still answered")
173
+ if self.config.cancel.enabled and not self._has_client:
174
+ self.warnings.append(
175
+ "cancel injection has no effect in venue mode (no client leg); "
176
+ "inbound 35=F from the external client is still answered")
170
177
  if self._has_client:
171
178
  self._client_sub = self._sm.subscribe_session(self.config.client_session_id)
172
179
  if self._has_venue:
@@ -218,12 +225,14 @@ class PerfRun:
218
225
  await asyncio.sleep(0.02)
219
226
  else:
220
227
  await asyncio.sleep(_GRACE_DRAIN_S)
221
- for t in list(self._tasks) + list(self._amend_tasks):
228
+ for t in list(self._tasks) + list(self._aux_tasks):
222
229
  t.cancel()
223
- await asyncio.gather(*self._tasks, *self._amend_tasks, return_exceptions=True)
230
+ await asyncio.gather(*self._tasks, *self._aux_tasks, return_exceptions=True)
224
231
  for entry in list(self._pending.values()):
225
232
  if entry.get("amend_inflight"):
226
233
  self.stats.amends_lost += 1
234
+ if entry.get("cancel_inflight"):
235
+ self.stats.cancels_lost += 1
227
236
  self._finalize(entry, "filled" if entry.get("completed") else "lost")
228
237
  self._pending.clear()
229
238
  self._venue_orders.clear()
@@ -351,6 +360,11 @@ class PerfRun:
351
360
  meta["amends_sent"] = 0
352
361
  meta["amend_inflight"] = False
353
362
  meta["completed"] = False
363
+ if self._cancel_on and meta.get("msg_type") == "D":
364
+ meta["cancel_eligible"] = random.random() < self.config.cancel.cancel_ratio
365
+ meta["cancel_sent"] = False
366
+ meta["cancel_inflight"] = False
367
+ meta.setdefault("completed", False)
354
368
  if resp_event is not None:
355
369
  meta["resp_event"] = resp_event
356
370
  self._pending[meta["corr_id"]] = meta
@@ -425,6 +439,9 @@ class PerfRun:
425
439
  if inb.msg_type == "G":
426
440
  await self._handle_amend(inb.fields)
427
441
  continue
442
+ if inb.msg_type == "F":
443
+ await self._handle_cancel(inb.fields)
444
+ continue
428
445
  if inb.msg_type != "D":
429
446
  continue
430
447
  self.stats.orders_received += 1
@@ -566,6 +583,62 @@ class PerfRun:
566
583
  if er is not None:
567
584
  await self._sm.send_message(cfg.venue_session_id, er)
568
585
 
586
+ async def _handle_cancel(self, of: dict) -> None:
587
+ """Venue side of 35=F, per config.cancel.venue_response: "reject"
588
+ answers 35=9 OrderCancelReject (CxlRejReason from config, correlation
589
+ tag echoed) and the order keeps working — the cancel-reject flavour;
590
+ "cancel" answers 150=4/39=4 Canceled with the real CumQty and stops
591
+ any remaining fills. Like acks, replies are immediate, not
592
+ fill-rate-limited; unknown orders reply stateless."""
593
+ cfg = self.config
594
+ self.stats.cancels_received += 1
595
+ corr = of.get(cfg.correlation_tag)
596
+ if corr is None:
597
+ # Can't reply — there is nothing to echo for the client to correlate.
598
+ self.stats.cancels_uncorrelated += 1
599
+ if self.stats.cancels_uncorrelated == 1:
600
+ self.warnings.append(
601
+ f"venue received 35=F without correlation tag "
602
+ f"{cfg.correlation_tag} — no reply sent; check the "
603
+ f"counterparty passes the tag through on cancels")
604
+ return
605
+ entry = self._venue_orders.get(corr)
606
+ if cfg.cancel.venue_response == "cancel":
607
+ if entry is not None:
608
+ cum, px = entry["cum"], entry["px"]
609
+ self._venue_orders.pop(corr, None) # stop remaining fills
610
+ else:
611
+ cum, px = 0, of.get(T_PRICE) or "0"
612
+ er = self._build_exec(of, exec_type="4", ord_status="4", last_qty=0,
613
+ cum_qty=cum, leaves_qty=0, px=px,
614
+ orig_clordid=of.get(T_ORIGCLORDID))
615
+ if er is not None:
616
+ await self._sm.send_message(cfg.venue_session_id, er)
617
+ return
618
+ msg = self._build_cancel_reject(of, entry)
619
+ await self._sm.send_message(cfg.venue_session_id, msg)
620
+
621
+ def _build_cancel_reject(self, of: dict, entry: Optional[dict]) -> Message:
622
+ """35=9 OrderCancelReject answering a 35=F: CxlRejResponseTo(434)=1,
623
+ CxlRejReason(102) from config, OrdStatus(39) from the book when the
624
+ order is known, correlation tag echoed so the client can close its
625
+ in-flight cancel."""
626
+ cfg = self.config
627
+ clordid = of.get(T_CLORDID, "")
628
+ msg = Message()
629
+ msg.header.set(T_MSGTYPE, "9")
630
+ msg.set_field(T_CLORDID, clordid)
631
+ msg.set_field(T_ORIGCLORDID, of.get(T_ORIGCLORDID) or clordid)
632
+ msg.set_field(T_ORDERID, "NONE")
633
+ cum = entry["cum"] if entry is not None else 0
634
+ msg.set_field(T_ORDSTATUS, "1" if cum > 0 else "0")
635
+ msg.set_field(102, str(cfg.cancel.cxl_rej_reason))
636
+ msg.set_field(434, "1")
637
+ corr = of.get(cfg.correlation_tag)
638
+ if corr:
639
+ msg.set_field(cfg.correlation_tag, corr)
640
+ return msg
641
+
569
642
  def _exec(self, of: dict, **kw) -> Optional[Message]:
570
643
  """Build one ExecReport via the exec template when configured, else the
571
644
  built-in builder (Phase B). Both enforce the standard correlation tags."""
@@ -581,6 +654,8 @@ class PerfRun:
581
654
  fill_type: 'full' completes the order (final 150=2/39=2); 'partial' stops
582
655
  at partial_fill_pct% and never completes (all 150=1/39=1)."""
583
656
  f = self.config.payload.fill
657
+ if f.fills_per_order == 0:
658
+ return [] # ack-only orders (cancel flavour): never booked, no fills
584
659
  ftype = f.fill_type
585
660
  if ftype == "random":
586
661
  ftype = random.choice(("full", "partial"))
@@ -652,31 +727,94 @@ class PerfRun:
652
727
  while True:
653
728
  inb = await sub.get()
654
729
  if inb.msg_type == "9":
655
- self._on_cancel_reject(inb.fields)
730
+ self._on_cancel_reject(inb.fields, inb.recv_perf_ns or time.perf_counter_ns())
656
731
  continue
657
732
  if inb.msg_type != "8":
658
733
  continue
659
734
  self._on_exec_report(inb.fields, inb.recv_perf_ns or time.perf_counter_ns())
660
735
 
661
- def _on_cancel_reject(self, f: dict) -> None:
662
- """OrderCancelReject (35=9): the counterparty refused the amend. Count
663
- it (these would otherwise be invisible only 35=8 is correlated) and
664
- release the in-flight amend so the order finalizes normally instead of
665
- being reaped as amends_lost."""
736
+ def _on_cancel_reject(self, f: dict, now_ns: int) -> None:
737
+ """OrderCancelReject (35=9): answers either a 35=F (CxlRejResponseTo
738
+ 434=1 the cancel-reject flavour's expected reply) or a 35=G (434=2
739
+ an amend the counterparty refused). Dispatch on 434; when it's absent,
740
+ infer from which request is in flight on the correlated entry."""
741
+ corr = f.get(self.config.correlation_tag)
742
+ entry = self._pending.get(corr) if corr is not None else None
743
+ resp_to = f.get(434)
744
+ if resp_to == "1" or (resp_to is None and entry is not None
745
+ and entry.get("cancel_inflight")
746
+ and not entry.get("amend_inflight")):
747
+ self._on_cancel_rejected(corr, entry, now_ns)
748
+ return
666
749
  self.stats.amend_rejects += 1
667
750
  if self.stats.amend_rejects == 1:
668
751
  self.warnings.append(
669
752
  "counterparty rejected an amend (35=9 OrderCancelReject) — "
670
753
  "it may not match OrigClOrdID(41) against its own order chain")
671
- corr = f.get(self.config.correlation_tag)
672
- entry = self._pending.get(corr) if corr is not None else None
673
754
  if entry is None or not entry.get("amend_inflight"):
674
755
  return
675
756
  entry["amend_inflight"] = False
676
757
  entry["pending_clordid"] = None # chain tip stays at the last accepted
758
+ if entry.get("completed") and not entry.get("cancel_inflight"):
759
+ self._pending.pop(corr, None)
760
+ self._finalize(entry, "filled")
761
+
762
+ def _on_cancel_rejected(self, corr: str, entry: Optional[dict], now_ns: int) -> None:
763
+ """The counterparty refused this order's 35=F. In the ack-only flavour
764
+ (fills_per_order=0) the reject is the order's terminal event."""
765
+ self.stats.cancel_rejects += 1
766
+ if entry is None or not entry.get("cancel_inflight"):
767
+ return
768
+ sent_ns = entry.get("cancel_sent_ns")
769
+ if sent_ns:
770
+ self.stats.cancel_latency.add((now_ns - sent_ns) / 1000.0)
771
+ entry["cancel_inflight"] = False
772
+ if entry.get("amend_inflight"):
773
+ return # still awaiting the amend reply; that path finalizes
677
774
  if entry.get("completed"):
678
775
  self._pending.pop(corr, None)
679
776
  self._finalize(entry, "filled")
777
+ elif self.config.payload.fill.fills_per_order == 0:
778
+ # Ack-only order: no fill will ever finalize it — the reject does.
779
+ entry["completed"] = True
780
+ self._pending.pop(corr, None)
781
+ self._finalize(entry, "cancel_rejected")
782
+
783
+ def _on_canceled(self, corr: str, entry: dict, now_ns: int) -> None:
784
+ """Client got a Canceled ack (150=4): terminal for the order — unless
785
+ it fully filled first (the venue's stateless fallback answers a late F
786
+ on a completed order), which the fill completion already accounted."""
787
+ sent_ns = entry.get("cancel_sent_ns")
788
+ if entry.get("cancel_inflight") and sent_ns:
789
+ self.stats.cancel_latency.add((now_ns - sent_ns) / 1000.0)
790
+ entry["cancel_inflight"] = False
791
+ if entry.get("completed"):
792
+ if not entry.get("amend_inflight"):
793
+ self._pending.pop(corr, None)
794
+ self._finalize(entry, "filled")
795
+ return
796
+ self.stats.cancels_canceled += 1
797
+ entry["completed"] = True
798
+ self._pending.pop(corr, None)
799
+ self._finalize(entry, "canceled")
800
+
801
+ def _launch_cancel(self, entry: dict) -> None:
802
+ entry["cancel_sent"] = True
803
+ entry["cancel_inflight"] = True
804
+ t = asyncio.create_task(self._send_cancel(entry))
805
+ self._aux_tasks.add(t)
806
+ t.add_done_callback(self._aux_tasks.discard)
807
+
808
+ async def _send_cancel(self, entry: dict) -> None:
809
+ """Build and send the order's one 35=F (on the ack for after_fills=0,
810
+ else on the nth fill) — demand-driven, no pacer of its own."""
811
+ msg, new_clordid = self._factory.build_cancel(entry)
812
+ entry["cancel_clordid"] = new_clordid
813
+ entry["cancel_sent_ns"] = time.perf_counter_ns()
814
+ if await self._sm.send_message(self.config.client_session_id, msg):
815
+ self.stats.cancels_sent += 1
816
+ else:
817
+ entry["cancel_inflight"] = False
680
818
 
681
819
  def _on_exec_report(self, f: dict, now_ns: int) -> None:
682
820
  cfg = self.config
@@ -704,12 +842,15 @@ class PerfRun:
704
842
  ((now_ns - prev_ns) / 1000.0) if prev_ns is not None else None,
705
843
  ))
706
844
 
707
- # Amend responses (Pending Replace / Replaced) are not order responses:
708
- # they must not count as first response, a fill, or a completion.
845
+ # Amend/cancel responses (Pending Replace / Replaced / Pending Cancel /
846
+ # Canceled) are not order responses: they must not count as first
847
+ # response, a fill, or a completion.
709
848
  exec_type = f.get(T_EXECTYPE)
710
- if exec_type in ("E", "5"):
849
+ if exec_type in ("E", "5", "6", "4"):
711
850
  if exec_type == "5":
712
851
  self._on_replaced(corr, entry, now_ns)
852
+ elif exec_type == "4":
853
+ self._on_canceled(corr, entry, now_ns)
713
854
  return
714
855
 
715
856
  if not entry["responded"]:
@@ -723,23 +864,47 @@ class PerfRun:
723
864
  if ev is not None:
724
865
  ev.set()
725
866
 
867
+ # Cancel trigger, on-ack flavour (after_fills=0): the first response is
868
+ # the ack — send the order's one 35=F as soon as the order exists
869
+ # downstream. cancel_sent guards re-entry on later execs. Deliberately
870
+ # NOT gated on _injecting: the venue answers a 35=F at any time, and in
871
+ # the ack-only flavour the reply is what finalizes orders acked during
872
+ # the end-of-run drain.
873
+ if (self._cancel_on and entry.get("cancel_eligible")
874
+ and not entry.get("cancel_sent")
875
+ and self.config.cancel.after_fills == 0):
876
+ self._launch_cancel(entry)
877
+
726
878
  cum = _to_int(f.get(T_CUMQTY), 0)
727
879
 
728
- # Amend trigger: every nth fill of a still-working order. Counting cum
729
- # increases (not ExecType values) keeps it venue-agnostic (4.2's 150=1/2
730
- # and 4.4's 150=F alike). Never fires on the final fill.
731
- if self._amend_on and entry.get("amend_eligible") and cum > entry.get("cum_seen", 0):
880
+ # Fill counting: cum increases (not ExecType values) keep it
881
+ # venue-agnostic (4.2's 150=1/2 and 4.4's 150=F alike).
882
+ if cum > entry.get("cum_seen", 0):
732
883
  entry["cum_seen"] = cum
733
- entry["fills_seen"] += 1
734
- a = self.config.amend
735
- if (self._injecting and not entry["amend_inflight"]
736
- and cum < entry["order_qty"]
737
- and entry["fills_seen"] % a.every_n_fills == 0
738
- and (a.max_per_order == 0 or entry["amends_sent"] < a.max_per_order)):
739
- entry["amend_inflight"] = True
740
- t = asyncio.create_task(self._send_amend(entry))
741
- self._amend_tasks.add(t)
742
- t.add_done_callback(self._amend_tasks.discard)
884
+ entry["fills_seen"] = entry.get("fills_seen", 0) + 1
885
+
886
+ # Amend trigger: every nth fill of a still-working order.
887
+ # Never fires on the final fill.
888
+ if self._amend_on and entry.get("amend_eligible"):
889
+ a = self.config.amend
890
+ if (self._injecting and not entry["amend_inflight"]
891
+ and cum < entry["order_qty"]
892
+ and entry["fills_seen"] % a.every_n_fills == 0
893
+ and (a.max_per_order == 0 or entry["amends_sent"] < a.max_per_order)):
894
+ entry["amend_inflight"] = True
895
+ t = asyncio.create_task(self._send_amend(entry))
896
+ self._aux_tasks.add(t)
897
+ t.add_done_callback(self._aux_tasks.discard)
898
+
899
+ # Cancel trigger, nth-fill flavour: one 35=F on the configured
900
+ # fill of a still-working order. Not gated on _injecting (see the
901
+ # on-ack trigger above).
902
+ if (self._cancel_on and entry.get("cancel_eligible")
903
+ and not entry.get("cancel_sent")
904
+ and self.config.cancel.after_fills > 0
905
+ and entry["fills_seen"] == self.config.cancel.after_fills
906
+ and cum < entry["order_qty"]):
907
+ self._launch_cancel(entry)
743
908
 
744
909
  if cum >= entry["order_qty"] and not entry.get("completed"):
745
910
  lat_us = (now_ns - entry["sent_ns"]) / 1000.0
@@ -749,11 +914,11 @@ class PerfRun:
749
914
  entry["fill_price"] = f.get(T_LASTPX) or f.get(T_AVGPX)
750
915
  self.stats.completions += 1
751
916
  self.stats.fill_completion_latency.add(lat_us)
752
- if entry.get("amend_inflight"):
753
- # The venue dispatches an order's fills before it reads the 35=G,
754
- # so the Replaced ack usually lands after the final fill. Keep
755
- # the entry so the ack still correlates; _on_replaced (or the
756
- # sweeper / shutdown) finalizes it.
917
+ if entry.get("amend_inflight") or entry.get("cancel_inflight"):
918
+ # An amend/cancel reply can land after the final fill (the
919
+ # venue answers the G/F it reads after dispatching fills).
920
+ # Keep the entry so the reply still correlates; _on_replaced /
921
+ # _on_cancel_rejected (or the sweeper / shutdown) finalizes it.
757
922
  entry["completed"] = True
758
923
  else:
759
924
  entry["completed"] = True
@@ -770,7 +935,7 @@ class PerfRun:
770
935
  entry["clordid"] = entry["pending_clordid"]
771
936
  entry["pending_clordid"] = None
772
937
  entry["amend_inflight"] = False
773
- if entry.get("completed"):
938
+ if entry.get("completed") and not entry.get("cancel_inflight"):
774
939
  self._pending.pop(corr, None)
775
940
  self._finalize(entry, "filled")
776
941
 
@@ -811,8 +976,10 @@ class PerfRun:
811
976
  continue
812
977
  if entry.get("amend_inflight"):
813
978
  self.stats.amends_lost += 1
979
+ if entry.get("cancel_inflight"):
980
+ self.stats.cancels_lost += 1
814
981
  if entry.get("completed"):
815
- # Order filled; only the amend's Replaced ack never came —
982
+ # Order filled; only the amend/cancel reply never came —
816
983
  # don't double-count a completed order as lost.
817
984
  self._finalize(entry, "filled")
818
985
  else:
@@ -858,6 +1025,10 @@ class PerfRun:
858
1025
  amends_lost=self.stats.amends_lost,
859
1026
  amend_rejects=self.stats.amend_rejects,
860
1027
  aps_live=round(aps, 1),
1028
+ cancels_sent=self.stats.cancels_sent,
1029
+ cancel_rejects=self.stats.cancel_rejects,
1030
+ cancels_canceled=self.stats.cancels_canceled,
1031
+ cancels_lost=self.stats.cancels_lost,
861
1032
  scenarios_dispatched=self.stats.scenarios_dispatched,
862
1033
  scenarios_per_sec=round(sps, 1),
863
1034
  scenarios_completed=self.stats.scenarios_completed,
@@ -872,7 +1043,9 @@ class PerfRun:
872
1043
  unfilled=max(0, self.stats.orders_sent - self.stats.completions),
873
1044
  amends_received=self.stats.amends_received,
874
1045
  amends_uncorrelated=self.stats.amends_uncorrelated,
875
- amend_waits_expired=self.stats.amend_waits_expired)
1046
+ amend_waits_expired=self.stats.amend_waits_expired,
1047
+ cancels_received=self.stats.cancels_received,
1048
+ cancels_uncorrelated=self.stats.cancels_uncorrelated)
876
1049
  snap = LiveSnapshot(
877
1050
  run_id=self.run_id, status=self.status, mode=self.config.mode,
878
1051
  elapsed_s=round(self._elapsed_s(), 1), duration_s=self.config.test.duration,
@@ -881,6 +1054,7 @@ class PerfRun:
881
1054
  response_latency=LatencyStats(**self.stats.response_latency.stats()),
882
1055
  fill_completion_latency=LatencyStats(**self.stats.fill_completion_latency.stats()),
883
1056
  amend_latency=LatencyStats(**self.stats.amend_latency.stats()),
1057
+ cancel_latency=LatencyStats(**self.stats.cancel_latency.stats()),
884
1058
  scenario_latency=LatencyStats(**self.stats.scenario_latency.stats()),
885
1059
  errors=SnapshotErrors(lost_timeout=self.stats.lost_timeout,
886
1060
  rejected=self.stats.rejected,
@@ -84,6 +84,26 @@ class AmendConfig(BaseModel):
84
84
  amend_wait_ms: int = Field(default=2000, gt=0)
85
85
 
86
86
 
87
+ class CancelConfig(BaseModel):
88
+ """Client-leg cancel traffic (35=F OrderCancelRequest) — a run flavour
89
+ alongside amends.
90
+
91
+ `after_fills` picks the trigger: 0 sends the F as soon as the order is
92
+ acked (the D > ack > F > reply flavour; pair with fills_per_order=0 for a
93
+ pure no-fill run), n sends it after the nth fill. `cancel_ratio` picks the
94
+ eligible fraction of orders; one F per order.
95
+
96
+ `venue_response` shapes the in-process venue's reply: "reject" answers
97
+ 35=9 OrderCancelReject (CxlRejReason=`cxl_rej_reason`, CxlRejResponseTo=1,
98
+ correlation tag echoed) and the order keeps working; "cancel" answers
99
+ 150=4/39=4 Canceled with the real CumQty and stops any remaining fills."""
100
+ enabled: bool = False
101
+ after_fills: int = Field(default=0, ge=0)
102
+ cancel_ratio: float = Field(default=1.0, ge=0.0, le=1.0)
103
+ venue_response: Literal["reject", "cancel"] = "reject"
104
+ cxl_rej_reason: int = Field(default=0, ge=0) # tag 102; 0 = too late to cancel
105
+
106
+
87
107
  class OrderPayloadConfig(BaseModel):
88
108
  symbols: list[str] = Field(min_length=1)
89
109
  side: Literal["fixed_buy", "fixed_sell", "alternate", "random"] = "alternate"
@@ -108,8 +128,9 @@ class FillPayloadConfig(BaseModel):
108
128
  # FIX lifecycle: ack the order (35=8|150=0|39=0) before any fills.
109
129
  send_ack: bool = True
110
130
  # Split a filled order into N ExecutionReports: N-1 partials then a final fill
111
- # bringing CumQty to OrderQty (1 = single fill).
112
- fills_per_order: int = Field(default=1, gt=0)
131
+ # bringing CumQty to OrderQty (1 = single fill; 0 = ack-only, no fills —
132
+ # the cancel flavour's D > ack > F > reply sequence).
133
+ fills_per_order: int = Field(default=1, ge=0)
113
134
  partial_fill_pct_min: int = Field(default=50, ge=1, le=100)
114
135
  partial_fill_pct_max: int = Field(default=99, ge=1, le=100)
115
136
  price_variance_ticks: int = Field(default=0, ge=0)
@@ -174,6 +195,7 @@ class RunConfig(BaseModel):
174
195
  rate: RateConfig
175
196
  test: TestConfig = Field(default_factory=TestConfig)
176
197
  amend: AmendConfig = Field(default_factory=AmendConfig)
198
+ cancel: CancelConfig = Field(default_factory=CancelConfig)
177
199
  payload: PayloadConfig
178
200
 
179
201
  @model_validator(mode="after")
@@ -184,6 +206,15 @@ class RunConfig(BaseModel):
184
206
  raise ValueError(f"mode {self.mode!r} requires client_session_id")
185
207
  if need_venue and not self.venue_session_id:
186
208
  raise ValueError(f"mode {self.mode!r} requires venue_session_id")
209
+ if self.payload.fill.fills_per_order == 0:
210
+ # Ack-only orders never complete via fills: without a cancel reply
211
+ # to finalize them, every order just times out as lost.
212
+ if not self.cancel.enabled:
213
+ raise ValueError("fills_per_order=0 requires the cancel flavour "
214
+ "(cancel.enabled) — ack-only orders never fill")
215
+ if self.cancel.after_fills > 0:
216
+ raise ValueError("cancel.after_fills > 0 needs fills: "
217
+ "with fills_per_order=0 the 35=F would never trigger")
187
218
  return self
188
219
 
189
220
 
@@ -210,6 +241,10 @@ class ClientLeg(BaseModel):
210
241
  amends_lost: int = 0
211
242
  amend_rejects: int = 0
212
243
  aps_live: float = 0.0
244
+ cancels_sent: int = 0
245
+ cancel_rejects: int = 0
246
+ cancels_canceled: int = 0
247
+ cancels_lost: int = 0
213
248
  scenarios_dispatched: int = 0
214
249
  scenarios_per_sec: float = 0.0
215
250
  scenarios_completed: int = 0
@@ -227,6 +262,8 @@ class VenueLeg(BaseModel):
227
262
  amends_received: int = 0
228
263
  amends_uncorrelated: int = 0
229
264
  amend_waits_expired: int = 0
265
+ cancels_received: int = 0
266
+ cancels_uncorrelated: int = 0
230
267
 
231
268
 
232
269
  class SnapshotErrors(BaseModel):
@@ -247,6 +284,7 @@ class LiveSnapshot(BaseModel):
247
284
  response_latency: LatencyStats = Field(default_factory=LatencyStats)
248
285
  fill_completion_latency: LatencyStats = Field(default_factory=LatencyStats)
249
286
  amend_latency: LatencyStats = Field(default_factory=LatencyStats)
287
+ cancel_latency: LatencyStats = Field(default_factory=LatencyStats)
250
288
  scenario_latency: LatencyStats = Field(default_factory=LatencyStats)
251
289
  errors: SnapshotErrors = Field(default_factory=SnapshotErrors)
252
290
 
@@ -427,6 +427,29 @@ class PayloadFactory:
427
427
  msg.set_field(60, _utc_ms())
428
428
  return msg, new_clordid
429
429
 
430
+ # -- cancel (35=F OrderCancelRequest) --------------------------------
431
+
432
+ def build_cancel(self, meta: dict) -> tuple[Message, str]:
433
+ """Cancel a working order. Carries the order's correlation id so the
434
+ reply (35=9 reject or 150=4 Canceled) routes to the same pending
435
+ entry; OrigClOrdID(41) is the current chain tip, ClOrdID(11) is
436
+ fresh. Routing/identity tags (AMEND_MIRROR_TAGS) are echoed verbatim
437
+ from the order, exactly like amends — endpoints commonly require
438
+ them to match across the chain."""
439
+ new_clordid = self._clordid()
440
+ msg = Message()
441
+ msg.header.set(35, "F")
442
+ msg.set_field(11, new_clordid)
443
+ msg.set_field(41, meta["clordid"])
444
+ msg.set_field(self.config.correlation_tag, meta["corr_id"])
445
+ for tag, val in (meta.get("mirror_tags") or {}).items():
446
+ msg.set_field(tag, val)
447
+ msg.set_field(55, meta["symbol"])
448
+ msg.set_field(54, meta["side"])
449
+ msg.set_field(38, str(meta["order_qty"]))
450
+ msg.set_field(60, _utc_ms())
451
+ return msg, new_clordid
452
+
430
453
  def _apply_expiry(self, msg: Message, fields: dict) -> None:
431
454
  """Rewrite ExpireTime(126)/ExpireDate(432) to a shared future instant —
432
455
  only for tags the message already carries (never inserts), exactly like
@@ -126,6 +126,12 @@ class PerfStats:
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
128
  self.amend_waits_expired = 0 # venue fill-hold released by grace, not by a 35=G
129
+ self.cancels_sent = 0 # client sent a 35=F
130
+ self.cancel_rejects = 0 # client got a 35=9 answering its 35=F (434=1)
131
+ self.cancels_canceled = 0 # client got a Canceled ack (150=4)
132
+ self.cancels_lost = 0 # cancel in flight when its order was reaped
133
+ self.cancels_received = 0 # venue saw an inbound 35=F
134
+ self.cancels_uncorrelated = 0 # inbound 35=F lacked the correlation tag (no reply)
129
135
  self.lost_timeout = 0
130
136
  self.dropped = 0
131
137
  self.rejected = 0
@@ -138,6 +144,7 @@ class PerfStats:
138
144
  self.response_latency = _Reservoir(reservoir_size)
139
145
  self.fill_completion_latency = _Reservoir(reservoir_size)
140
146
  self.amend_latency = _Reservoir(reservoir_size)
147
+ self.cancel_latency = _Reservoir(reservoir_size)
141
148
  self.scenario_latency = _Reservoir(reservoir_size)
142
149
 
143
150
  self._last_t = time.monotonic()
@@ -186,6 +193,12 @@ class PerfStats:
186
193
  "amends_received": self.amends_received,
187
194
  "amends_uncorrelated": self.amends_uncorrelated,
188
195
  "amend_waits_expired": self.amend_waits_expired,
196
+ "cancels_sent": self.cancels_sent,
197
+ "cancel_rejects": self.cancel_rejects,
198
+ "cancels_canceled": self.cancels_canceled,
199
+ "cancels_lost": self.cancels_lost,
200
+ "cancels_received": self.cancels_received,
201
+ "cancels_uncorrelated": self.cancels_uncorrelated,
189
202
  "lost_timeout": self.lost_timeout,
190
203
  "dropped": self.dropped,
191
204
  "rejected": self.rejected,
@@ -196,5 +209,6 @@ class PerfStats:
196
209
  "response_latency": self.response_latency.stats(),
197
210
  "fill_completion_latency": self.fill_completion_latency.stats(),
198
211
  "amend_latency": self.amend_latency.stats(),
212
+ "cancel_latency": self.cancel_latency.stats(),
199
213
  "scenario_latency": self.scenario_latency.stats(),
200
214
  }
@@ -51,14 +51,8 @@ class Session:
51
51
  def __init__(self, config: SessionConfig, event_handler: EventHandler,
52
52
  msg_writer: Optional["MessageWriter"] = None):
53
53
  self.config = config
54
- if msg_writer is not None and config.log_messages:
55
- from .message_store import MessageStore
56
- self._message_log = MessageStore(config.session_id, msg_writer)
57
- else:
58
- # No persistent writer, or logging disabled for this session
59
- # (high-rate/perf): in-memory log only, no per-message SQLite churn.
60
- from .message_log import MessageLog
61
- self._message_log = MessageLog()
54
+ self._msg_writer = msg_writer
55
+ self._message_log = self._pick_message_log()
62
56
  self.state = SessionState(config=config, message_log=self._message_log)
63
57
  self._event_handler = event_handler
64
58
  self._transport: Optional[SocketInitiator | SocketAcceptor] = None
@@ -69,6 +63,23 @@ class Session:
69
63
  # and the ack/fill silently never sent.
70
64
  self._venue_tasks: set[asyncio.Task] = set()
71
65
 
66
+ def _pick_message_log(self):
67
+ if self._msg_writer is not None and self.config.log_messages:
68
+ from .message_store import MessageStore
69
+ return MessageStore(self.config.session_id, self._msg_writer)
70
+ # No persistent writer, or logging disabled for this session
71
+ # (high-rate/perf): in-memory log only, no per-message SQLite churn.
72
+ from .message_log import MessageLog
73
+ return MessageLog()
74
+
75
+ def rebind_message_log(self) -> None:
76
+ """Re-run the log-backend choice after config.log_messages changed
77
+ (standard <-> perf conversion on a stopped session). Switching to the
78
+ persistent store re-exposes the session's messages.db history; the
79
+ in-memory ring from perf mode is not carried over."""
80
+ self._message_log = self._pick_message_log()
81
+ self.state.message_log = self._message_log
82
+
72
83
  # ------------------------------------------------------------------
73
84
  # Public API
74
85
  # ------------------------------------------------------------------
@@ -1,5 +1,7 @@
1
1
  import copy
2
2
  import logging
3
+ import os
4
+ import shutil
3
5
  import threading
4
6
  from typing import Dict, List, Optional, Set
5
7
  from .models import SessionConfig, SessionState, SessionStatus
@@ -73,7 +75,15 @@ class SessionManager:
73
75
  await self._get(session_id).stop(force=force)
74
76
 
75
77
  def update_session(self, session_id: str, req) -> SessionConfig:
76
- """Update config fields on a stopped session."""
78
+ """Update config fields on a stopped session.
79
+
80
+ Standard <-> perf conversion happens here too: `durable_seqnums` and
81
+ the engine-side logging are start-time decisions and need nothing,
82
+ but flipping `log_messages` rebinds the message-log backend
83
+ (SQLite-backed store vs in-memory ring), and flipping
84
+ `durable_seqnums` clears the session's file store so the next start
85
+ gets clean seqnums instead of resuming from a stale file.
86
+ """
77
87
  from .models import ConnectionType, SessionRole
78
88
  session = self._get(session_id)
79
89
  with self._lock:
@@ -92,9 +102,17 @@ class SessionManager:
92
102
  for key, value in updates.items():
93
103
  setattr(candidate, key, value)
94
104
  self._validate_config(candidate, exclude_id=session_id)
105
+ old_log_messages = cfg.log_messages
106
+ old_durable = cfg.durable_seqnums
95
107
  for key, value in updates.items():
96
108
  setattr(cfg, key, value)
109
+ if cfg.log_messages != old_log_messages:
110
+ session.rebind_message_log()
97
111
  self._persist() # disk I/O — outside the lock
112
+ if cfg.durable_seqnums != old_durable:
113
+ store_path = os.path.abspath(
114
+ os.path.join(cfg.file_store_path, session_id))
115
+ shutil.rmtree(store_path, ignore_errors=True)
98
116
  return cfg
99
117
 
100
118
  async def remove_session(self, session_id: str) -> None: