fixtureqa 0.4.10__tar.gz → 0.4.12__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.10/fixtureqa.egg-info → fixtureqa-0.4.12}/PKG-INFO +1 -1
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/perf_engine.py +127 -24
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/perf_models.py +11 -1
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/perf_payload.py +24 -4
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/perf_stats.py +2 -0
- fixtureqa-0.4.10/fixture/static/assets/index-BFPDQdQb.js → fixtureqa-0.4.12/fixture/static/assets/index-DHd_iEnT.js +22 -22
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/static/index.html +1 -1
- {fixtureqa-0.4.10 → fixtureqa-0.4.12/fixtureqa.egg-info}/PKG-INFO +1 -1
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixtureqa.egg-info/SOURCES.txt +1 -1
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/pyproject.toml +1 -1
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_perf_engine.py +135 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_perf_payload.py +53 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/LICENSE +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/README.md +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/__init__.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/__main__.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/api/__init__.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/api/app.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/api/connection_manager.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/api/deps.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/api/routers/__init__.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/api/routers/admin.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/api/routers/auth.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/api/routers/branding.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/api/routers/custom_tags.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/api/routers/fix_spec.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/api/routers/messages.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/api/routers/perf.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/api/routers/scenarios.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/api/routers/sessions.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/api/routers/setup.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/api/routers/spec_overlay.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/api/routers/templates.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/api/routers/ws.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/api/schemas.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/config/__init__.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/__init__.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/atomic_io.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/auth.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/config_store.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/custom_tag_store.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/db_migrations.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/events.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/exec_csv_writer.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/fix_application.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/fix_builder.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/fix_parser.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/fix_spec_parser.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/fix_tags.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/fix_time.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/housekeeping.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/inbound.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/json_store.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/message_log.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/message_store.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/models.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/perf_store.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/perf_writer.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/scenario_runner.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/scenario_store.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/session.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/session_manager.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/spec_overlay_store.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/template_store.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/user_store.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/core/venue_responses.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/fix_specs/FIX42.xml +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/fix_specs/FIX44.xml +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/server.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/static/assets/index-BwQf-cei.css +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/static/assets/index-CyNOPa0n.js +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/static/favicon.svg +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixture/ui/__init__.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixtureqa.egg-info/dependency_links.txt +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixtureqa.egg-info/entry_points.txt +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixtureqa.egg-info/requires.txt +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/fixtureqa.egg-info/top_level.txt +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/setup.cfg +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_atomic_io.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_auth.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_config_store.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_connection_manager.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_db_migrations.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_fix_builder.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_health.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_inbound.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_inbound_validation.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_message_store.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_perf_api.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_perf_models.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_perf_rehydrate.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_scenarios.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_session_lifecycle.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_session_manager_concurrency.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_sessions.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_templates.py +0 -0
- {fixtureqa-0.4.10 → fixtureqa-0.4.12}/tests/test_ws.py +0 -0
|
@@ -25,6 +25,7 @@ import os
|
|
|
25
25
|
import random
|
|
26
26
|
import time
|
|
27
27
|
import uuid
|
|
28
|
+
from collections import deque
|
|
28
29
|
from typing import Optional
|
|
29
30
|
|
|
30
31
|
from fixcore.message.message import Message
|
|
@@ -111,6 +112,13 @@ class PerfRun:
|
|
|
111
112
|
self._amend_on = config.amend.enabled and self._has_client
|
|
112
113
|
self._amend_tasks: set[asyncio.Task] = set()
|
|
113
114
|
|
|
115
|
+
# Venue order book: corr → resumable fill state. The dispatcher books
|
|
116
|
+
# orders; _fill_sender drains them round-robin (via _fill_runnable),
|
|
117
|
+
# pausing at predicted amend trigger points when amends are enabled.
|
|
118
|
+
self._venue_orders: dict[str, dict] = {}
|
|
119
|
+
self._fill_runnable: deque[str] = deque()
|
|
120
|
+
self._fill_wakeup = asyncio.Event()
|
|
121
|
+
|
|
114
122
|
# PayloadFactory validates templates/tokens now — raises PerfConfigError
|
|
115
123
|
# (surfaced as 422) before any task starts.
|
|
116
124
|
self._factory = PayloadFactory(config, template_store, owner_uid,
|
|
@@ -180,7 +188,7 @@ class PerfRun:
|
|
|
180
188
|
if self._has_client:
|
|
181
189
|
tasks += [self._injector(), self._correlator(), self._timeout_sweeper()]
|
|
182
190
|
if self._has_venue:
|
|
183
|
-
tasks += [self._fill_dispatcher()]
|
|
191
|
+
tasks += [self._fill_dispatcher(), self._fill_sender()]
|
|
184
192
|
self._tasks = [asyncio.create_task(t) for t in tasks]
|
|
185
193
|
dur = self.config.test.duration
|
|
186
194
|
try:
|
|
@@ -218,6 +226,8 @@ class PerfRun:
|
|
|
218
226
|
self.stats.amends_lost += 1
|
|
219
227
|
self._finalize(entry, "filled" if entry.get("completed") else "lost")
|
|
220
228
|
self._pending.clear()
|
|
229
|
+
self._venue_orders.clear()
|
|
230
|
+
self._fill_runnable.clear()
|
|
221
231
|
if self._client_sub:
|
|
222
232
|
self._client_sub.close()
|
|
223
233
|
if self._venue_sub:
|
|
@@ -245,8 +255,13 @@ class PerfRun:
|
|
|
245
255
|
r = self.config.rate
|
|
246
256
|
fill_rate = r.fills_per_window / (r.fill_window_ms / 1000.0) # fills/sec
|
|
247
257
|
quiet = max(0.3, (3.0 / fill_rate) if fill_rate > 0 else 0.3)
|
|
248
|
-
|
|
249
|
-
|
|
258
|
+
# Backlog = unread inbound orders (not yet booked) + booked orders'
|
|
259
|
+
# remaining scheduled fills. Held orders resume via grace at the
|
|
260
|
+
# latest (no new holds once injection stops), so add one grace.
|
|
261
|
+
backlog = (self._venue_sub.qsize() * max(1, self.config.payload.fill.fills_per_order)
|
|
262
|
+
+ sum(len(e["schedule"]) - e["idx"] for e in self._venue_orders.values()))
|
|
263
|
+
cap = time.perf_counter() + max(30.0, (backlog / max(1.0, fill_rate)) * 2.0
|
|
264
|
+
+ self.config.amend.amend_wait_ms / 1000.0 + 10.0)
|
|
250
265
|
|
|
251
266
|
last_sent = self.stats.fills_sent + self.stats.acks_sent
|
|
252
267
|
last_activity = time.perf_counter()
|
|
@@ -260,10 +275,12 @@ class PerfRun:
|
|
|
260
275
|
if cur_sent != last_sent:
|
|
261
276
|
last_sent = cur_sent
|
|
262
277
|
last_activity = now
|
|
263
|
-
# Done when the venue has drained its backlog
|
|
264
|
-
#
|
|
265
|
-
#
|
|
278
|
+
# Done when the venue has drained its backlog (inbound queue AND
|
|
279
|
+
# order book), the client has received the fills, and no new
|
|
280
|
+
# fill/ack has gone out for a quiet window (covers the
|
|
281
|
+
# rate-limited last order still being filled).
|
|
266
282
|
quiet_ok = (self._venue_sub.qsize() == 0 and self._client_sub.qsize() == 0
|
|
283
|
+
and not self._venue_orders
|
|
267
284
|
and (now - last_activity) >= quiet)
|
|
268
285
|
if quiet_ok:
|
|
269
286
|
break
|
|
@@ -396,6 +413,10 @@ class PerfRun:
|
|
|
396
413
|
# ------------------------------------------------------------------
|
|
397
414
|
|
|
398
415
|
async def _fill_dispatcher(self) -> None:
|
|
416
|
+
"""Venue inbound router. Never blocks on fill pacing: orders are booked
|
|
417
|
+
with a resumable schedule for _fill_sender, so an inbound 35=G is
|
|
418
|
+
handled the moment it arrives instead of queueing behind the very
|
|
419
|
+
fills that triggered it."""
|
|
399
420
|
cfg = self.config
|
|
400
421
|
f = cfg.payload.fill
|
|
401
422
|
sub = self._venue_sub
|
|
@@ -409,9 +430,9 @@ class PerfRun:
|
|
|
409
430
|
self.stats.orders_received += 1
|
|
410
431
|
of = inb.fields
|
|
411
432
|
order_qty = _to_int(of.get(T_ORDERQTY), 0)
|
|
412
|
-
|
|
433
|
+
corr = of.get(cfg.correlation_tag)
|
|
434
|
+
if corr is None or order_qty <= 0:
|
|
413
435
|
continue
|
|
414
|
-
px = of.get(T_PRICE) or "100.00"
|
|
415
436
|
|
|
416
437
|
# 1. Ack the order first (New) — immediate, not rate-limited.
|
|
417
438
|
if f.send_ack:
|
|
@@ -420,25 +441,92 @@ class PerfRun:
|
|
|
420
441
|
if ack is not None and await self._sm.send_message(cfg.venue_session_id, ack):
|
|
421
442
|
self.stats.acks_sent += 1
|
|
422
443
|
|
|
423
|
-
# 2.
|
|
444
|
+
# 2. Book the fills — gated by fill_ratio (a skipped order stays
|
|
445
|
+
# acked/open and is never booked).
|
|
424
446
|
if cfg.rate.fill_ratio < 1.0 and random.random() > cfg.rate.fill_ratio:
|
|
425
447
|
continue
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
448
|
+
schedule = self._fill_schedule(order_qty)
|
|
449
|
+
if not schedule:
|
|
450
|
+
continue
|
|
451
|
+
self._venue_orders[corr] = {
|
|
452
|
+
"of": of, "schedule": schedule, "idx": 0, "cum": 0,
|
|
453
|
+
"px": of.get(T_PRICE) or "100.00", "order_qty": order_qty,
|
|
454
|
+
"pauses_done": 0, "waiting": False, "wait_deadline": 0.0,
|
|
455
|
+
}
|
|
456
|
+
self._fill_runnable.append(corr)
|
|
457
|
+
self._fill_wakeup.set()
|
|
458
|
+
|
|
459
|
+
async def _fill_sender(self) -> None:
|
|
460
|
+
"""Drains all booked orders' fills round-robin through the shared fill
|
|
461
|
+
bucket — one order holding for its amend never blocks the others.
|
|
462
|
+
|
|
463
|
+
Amend-aware hold: after fill k of a still-working order, when amends
|
|
464
|
+
are enabled and k is a predicted trigger point (k % every_n_fills == 0,
|
|
465
|
+
pause cap not reached), the order waits up to amend_wait_ms for its
|
|
466
|
+
35=G; _handle_amend resumes it early, the grace check resumes it late.
|
|
467
|
+
No holds when amends are off or once injection has stopped (drain)."""
|
|
468
|
+
cfg = self.config
|
|
469
|
+
a = cfg.amend
|
|
470
|
+
book = self._venue_orders
|
|
471
|
+
runnable = self._fill_runnable
|
|
472
|
+
next_wait_check = 0.0
|
|
473
|
+
while True:
|
|
474
|
+
now = time.monotonic()
|
|
475
|
+
if now >= next_wait_check:
|
|
476
|
+
self._release_expired_waits(now)
|
|
477
|
+
next_wait_check = now + 0.05
|
|
478
|
+
if not runnable:
|
|
479
|
+
self._fill_wakeup.clear()
|
|
480
|
+
if not runnable:
|
|
481
|
+
try:
|
|
482
|
+
await asyncio.wait_for(self._fill_wakeup.wait(), timeout=0.05)
|
|
483
|
+
except asyncio.TimeoutError:
|
|
484
|
+
pass
|
|
485
|
+
continue
|
|
486
|
+
corr = runnable.popleft()
|
|
487
|
+
entry = book.get(corr)
|
|
488
|
+
if entry is None or entry["waiting"]:
|
|
489
|
+
continue # completed meanwhile, or re-queued while holding
|
|
490
|
+
await self._fill_bucket.acquire()
|
|
491
|
+
et, ost, last_qty, cum_qty, leaves_qty = entry["schedule"][entry["idx"]]
|
|
492
|
+
er = self._exec(entry["of"], exec_type=et, ord_status=ost, last_qty=last_qty,
|
|
493
|
+
cum_qty=cum_qty, leaves_qty=leaves_qty, px=entry["px"])
|
|
494
|
+
if er is not None and await self._sm.send_message(cfg.venue_session_id, er):
|
|
495
|
+
self.stats.fills_sent += 1
|
|
496
|
+
entry["idx"] += 1
|
|
497
|
+
entry["cum"] = cum_qty
|
|
498
|
+
if entry["idx"] >= len(entry["schedule"]):
|
|
499
|
+
book.pop(corr, None)
|
|
500
|
+
elif (a.enabled and self._injecting
|
|
501
|
+
and entry["idx"] % a.every_n_fills == 0
|
|
502
|
+
and (a.max_per_order == 0 or entry["pauses_done"] < a.max_per_order)):
|
|
503
|
+
entry["waiting"] = True
|
|
504
|
+
entry["wait_deadline"] = time.monotonic() + a.amend_wait_ms / 1000.0
|
|
505
|
+
else:
|
|
506
|
+
runnable.append(corr)
|
|
507
|
+
|
|
508
|
+
def _release_expired_waits(self, now: float) -> None:
|
|
509
|
+
"""Resume held orders whose 35=G never came (amend_ratio < 1, client
|
|
510
|
+
cap reached, or the amend was rejected upstream)."""
|
|
511
|
+
for corr, entry in self._venue_orders.items():
|
|
512
|
+
if entry["waiting"] and now >= entry["wait_deadline"]:
|
|
513
|
+
entry["waiting"] = False
|
|
514
|
+
entry["pauses_done"] += 1 # this trigger point is consumed
|
|
515
|
+
self.stats.amend_waits_expired += 1
|
|
516
|
+
self._fill_runnable.append(corr)
|
|
432
517
|
|
|
433
518
|
async def _handle_amend(self, of: dict) -> None:
|
|
434
519
|
"""Venue side of 35=G: reply Replaced (150=5/39=5), optionally preceded
|
|
435
|
-
by Pending Replace (150=E/39=E).
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
520
|
+
by Pending Replace (150=E/39=E). With the order in the book the reply
|
|
521
|
+
carries the REAL CumQty/LeavesQty, the order's price is updated so the
|
|
522
|
+
remaining fills go out at the amended price, and a held order resumes
|
|
523
|
+
immediately. Unknown orders (already completed, a late G, or venue
|
|
524
|
+
mode against an external client) fall back to the stateless reply
|
|
525
|
+
(CumQty=0). Like acks, replies are immediate, not fill-rate-limited."""
|
|
439
526
|
cfg = self.config
|
|
440
527
|
self.stats.amends_received += 1
|
|
441
|
-
|
|
528
|
+
corr = of.get(cfg.correlation_tag)
|
|
529
|
+
if corr is None:
|
|
442
530
|
# Can't reply — there is nothing to echo for the client to correlate.
|
|
443
531
|
# Classic cause: the OMS rebuilds the 35=G downstream and drops the
|
|
444
532
|
# custom tag it passes through fine on NewOrderSingle.
|
|
@@ -450,16 +538,30 @@ class PerfRun:
|
|
|
450
538
|
f"counterparty passes the tag through on amends")
|
|
451
539
|
return
|
|
452
540
|
order_qty = _to_int(of.get(T_ORDERQTY), 0)
|
|
453
|
-
px = of.get(T_PRICE) or "0"
|
|
454
541
|
orig = of.get(T_ORIGCLORDID)
|
|
542
|
+
entry = self._venue_orders.get(corr)
|
|
543
|
+
if entry is not None:
|
|
544
|
+
new_px = of.get(T_PRICE)
|
|
545
|
+
if new_px:
|
|
546
|
+
entry["px"] = new_px
|
|
547
|
+
cum = entry["cum"]
|
|
548
|
+
leaves = max(0, entry["order_qty"] - cum)
|
|
549
|
+
px = entry["px"]
|
|
550
|
+
entry["pauses_done"] += 1
|
|
551
|
+
if entry["waiting"]:
|
|
552
|
+
entry["waiting"] = False
|
|
553
|
+
self._fill_runnable.append(corr)
|
|
554
|
+
self._fill_wakeup.set()
|
|
555
|
+
else:
|
|
556
|
+
cum, leaves, px = 0, order_qty, of.get(T_PRICE) or "0"
|
|
455
557
|
if cfg.amend.pending_replace:
|
|
456
558
|
er = self._build_exec(of, exec_type="E", ord_status="E", last_qty=0,
|
|
457
|
-
cum_qty=
|
|
559
|
+
cum_qty=cum, leaves_qty=leaves, px=px,
|
|
458
560
|
orig_clordid=orig)
|
|
459
561
|
if er is not None:
|
|
460
562
|
await self._sm.send_message(cfg.venue_session_id, er)
|
|
461
563
|
er = self._build_exec(of, exec_type="5", ord_status="5", last_qty=0,
|
|
462
|
-
cum_qty=
|
|
564
|
+
cum_qty=cum, leaves_qty=leaves, px=px,
|
|
463
565
|
orig_clordid=orig)
|
|
464
566
|
if er is not None:
|
|
465
567
|
await self._sm.send_message(cfg.venue_session_id, er)
|
|
@@ -769,7 +871,8 @@ class PerfRun:
|
|
|
769
871
|
fill_ratio=round(self.stats.fill_ratio(), 3),
|
|
770
872
|
unfilled=max(0, self.stats.orders_sent - self.stats.completions),
|
|
771
873
|
amends_received=self.stats.amends_received,
|
|
772
|
-
amends_uncorrelated=self.stats.amends_uncorrelated
|
|
874
|
+
amends_uncorrelated=self.stats.amends_uncorrelated,
|
|
875
|
+
amend_waits_expired=self.stats.amend_waits_expired)
|
|
773
876
|
snap = LiveSnapshot(
|
|
774
877
|
run_id=self.run_id, status=self.status, mode=self.config.mode,
|
|
775
878
|
elapsed_s=round(self._elapsed_s(), 1), duration_s=self.config.test.duration,
|
|
@@ -66,13 +66,22 @@ class AmendConfig(BaseModel):
|
|
|
66
66
|
the price by ± `price_offset` (alternating). `pending_replace` makes the
|
|
67
67
|
in-process venue reply 150=E (Pending Replace) before 150=5 (Replaced),
|
|
68
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.
|
|
69
|
+
— use partial fills or fills_per_order > 1 to leave the order working.
|
|
70
|
+
|
|
71
|
+
When enabled, the in-process venue is amend-aware: it holds an order's
|
|
72
|
+
remaining fills at each predicted trigger point (every nth fill) for up
|
|
73
|
+
to `amend_wait_ms`, so the 35=G is answered while the order is still
|
|
74
|
+
working — with the real CumQty — and post-replace fills go out at the
|
|
75
|
+
amended price. Orders whose amend never arrives (amend_ratio < 1, cap
|
|
76
|
+
reached, or rejected upstream) resume after the grace. Disabled → no
|
|
77
|
+
holds, fills flow exactly as before."""
|
|
70
78
|
enabled: bool = False
|
|
71
79
|
every_n_fills: int = Field(default=1, ge=1)
|
|
72
80
|
amend_ratio: float = Field(default=1.0, ge=0.0, le=1.0)
|
|
73
81
|
max_per_order: int = Field(default=1, ge=0)
|
|
74
82
|
price_offset: float = Field(default=0.01, gt=0)
|
|
75
83
|
pending_replace: bool = False
|
|
84
|
+
amend_wait_ms: int = Field(default=2000, gt=0)
|
|
76
85
|
|
|
77
86
|
|
|
78
87
|
class OrderPayloadConfig(BaseModel):
|
|
@@ -217,6 +226,7 @@ class VenueLeg(BaseModel):
|
|
|
217
226
|
venue_proc_us_p50: float = 0.0
|
|
218
227
|
amends_received: int = 0
|
|
219
228
|
amends_uncorrelated: int = 0
|
|
229
|
+
amend_waits_expired: int = 0
|
|
220
230
|
|
|
221
231
|
|
|
222
232
|
class SnapshotErrors(BaseModel):
|
|
@@ -59,6 +59,13 @@ _EXEC_TOKENS = {"cum_qty", "leaves_qty", "last_qty", "order_qty", "ord_status",
|
|
|
59
59
|
|
|
60
60
|
_SIDE_BUY, _SIDE_SELL = "1", "2"
|
|
61
61
|
|
|
62
|
+
# Routing/identity tags mirrored from an order onto every 35=G in its chain —
|
|
63
|
+
# endpoints commonly require these to match the original order. Header tags
|
|
64
|
+
# 50/57/115/116/128/129/142/143/144/145 plus ClientID(109), the FIX 4.2 body
|
|
65
|
+
# tag for the same purpose. CompIDs (49/56) are transport-owned and excluded
|
|
66
|
+
# (the session stamps them).
|
|
67
|
+
AMEND_MIRROR_TAGS = (50, 57, 109, 115, 116, 128, 129, 142, 143, 144, 145)
|
|
68
|
+
|
|
62
69
|
|
|
63
70
|
class PerfConfigError(Exception):
|
|
64
71
|
"""Raised for bad templates/tokens at run start → surfaced as 422."""
|
|
@@ -314,7 +321,8 @@ class PayloadFactory:
|
|
|
314
321
|
self._apply_expiry(msg, self._gen_rules)
|
|
315
322
|
return msg, {"corr_id": corr, "clordid": clordid, "symbol": ctx["symbol"],
|
|
316
323
|
"side": ctx["side"], "qty": ctx["qty"], "price": ctx["price"],
|
|
317
|
-
"order_qty": ctx["qty"], "msg_type": "D"
|
|
324
|
+
"order_qty": ctx["qty"], "msg_type": "D",
|
|
325
|
+
"mirror_tags": self._mirror_tags(msg)}
|
|
318
326
|
|
|
319
327
|
# -- template message (scenarios) ----------------------------------
|
|
320
328
|
|
|
@@ -340,7 +348,8 @@ class PayloadFactory:
|
|
|
340
348
|
refs[tpl_id] = clordid
|
|
341
349
|
return msg, {"corr_id": corr, "clordid": clordid, "symbol": ctx["symbol"],
|
|
342
350
|
"side": ctx["side"], "qty": ctx["qty"], "price": ctx["price"],
|
|
343
|
-
"order_qty": ctx["qty"], "msg_type": mtype
|
|
351
|
+
"order_qty": ctx["qty"], "msg_type": mtype,
|
|
352
|
+
"mirror_tags": self._mirror_tags(msg)}
|
|
344
353
|
|
|
345
354
|
def _resolve(self, value: str, ctx: dict, corr: str, clordid: str, refs: dict,
|
|
346
355
|
tag: int = 0) -> str:
|
|
@@ -385,13 +394,21 @@ class PayloadFactory:
|
|
|
385
394
|
def _clordid(self) -> str:
|
|
386
395
|
return "PCL-" + _guid()[:12].upper()
|
|
387
396
|
|
|
397
|
+
@staticmethod
|
|
398
|
+
def _mirror_tags(msg: Message) -> dict[int, str]:
|
|
399
|
+
"""Rendered routing/identity header tags present on this order — stamped
|
|
400
|
+
verbatim onto every amend in its chain."""
|
|
401
|
+
return {t: v for t in AMEND_MIRROR_TAGS if (v := msg.get_field_or(t, ""))}
|
|
402
|
+
|
|
388
403
|
# -- amend (35=G OrderCancelReplaceRequest, price-only) -------------
|
|
389
404
|
|
|
390
405
|
def build_amend(self, meta: dict, new_price: float) -> tuple[Message, str]:
|
|
391
406
|
"""Re-price a working order. Carries the order's correlation id so the
|
|
392
407
|
replace acks route to the same pending entry; OrigClOrdID(41) is the
|
|
393
408
|
current chain tip, ClOrdID(11) is fresh — returned so the engine can
|
|
394
|
-
promote the chain once the venue confirms (150=5).
|
|
409
|
+
promote the chain once the venue confirms (150=5). Routing/identity
|
|
410
|
+
header tags (AMEND_MIRROR_TAGS) are echoed verbatim from the order —
|
|
411
|
+
endpoints commonly require them to match across the chain."""
|
|
395
412
|
p = self.config.payload.order
|
|
396
413
|
new_clordid = self._clordid()
|
|
397
414
|
msg = Message()
|
|
@@ -399,6 +416,8 @@ class PayloadFactory:
|
|
|
399
416
|
msg.set_field(11, new_clordid)
|
|
400
417
|
msg.set_field(41, meta["clordid"])
|
|
401
418
|
msg.set_field(self.config.correlation_tag, meta["corr_id"])
|
|
419
|
+
for tag, val in (meta.get("mirror_tags") or {}).items():
|
|
420
|
+
msg.set_field(tag, val)
|
|
402
421
|
msg.set_field(55, meta["symbol"])
|
|
403
422
|
msg.set_field(54, meta["side"])
|
|
404
423
|
msg.set_field(38, str(meta["order_qty"]))
|
|
@@ -463,7 +482,8 @@ class PayloadFactory:
|
|
|
463
482
|
self._apply_expiry(msg, fields)
|
|
464
483
|
return msg, {"corr_id": corr, "clordid": clordid, "symbol": ctx["symbol"],
|
|
465
484
|
"side": ctx["side"], "qty": ctx["qty"], "price": ctx["price"],
|
|
466
|
-
"order_qty": order_qty, "msg_type": mtype
|
|
485
|
+
"order_qty": order_qty, "msg_type": mtype,
|
|
486
|
+
"mirror_tags": self._mirror_tags(msg)}
|
|
467
487
|
|
|
468
488
|
# -- Phase B: templated ExecutionReport (venue ack/fill) ----------------
|
|
469
489
|
|
|
@@ -125,6 +125,7 @@ class PerfStats:
|
|
|
125
125
|
self.amend_rejects = 0 # client got an OrderCancelReject (35=9)
|
|
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
|
+
self.amend_waits_expired = 0 # venue fill-hold released by grace, not by a 35=G
|
|
128
129
|
self.lost_timeout = 0
|
|
129
130
|
self.dropped = 0
|
|
130
131
|
self.rejected = 0
|
|
@@ -184,6 +185,7 @@ class PerfStats:
|
|
|
184
185
|
"amend_rejects": self.amend_rejects,
|
|
185
186
|
"amends_received": self.amends_received,
|
|
186
187
|
"amends_uncorrelated": self.amends_uncorrelated,
|
|
188
|
+
"amend_waits_expired": self.amend_waits_expired,
|
|
187
189
|
"lost_timeout": self.lost_timeout,
|
|
188
190
|
"dropped": self.dropped,
|
|
189
191
|
"rejected": self.rejected,
|