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