fixtureqa 0.4.15__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.
- {fixtureqa-0.4.15/fixtureqa.egg-info → fixtureqa-0.4.16}/PKG-INFO +1 -1
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/core/perf_engine.py +211 -37
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/core/perf_models.py +40 -2
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/core/perf_payload.py +23 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/core/perf_stats.py +14 -0
- fixtureqa-0.4.16/fixture/static/assets/index-BqX5ek3M.js +102 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/static/index.html +1 -1
- {fixtureqa-0.4.15 → fixtureqa-0.4.16/fixtureqa.egg-info}/PKG-INFO +1 -1
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixtureqa.egg-info/SOURCES.txt +1 -1
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/pyproject.toml +1 -1
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/tests/test_perf_engine.py +129 -3
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/tests/test_perf_payload.py +18 -0
- fixtureqa-0.4.15/fixture/static/assets/index-BVKx6Ai2.js +0 -102
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/LICENSE +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/README.md +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/__init__.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/__main__.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/api/__init__.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/api/app.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/api/connection_manager.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/api/deps.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/api/routers/__init__.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/api/routers/admin.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/api/routers/auth.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/api/routers/branding.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/api/routers/custom_tags.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/api/routers/fix_spec.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/api/routers/messages.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/api/routers/perf.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/api/routers/scenarios.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/api/routers/sessions.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/api/routers/setup.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/api/routers/spec_overlay.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/api/routers/templates.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/api/routers/ws.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/api/schemas.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/config/__init__.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/core/__init__.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/core/atomic_io.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/core/auth.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/core/config_store.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/core/custom_tag_store.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/core/db_migrations.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/core/events.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/core/exec_csv_writer.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/core/fix_application.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/core/fix_builder.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/core/fix_parser.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/core/fix_spec_parser.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/core/fix_tags.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/core/fix_time.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/core/housekeeping.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/core/inbound.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/core/json_store.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/core/message_log.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/core/message_store.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/core/models.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/core/perf_store.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/core/perf_writer.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/core/scenario_runner.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/core/scenario_store.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/core/session.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/core/session_manager.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/core/spec_overlay_store.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/core/template_store.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/core/user_store.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/core/venue_responses.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/fix_specs/FIX42.xml +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/fix_specs/FIX44.xml +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/server.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/static/assets/index-BwQf-cei.css +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/static/assets/index-CyNOPa0n.js +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/static/favicon.svg +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixture/ui/__init__.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixtureqa.egg-info/dependency_links.txt +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixtureqa.egg-info/entry_points.txt +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixtureqa.egg-info/requires.txt +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/fixtureqa.egg-info/top_level.txt +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/setup.cfg +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/tests/test_atomic_io.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/tests/test_auth.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/tests/test_config_store.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/tests/test_connection_manager.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/tests/test_db_migrations.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/tests/test_fix_builder.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/tests/test_health.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/tests/test_inbound.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/tests/test_inbound_validation.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/tests/test_message_store.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/tests/test_perf_api.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/tests/test_perf_models.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/tests/test_perf_rehydrate.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/tests/test_scenarios.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/tests/test_session_lifecycle.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/tests/test_session_manager_concurrency.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/tests/test_sessions.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/tests/test_templates.py +0 -0
- {fixtureqa-0.4.15 → fixtureqa-0.4.16}/tests/test_ws.py +0 -0
|
@@ -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.
|
|
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.
|
|
228
|
+
for t in list(self._tasks) + list(self._aux_tasks):
|
|
222
229
|
t.cancel()
|
|
223
|
-
await asyncio.gather(*self._tasks, *self.
|
|
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):
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
|
708
|
-
# they must not count as first
|
|
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
|
-
#
|
|
729
|
-
#
|
|
730
|
-
|
|
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"]
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
entry["amend_inflight"]
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
-
#
|
|
754
|
-
#
|
|
755
|
-
# the entry so 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
|
|
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
|
-
|
|
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
|
}
|